nexo-brain 5.3.19 → 5.3.21

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared protocol-discipline settings loaded from calibration.json."""
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ DEFAULT_PROTOCOL_STRICTNESS = "lenient"
11
+ VALID_PROTOCOL_STRICTNESS = {"lenient", "strict", "learning"}
12
+
13
+
14
+ def _nexo_home() -> Path:
15
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
16
+
17
+
18
+ def _calibration_path() -> Path:
19
+ return _nexo_home() / "brain" / "calibration.json"
20
+
21
+
22
+ def normalize_protocol_strictness(value: str | None) -> str:
23
+ candidate = str(value or "").strip().lower()
24
+ aliases = {
25
+ "default": "lenient",
26
+ "normal": "lenient",
27
+ "off": "lenient",
28
+ "warn": "lenient",
29
+ "soft": "lenient",
30
+ "hard": "strict",
31
+ "guided": "learning",
32
+ }
33
+ candidate = aliases.get(candidate, candidate)
34
+ if candidate in VALID_PROTOCOL_STRICTNESS:
35
+ return candidate
36
+ return DEFAULT_PROTOCOL_STRICTNESS
37
+
38
+
39
+ def get_protocol_strictness() -> str:
40
+ env_override = os.environ.get("NEXO_PROTOCOL_STRICTNESS", "").strip()
41
+ if env_override:
42
+ return normalize_protocol_strictness(env_override)
43
+
44
+ cal_path = _calibration_path()
45
+ if not cal_path.is_file():
46
+ return DEFAULT_PROTOCOL_STRICTNESS
47
+
48
+ try:
49
+ payload = json.loads(cal_path.read_text())
50
+ except Exception:
51
+ return DEFAULT_PROTOCOL_STRICTNESS
52
+
53
+ preferences = payload.get("preferences") if isinstance(payload, dict) else {}
54
+ candidate = ""
55
+ if isinstance(preferences, dict):
56
+ candidate = str(preferences.get("protocol_strictness", "") or "").strip()
57
+ if not candidate and isinstance(payload, dict):
58
+ candidate = str(payload.get("protocol_strictness", "") or "").strip()
59
+ return normalize_protocol_strictness(candidate)
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+ """Public contribution preferences and GitHub PR workflow helpers.
3
+
4
+ This module manages the opt-in "public core evolution" mode:
5
+ - user consent and persisted config in schedule.json
6
+ - GitHub auth/fork detection
7
+ - active Draft PR pause/resume lifecycle
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import socket
16
+ import subprocess
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from runtime_power import load_schedule_config, save_schedule_config
21
+
22
+
23
+ UPSTREAM_REPO = "wazionapps/nexo"
24
+ CONFIG_KEY = "public_contribution"
25
+ CONSENT_VERSION = 1
26
+ MODE_UNSET = "unset"
27
+ MODE_OFF = "off"
28
+ MODE_DRAFT_PRS = "draft_prs"
29
+ MODE_PENDING_AUTH = "pending_auth"
30
+ STATUS_UNSET = "unset"
31
+ STATUS_ACTIVE = "active"
32
+ STATUS_PENDING_AUTH = "pending_auth"
33
+ STATUS_PAUSED_OPEN_PR = "paused_open_pr"
34
+ STATUS_COOLDOWN = "cooldown"
35
+ STATUS_OFF = "off"
36
+ VALID_MODES = {MODE_UNSET, MODE_OFF, MODE_DRAFT_PRS, MODE_PENDING_AUTH}
37
+ VALID_STATUSES = {
38
+ STATUS_UNSET,
39
+ STATUS_ACTIVE,
40
+ STATUS_PENDING_AUTH,
41
+ STATUS_PAUSED_OPEN_PR,
42
+ STATUS_COOLDOWN,
43
+ STATUS_OFF,
44
+ }
45
+
46
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
47
+ CONTRIB_ROOT = NEXO_HOME / "contrib" / "public-core"
48
+ CONTRIB_REPO_DIR = CONTRIB_ROOT / "repo"
49
+ CONTRIB_WORKTREES_DIR = CONTRIB_ROOT / "worktrees"
50
+ CONTRIB_ARTIFACTS_DIR = NEXO_HOME / "operations" / "public-contrib"
51
+
52
+
53
+ def _utcnow() -> datetime:
54
+ return datetime.now(timezone.utc)
55
+
56
+
57
+ def _machine_id() -> str:
58
+ raw = socket.gethostname().strip().lower() or "nexo-machine"
59
+ return re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-") or "nexo-machine"
60
+
61
+
62
+ def _default_public_contribution() -> dict:
63
+ return {
64
+ "enabled": False,
65
+ "mode": MODE_UNSET,
66
+ "consent_version": CONSENT_VERSION,
67
+ "github_user": "",
68
+ "upstream_repo": UPSTREAM_REPO,
69
+ "fork_repo": "",
70
+ "machine_id": _machine_id(),
71
+ "active_pr_url": "",
72
+ "active_pr_number": None,
73
+ "active_branch": "",
74
+ "status": STATUS_UNSET,
75
+ "cooldown_until": "",
76
+ "last_run_at": "",
77
+ "last_result": "",
78
+ }
79
+
80
+
81
+ def normalize_public_contribution_config(config: dict | None) -> dict:
82
+ merged = dict(_default_public_contribution())
83
+ if isinstance(config, dict):
84
+ merged.update(config)
85
+ merged["mode"] = str(merged.get("mode") or MODE_UNSET).strip().lower()
86
+ if merged["mode"] not in VALID_MODES:
87
+ merged["mode"] = MODE_UNSET
88
+ merged["status"] = str(merged.get("status") or STATUS_UNSET).strip().lower()
89
+ if merged["status"] not in VALID_STATUSES:
90
+ merged["status"] = STATUS_UNSET
91
+ merged["enabled"] = bool(merged.get("enabled", False))
92
+ merged["consent_version"] = CONSENT_VERSION
93
+ merged["upstream_repo"] = str(merged.get("upstream_repo") or UPSTREAM_REPO)
94
+ merged["github_user"] = str(merged.get("github_user") or "").strip()
95
+ merged["fork_repo"] = str(merged.get("fork_repo") or "").strip()
96
+ merged["machine_id"] = str(merged.get("machine_id") or _machine_id()).strip() or _machine_id()
97
+ merged["active_pr_url"] = str(merged.get("active_pr_url") or "").strip()
98
+ merged["active_pr_number"] = merged.get("active_pr_number")
99
+ if merged["active_pr_number"] in {"", 0, "0"}:
100
+ merged["active_pr_number"] = None
101
+ merged["active_branch"] = str(merged.get("active_branch") or "").strip()
102
+ merged["cooldown_until"] = str(merged.get("cooldown_until") or "").strip()
103
+ merged["last_run_at"] = str(merged.get("last_run_at") or "").strip()
104
+ merged["last_result"] = str(merged.get("last_result") or "").strip()
105
+ return merged
106
+
107
+
108
+ def load_public_contribution_config(schedule: dict | None = None) -> dict:
109
+ schedule = schedule or load_schedule_config()
110
+ return normalize_public_contribution_config(schedule.get(CONFIG_KEY))
111
+
112
+
113
+ def save_public_contribution_config(config: dict) -> dict:
114
+ schedule = load_schedule_config()
115
+ schedule[CONFIG_KEY] = normalize_public_contribution_config(config)
116
+ save_schedule_config(schedule)
117
+ return schedule[CONFIG_KEY]
118
+
119
+
120
+ def _gh(*args: str, cwd: Path | None = None, timeout: int = 20) -> subprocess.CompletedProcess:
121
+ env = os.environ.copy()
122
+ token = (
123
+ str(env.get("GH_TOKEN") or env.get("GITHUB_TOKEN") or "").strip()
124
+ or _github_token_from_credentials()
125
+ )
126
+ if token:
127
+ env["GH_TOKEN"] = token
128
+ return subprocess.run(
129
+ ["gh", *args],
130
+ cwd=str(cwd) if cwd else None,
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=timeout,
134
+ env=env,
135
+ )
136
+
137
+
138
+ def _github_token_from_credentials() -> str:
139
+ try:
140
+ from db import get_credential
141
+ except Exception:
142
+ return ""
143
+ for key in ("token", "gh_token", "github_token"):
144
+ try:
145
+ matches = get_credential("github", key)
146
+ except Exception:
147
+ continue
148
+ for item in matches or []:
149
+ value = str(item.get("value") or "").strip()
150
+ if value:
151
+ return value
152
+ return ""
153
+
154
+
155
+ def github_auth_status() -> dict:
156
+ if not shutil.which("gh"):
157
+ return {"ok": False, "message": "GitHub CLI not found.", "login": "", "code": "gh_missing"}
158
+ try:
159
+ result = _gh("api", "user", timeout=20)
160
+ except Exception as e:
161
+ return {"ok": False, "message": str(e), "login": "", "code": "gh_error"}
162
+ if result.returncode != 0:
163
+ message = (result.stderr or result.stdout).strip()
164
+ lowered = message.lower()
165
+ code = "auth_missing"
166
+ if "keychain" in lowered:
167
+ code = "keychain_blocked"
168
+ elif "token" in lowered or "authentication" in lowered or "login" in lowered:
169
+ code = "auth_missing"
170
+ return {"ok": False, "message": message, "login": "", "code": code}
171
+ try:
172
+ payload = json.loads(result.stdout or "{}")
173
+ login = str(payload.get("login") or "").strip()
174
+ except Exception:
175
+ login = ""
176
+ return {"ok": bool(login), "message": "", "login": login, "code": "ok" if login else "auth_missing"}
177
+
178
+
179
+ def ensure_fork(login: str) -> dict:
180
+ if not login:
181
+ return {"ok": False, "message": "Missing GitHub login.", "fork_repo": "", "code": "missing_login"}
182
+ fork_repo = f"{login}/nexo"
183
+ if not shutil.which("gh"):
184
+ return {"ok": False, "message": "GitHub CLI not found.", "fork_repo": "", "code": "gh_missing"}
185
+ try:
186
+ check = _gh("repo", "view", fork_repo, "--json", "nameWithOwner", timeout=20)
187
+ if check.returncode == 0:
188
+ return {"ok": True, "message": "", "fork_repo": fork_repo, "code": "ok"}
189
+ create = _gh("repo", "fork", UPSTREAM_REPO, "--clone=false", "--remote=false", timeout=60)
190
+ if create.returncode == 0:
191
+ return {"ok": True, "message": "", "fork_repo": fork_repo, "code": "ok"}
192
+ return {
193
+ "ok": False,
194
+ "message": (create.stderr or create.stdout or check.stderr or check.stdout).strip(),
195
+ "fork_repo": "",
196
+ "code": "fork_unavailable",
197
+ }
198
+ except Exception as e:
199
+ return {"ok": False, "message": str(e), "fork_repo": "", "code": "fork_error"}
200
+
201
+
202
+ def _set_pending_auth(config: dict, message: str) -> dict:
203
+ config["status"] = STATUS_PENDING_AUTH
204
+ config["last_result"] = f"pending_auth:{message}"
205
+ save_public_contribution_config(config)
206
+ config["message"] = message
207
+ return config
208
+
209
+
210
+ def _parse_iso(ts: str | None) -> datetime | None:
211
+ value = str(ts or "").strip()
212
+ if not value:
213
+ return None
214
+ try:
215
+ if value.endswith("Z"):
216
+ value = value[:-1] + "+00:00"
217
+ dt = datetime.fromisoformat(value)
218
+ if dt.tzinfo is None:
219
+ dt = dt.replace(tzinfo=timezone.utc)
220
+ return dt.astimezone(timezone.utc)
221
+ except Exception:
222
+ return None
223
+
224
+
225
+ def format_public_contribution_label(config: dict | None = None) -> str:
226
+ cfg = normalize_public_contribution_config(config)
227
+ if cfg["mode"] == MODE_DRAFT_PRS:
228
+ return f"draft_prs ({cfg['status']})"
229
+ return cfg["mode"]
230
+
231
+
232
+ def prompt_for_public_contribution(
233
+ *,
234
+ reason: str = "update",
235
+ input_fn=input,
236
+ output_fn=print,
237
+ ) -> dict:
238
+ output_fn("[NEXO] Public contribution mode is optional and opt-in.")
239
+ output_fn(
240
+ "[NEXO] If enabled, this machine may prepare core improvements in an isolated checkout "
241
+ "and open a Draft PR to the public NEXO repository."
242
+ )
243
+ output_fn("[NEXO] It never auto-merges, and it stays paused while that PR remains open.")
244
+ output_fn("[NEXO] It must never publish personal scripts, local runtime data, logs, prompts, or secrets.")
245
+
246
+ while True:
247
+ answer = str(
248
+ input_fn("[NEXO] Enable public contribution via Draft PRs on this machine? [y]es / [n]o / [l]ater: ")
249
+ ).strip().lower()
250
+ if answer in {"y", "yes"}:
251
+ auth = github_auth_status()
252
+ if not auth.get("ok"):
253
+ return {
254
+ "mode": MODE_PENDING_AUTH,
255
+ "status": STATUS_PENDING_AUTH,
256
+ "enabled": False,
257
+ "message": auth.get("message") or "GitHub authentication is missing.",
258
+ "github_user": "",
259
+ "fork_repo": "",
260
+ "prompted": True,
261
+ }
262
+ fork = ensure_fork(auth.get("login", ""))
263
+ if not fork.get("ok"):
264
+ return {
265
+ "mode": MODE_PENDING_AUTH,
266
+ "status": STATUS_PENDING_AUTH,
267
+ "enabled": False,
268
+ "message": fork.get("message") or "Could not ensure a GitHub fork.",
269
+ "github_user": auth.get("login", ""),
270
+ "fork_repo": "",
271
+ "prompted": True,
272
+ }
273
+ return {
274
+ "mode": MODE_DRAFT_PRS,
275
+ "status": STATUS_ACTIVE,
276
+ "enabled": True,
277
+ "message": "",
278
+ "github_user": auth.get("login", ""),
279
+ "fork_repo": fork.get("fork_repo", ""),
280
+ "prompted": True,
281
+ }
282
+ if answer in {"n", "no"}:
283
+ return {
284
+ "mode": MODE_OFF,
285
+ "status": STATUS_OFF,
286
+ "enabled": False,
287
+ "message": "",
288
+ "github_user": "",
289
+ "fork_repo": "",
290
+ "prompted": True,
291
+ }
292
+ if answer in {"l", "later", ""}:
293
+ return {
294
+ "mode": MODE_UNSET,
295
+ "status": STATUS_UNSET,
296
+ "enabled": False,
297
+ "message": "",
298
+ "github_user": "",
299
+ "fork_repo": "",
300
+ "prompted": True,
301
+ }
302
+ output_fn("[NEXO] Reply with yes, no, or later.")
303
+
304
+
305
+ def ensure_public_contribution_choice(
306
+ *,
307
+ interactive: bool,
308
+ reason: str = "update",
309
+ input_fn=input,
310
+ output_fn=print,
311
+ force_prompt: bool = False,
312
+ ) -> dict:
313
+ config = load_public_contribution_config()
314
+ prompted = False
315
+ if interactive and (force_prompt or config["mode"] == MODE_UNSET):
316
+ prompted = True
317
+ result = prompt_for_public_contribution(reason=reason, input_fn=input_fn, output_fn=output_fn)
318
+ config.update({
319
+ "enabled": result["enabled"],
320
+ "mode": result["mode"],
321
+ "status": result["status"],
322
+ "github_user": result["github_user"],
323
+ "fork_repo": result["fork_repo"],
324
+ "machine_id": config.get("machine_id") or _machine_id(),
325
+ })
326
+ if result["mode"] != MODE_DRAFT_PRS:
327
+ config["active_pr_url"] = ""
328
+ config["active_pr_number"] = None
329
+ config["active_branch"] = ""
330
+ save_public_contribution_config(config)
331
+ config = load_public_contribution_config()
332
+ config["message"] = result.get("message", "")
333
+ else:
334
+ config["message"] = ""
335
+ config["prompted"] = prompted
336
+ return config
337
+
338
+
339
+ def refresh_public_contribution_state(config: dict | None = None) -> dict:
340
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
341
+ if config["mode"] != MODE_DRAFT_PRS:
342
+ return config
343
+
344
+ if config.get("active_pr_number") and config.get("active_pr_url"):
345
+ try:
346
+ result = _gh(
347
+ "pr",
348
+ "view",
349
+ str(config["active_pr_number"]),
350
+ "--repo",
351
+ config["upstream_repo"],
352
+ "--json",
353
+ "state,isDraft,url,mergedAt,closed",
354
+ timeout=20,
355
+ )
356
+ except Exception as e:
357
+ config["last_result"] = f"pr_status_error:{e}"
358
+ save_public_contribution_config(config)
359
+ return config
360
+ if result.returncode == 0:
361
+ payload = json.loads(result.stdout or "{}")
362
+ if payload.get("state") == "OPEN" and payload.get("isDraft", False):
363
+ config["status"] = STATUS_PAUSED_OPEN_PR
364
+ save_public_contribution_config(config)
365
+ return config
366
+ resolution = "merged" if payload.get("mergedAt") else "closed"
367
+ config["active_pr_url"] = ""
368
+ config["active_pr_number"] = None
369
+ config["active_branch"] = ""
370
+ config["cooldown_until"] = ""
371
+ config["status"] = STATUS_ACTIVE
372
+ config["last_result"] = f"resolved_pr:{resolution}:{payload.get('url') or ''}".rstrip(":")
373
+ save_public_contribution_config(config)
374
+ return config
375
+ return _set_pending_auth(
376
+ config,
377
+ f"GitHub Draft PR status check failed: {(result.stderr or result.stdout).strip() or 'unknown gh error'}",
378
+ )
379
+
380
+ cooldown_until = _parse_iso(config.get("cooldown_until"))
381
+ if cooldown_until and cooldown_until > _utcnow():
382
+ # Legacy installs used a post-merge/close cooldown that blocked the next
383
+ # public contribution cycle even after maintainers resolved the Draft PR.
384
+ # Public contribution should pause only while the PR is still open.
385
+ config["cooldown_until"] = ""
386
+ config["status"] = STATUS_ACTIVE
387
+ save_public_contribution_config(config)
388
+ return config
389
+
390
+ auth = github_auth_status()
391
+ if not auth.get("ok"):
392
+ return _set_pending_auth(
393
+ config,
394
+ auth.get("message") or "GitHub authentication is missing for public contribution.",
395
+ )
396
+ login = str(auth.get("login") or "").strip()
397
+ configured_login = str(config.get("github_user") or "").strip()
398
+ if configured_login and login and configured_login.lower() != login.lower():
399
+ return _set_pending_auth(
400
+ config,
401
+ f"GitHub login drift detected: configured {configured_login}, current {login}. Reconfirm public contribution credentials.",
402
+ )
403
+ if login and not configured_login:
404
+ config["github_user"] = login
405
+
406
+ if not str(config.get("fork_repo") or "").strip():
407
+ fork = ensure_fork(login)
408
+ if not fork.get("ok"):
409
+ return _set_pending_auth(
410
+ config,
411
+ fork.get("message") or "GitHub fork setup is missing for public contribution.",
412
+ )
413
+ config["fork_repo"] = str(fork.get("fork_repo") or "").strip()
414
+
415
+ if config["mode"] == MODE_PENDING_AUTH:
416
+ config["status"] = STATUS_PENDING_AUTH
417
+ else:
418
+ config["status"] = STATUS_ACTIVE
419
+ save_public_contribution_config(config)
420
+ return config
421
+
422
+
423
+ def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str, dict]:
424
+ config = refresh_public_contribution_state(config)
425
+ if config["mode"] == MODE_PENDING_AUTH or config["status"] == STATUS_PENDING_AUTH:
426
+ detail = str(config.get("message") or config.get("last_result") or "").strip()
427
+ return False, detail or "github authentication or fork setup is pending", config
428
+ if config["mode"] != MODE_DRAFT_PRS or not config.get("enabled"):
429
+ return False, "public contribution is disabled", config
430
+ if config["status"] == STATUS_PAUSED_OPEN_PR:
431
+ return False, "an active Draft PR is already open for this machine", config
432
+ return True, "", config
433
+
434
+
435
+ def mark_public_contribution_result(*, result: str, config: dict | None = None) -> dict:
436
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
437
+ config["last_run_at"] = _utcnow().isoformat()
438
+ config["last_result"] = str(result or "")
439
+ save_public_contribution_config(config)
440
+ return config
441
+
442
+
443
+ def mark_active_pr(*, pr_url: str, pr_number: int | None, branch: str, config: dict | None = None) -> dict:
444
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
445
+ config["active_pr_url"] = pr_url
446
+ config["active_pr_number"] = pr_number
447
+ config["active_branch"] = branch
448
+ config["status"] = STATUS_PAUSED_OPEN_PR
449
+ config["last_run_at"] = _utcnow().isoformat()
450
+ config["last_result"] = "draft_pr_created"
451
+ save_public_contribution_config(config)
452
+ return config
453
+
454
+
455
+ def disable_public_contribution() -> dict:
456
+ config = load_public_contribution_config()
457
+ config.update({
458
+ "enabled": False,
459
+ "mode": MODE_OFF,
460
+ "status": STATUS_OFF,
461
+ "active_pr_url": "",
462
+ "active_pr_number": None,
463
+ "active_branch": "",
464
+ })
465
+ save_public_contribution_config(config)
466
+ return config