nexo-brain 2.6.5 → 2.6.7
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +911 -143
- package/bin/nexo-brain.js +256 -0
- package/package.json +1 -1
- package/src/auto_close_sessions.py +24 -4
- package/src/auto_update.py +45 -6
- package/src/cli.py +136 -3
- package/src/db/_episodic.py +5 -16
- package/src/doctor/providers/runtime.py +5 -0
- package/src/evolution_cycle.py +51 -1
- package/src/plugins/episodic_memory.py +1 -1
- package/src/plugins/personal_plugins.py +135 -0
- package/src/plugins/update.py +25 -3
- package/src/public_contribution.py +396 -0
- package/src/runtime_power.py +416 -0
- package/src/scripts/nexo-evolution-run.py +394 -2
- package/templates/plugin-template.py +36 -0
|
@@ -0,0 +1,396 @@
|
|
|
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, timedelta, 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
|
+
COOLDOWN_HOURS_MERGED = 168
|
|
37
|
+
COOLDOWN_HOURS_CLOSED = 72
|
|
38
|
+
VALID_MODES = {MODE_UNSET, MODE_OFF, MODE_DRAFT_PRS, MODE_PENDING_AUTH}
|
|
39
|
+
VALID_STATUSES = {
|
|
40
|
+
STATUS_UNSET,
|
|
41
|
+
STATUS_ACTIVE,
|
|
42
|
+
STATUS_PENDING_AUTH,
|
|
43
|
+
STATUS_PAUSED_OPEN_PR,
|
|
44
|
+
STATUS_COOLDOWN,
|
|
45
|
+
STATUS_OFF,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
49
|
+
CONTRIB_ROOT = NEXO_HOME / "contrib" / "public-core"
|
|
50
|
+
CONTRIB_REPO_DIR = CONTRIB_ROOT / "repo"
|
|
51
|
+
CONTRIB_WORKTREES_DIR = CONTRIB_ROOT / "worktrees"
|
|
52
|
+
CONTRIB_ARTIFACTS_DIR = NEXO_HOME / "operations" / "public-contrib"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _utcnow() -> datetime:
|
|
56
|
+
return datetime.now(timezone.utc)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _machine_id() -> str:
|
|
60
|
+
raw = socket.gethostname().strip().lower() or "nexo-machine"
|
|
61
|
+
return re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-") or "nexo-machine"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _default_public_contribution() -> dict:
|
|
65
|
+
return {
|
|
66
|
+
"enabled": False,
|
|
67
|
+
"mode": MODE_UNSET,
|
|
68
|
+
"consent_version": CONSENT_VERSION,
|
|
69
|
+
"github_user": "",
|
|
70
|
+
"upstream_repo": UPSTREAM_REPO,
|
|
71
|
+
"fork_repo": "",
|
|
72
|
+
"machine_id": _machine_id(),
|
|
73
|
+
"active_pr_url": "",
|
|
74
|
+
"active_pr_number": None,
|
|
75
|
+
"active_branch": "",
|
|
76
|
+
"status": STATUS_UNSET,
|
|
77
|
+
"cooldown_until": "",
|
|
78
|
+
"last_run_at": "",
|
|
79
|
+
"last_result": "",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalize_public_contribution_config(config: dict | None) -> dict:
|
|
84
|
+
merged = dict(_default_public_contribution())
|
|
85
|
+
if isinstance(config, dict):
|
|
86
|
+
merged.update(config)
|
|
87
|
+
merged["mode"] = str(merged.get("mode") or MODE_UNSET).strip().lower()
|
|
88
|
+
if merged["mode"] not in VALID_MODES:
|
|
89
|
+
merged["mode"] = MODE_UNSET
|
|
90
|
+
merged["status"] = str(merged.get("status") or STATUS_UNSET).strip().lower()
|
|
91
|
+
if merged["status"] not in VALID_STATUSES:
|
|
92
|
+
merged["status"] = STATUS_UNSET
|
|
93
|
+
merged["enabled"] = bool(merged.get("enabled", False))
|
|
94
|
+
merged["consent_version"] = CONSENT_VERSION
|
|
95
|
+
merged["upstream_repo"] = str(merged.get("upstream_repo") or UPSTREAM_REPO)
|
|
96
|
+
merged["github_user"] = str(merged.get("github_user") or "").strip()
|
|
97
|
+
merged["fork_repo"] = str(merged.get("fork_repo") or "").strip()
|
|
98
|
+
merged["machine_id"] = str(merged.get("machine_id") or _machine_id()).strip() or _machine_id()
|
|
99
|
+
merged["active_pr_url"] = str(merged.get("active_pr_url") or "").strip()
|
|
100
|
+
merged["active_pr_number"] = merged.get("active_pr_number")
|
|
101
|
+
if merged["active_pr_number"] in {"", 0, "0"}:
|
|
102
|
+
merged["active_pr_number"] = None
|
|
103
|
+
merged["active_branch"] = str(merged.get("active_branch") or "").strip()
|
|
104
|
+
merged["cooldown_until"] = str(merged.get("cooldown_until") or "").strip()
|
|
105
|
+
merged["last_run_at"] = str(merged.get("last_run_at") or "").strip()
|
|
106
|
+
merged["last_result"] = str(merged.get("last_result") or "").strip()
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_public_contribution_config(schedule: dict | None = None) -> dict:
|
|
111
|
+
schedule = schedule or load_schedule_config()
|
|
112
|
+
return normalize_public_contribution_config(schedule.get(CONFIG_KEY))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def save_public_contribution_config(config: dict) -> dict:
|
|
116
|
+
schedule = load_schedule_config()
|
|
117
|
+
schedule[CONFIG_KEY] = normalize_public_contribution_config(config)
|
|
118
|
+
save_schedule_config(schedule)
|
|
119
|
+
return schedule[CONFIG_KEY]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _gh(*args: str, cwd: Path | None = None, timeout: int = 20) -> subprocess.CompletedProcess:
|
|
123
|
+
return subprocess.run(
|
|
124
|
+
["gh", *args],
|
|
125
|
+
cwd=str(cwd) if cwd else None,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def github_auth_status() -> dict:
|
|
133
|
+
if not shutil.which("gh"):
|
|
134
|
+
return {"ok": False, "message": "GitHub CLI not found.", "login": ""}
|
|
135
|
+
try:
|
|
136
|
+
result = _gh("api", "user", timeout=20)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
return {"ok": False, "message": str(e), "login": ""}
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
return {"ok": False, "message": (result.stderr or result.stdout).strip(), "login": ""}
|
|
141
|
+
try:
|
|
142
|
+
payload = json.loads(result.stdout or "{}")
|
|
143
|
+
login = str(payload.get("login") or "").strip()
|
|
144
|
+
except Exception:
|
|
145
|
+
login = ""
|
|
146
|
+
return {"ok": bool(login), "message": "", "login": login}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ensure_fork(login: str) -> dict:
|
|
150
|
+
if not login:
|
|
151
|
+
return {"ok": False, "message": "Missing GitHub login.", "fork_repo": ""}
|
|
152
|
+
fork_repo = f"{login}/nexo"
|
|
153
|
+
if not shutil.which("gh"):
|
|
154
|
+
return {"ok": False, "message": "GitHub CLI not found.", "fork_repo": ""}
|
|
155
|
+
try:
|
|
156
|
+
check = _gh("repo", "view", fork_repo, "--json", "nameWithOwner", timeout=20)
|
|
157
|
+
if check.returncode == 0:
|
|
158
|
+
return {"ok": True, "message": "", "fork_repo": fork_repo}
|
|
159
|
+
create = _gh("repo", "fork", UPSTREAM_REPO, "--clone=false", "--remote=false", timeout=60)
|
|
160
|
+
if create.returncode == 0:
|
|
161
|
+
return {"ok": True, "message": "", "fork_repo": fork_repo}
|
|
162
|
+
return {
|
|
163
|
+
"ok": False,
|
|
164
|
+
"message": (create.stderr or create.stdout or check.stderr or check.stdout).strip(),
|
|
165
|
+
"fork_repo": "",
|
|
166
|
+
}
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return {"ok": False, "message": str(e), "fork_repo": ""}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_iso(ts: str | None) -> datetime | None:
|
|
172
|
+
value = str(ts or "").strip()
|
|
173
|
+
if not value:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
if value.endswith("Z"):
|
|
177
|
+
value = value[:-1] + "+00:00"
|
|
178
|
+
dt = datetime.fromisoformat(value)
|
|
179
|
+
if dt.tzinfo is None:
|
|
180
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
181
|
+
return dt.astimezone(timezone.utc)
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _future_iso(hours: int) -> str:
|
|
187
|
+
return (_utcnow() + timedelta(hours=hours)).isoformat()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_public_contribution_label(config: dict | None = None) -> str:
|
|
191
|
+
cfg = normalize_public_contribution_config(config)
|
|
192
|
+
if cfg["mode"] == MODE_DRAFT_PRS:
|
|
193
|
+
return f"draft_prs ({cfg['status']})"
|
|
194
|
+
return cfg["mode"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def prompt_for_public_contribution(
|
|
198
|
+
*,
|
|
199
|
+
reason: str = "update",
|
|
200
|
+
input_fn=input,
|
|
201
|
+
output_fn=print,
|
|
202
|
+
) -> dict:
|
|
203
|
+
output_fn("[NEXO] Public contribution mode is optional and opt-in.")
|
|
204
|
+
output_fn(
|
|
205
|
+
"[NEXO] If enabled, this machine may prepare core improvements in an isolated checkout "
|
|
206
|
+
"and open a Draft PR to the public NEXO repository."
|
|
207
|
+
)
|
|
208
|
+
output_fn("[NEXO] It never auto-merges, and it stays paused while that PR remains open.")
|
|
209
|
+
output_fn("[NEXO] It must never publish personal scripts, local runtime data, logs, prompts, or secrets.")
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
answer = str(
|
|
213
|
+
input_fn("[NEXO] Enable public contribution via Draft PRs on this machine? [y]es / [n]o / [l]ater: ")
|
|
214
|
+
).strip().lower()
|
|
215
|
+
if answer in {"y", "yes"}:
|
|
216
|
+
auth = github_auth_status()
|
|
217
|
+
if not auth.get("ok"):
|
|
218
|
+
return {
|
|
219
|
+
"mode": MODE_PENDING_AUTH,
|
|
220
|
+
"status": STATUS_PENDING_AUTH,
|
|
221
|
+
"enabled": False,
|
|
222
|
+
"message": auth.get("message") or "GitHub authentication is missing.",
|
|
223
|
+
"github_user": "",
|
|
224
|
+
"fork_repo": "",
|
|
225
|
+
"prompted": True,
|
|
226
|
+
}
|
|
227
|
+
fork = ensure_fork(auth.get("login", ""))
|
|
228
|
+
if not fork.get("ok"):
|
|
229
|
+
return {
|
|
230
|
+
"mode": MODE_PENDING_AUTH,
|
|
231
|
+
"status": STATUS_PENDING_AUTH,
|
|
232
|
+
"enabled": False,
|
|
233
|
+
"message": fork.get("message") or "Could not ensure a GitHub fork.",
|
|
234
|
+
"github_user": auth.get("login", ""),
|
|
235
|
+
"fork_repo": "",
|
|
236
|
+
"prompted": True,
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
"mode": MODE_DRAFT_PRS,
|
|
240
|
+
"status": STATUS_ACTIVE,
|
|
241
|
+
"enabled": True,
|
|
242
|
+
"message": "",
|
|
243
|
+
"github_user": auth.get("login", ""),
|
|
244
|
+
"fork_repo": fork.get("fork_repo", ""),
|
|
245
|
+
"prompted": True,
|
|
246
|
+
}
|
|
247
|
+
if answer in {"n", "no"}:
|
|
248
|
+
return {
|
|
249
|
+
"mode": MODE_OFF,
|
|
250
|
+
"status": STATUS_OFF,
|
|
251
|
+
"enabled": False,
|
|
252
|
+
"message": "",
|
|
253
|
+
"github_user": "",
|
|
254
|
+
"fork_repo": "",
|
|
255
|
+
"prompted": True,
|
|
256
|
+
}
|
|
257
|
+
if answer in {"l", "later", ""}:
|
|
258
|
+
return {
|
|
259
|
+
"mode": MODE_UNSET,
|
|
260
|
+
"status": STATUS_UNSET,
|
|
261
|
+
"enabled": False,
|
|
262
|
+
"message": "",
|
|
263
|
+
"github_user": "",
|
|
264
|
+
"fork_repo": "",
|
|
265
|
+
"prompted": True,
|
|
266
|
+
}
|
|
267
|
+
output_fn("[NEXO] Reply with yes, no, or later.")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def ensure_public_contribution_choice(
|
|
271
|
+
*,
|
|
272
|
+
interactive: bool,
|
|
273
|
+
reason: str = "update",
|
|
274
|
+
input_fn=input,
|
|
275
|
+
output_fn=print,
|
|
276
|
+
force_prompt: bool = False,
|
|
277
|
+
) -> dict:
|
|
278
|
+
config = load_public_contribution_config()
|
|
279
|
+
prompted = False
|
|
280
|
+
if interactive and (force_prompt or config["mode"] == MODE_UNSET):
|
|
281
|
+
prompted = True
|
|
282
|
+
result = prompt_for_public_contribution(reason=reason, input_fn=input_fn, output_fn=output_fn)
|
|
283
|
+
config.update({
|
|
284
|
+
"enabled": result["enabled"],
|
|
285
|
+
"mode": result["mode"],
|
|
286
|
+
"status": result["status"],
|
|
287
|
+
"github_user": result["github_user"],
|
|
288
|
+
"fork_repo": result["fork_repo"],
|
|
289
|
+
"machine_id": config.get("machine_id") or _machine_id(),
|
|
290
|
+
})
|
|
291
|
+
if result["mode"] != MODE_DRAFT_PRS:
|
|
292
|
+
config["active_pr_url"] = ""
|
|
293
|
+
config["active_pr_number"] = None
|
|
294
|
+
config["active_branch"] = ""
|
|
295
|
+
save_public_contribution_config(config)
|
|
296
|
+
config = load_public_contribution_config()
|
|
297
|
+
config["message"] = result.get("message", "")
|
|
298
|
+
else:
|
|
299
|
+
config["message"] = ""
|
|
300
|
+
config["prompted"] = prompted
|
|
301
|
+
return config
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def refresh_public_contribution_state(config: dict | None = None) -> dict:
|
|
305
|
+
config = normalize_public_contribution_config(config or load_public_contribution_config())
|
|
306
|
+
if config["mode"] != MODE_DRAFT_PRS:
|
|
307
|
+
return config
|
|
308
|
+
|
|
309
|
+
if config.get("active_pr_number") and config.get("active_pr_url"):
|
|
310
|
+
try:
|
|
311
|
+
result = _gh(
|
|
312
|
+
"pr",
|
|
313
|
+
"view",
|
|
314
|
+
str(config["active_pr_number"]),
|
|
315
|
+
"--repo",
|
|
316
|
+
config["upstream_repo"],
|
|
317
|
+
"--json",
|
|
318
|
+
"state,isDraft,url,mergedAt,closed",
|
|
319
|
+
timeout=20,
|
|
320
|
+
)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
config["last_result"] = f"pr_status_error:{e}"
|
|
323
|
+
save_public_contribution_config(config)
|
|
324
|
+
return config
|
|
325
|
+
if result.returncode == 0:
|
|
326
|
+
payload = json.loads(result.stdout or "{}")
|
|
327
|
+
if payload.get("state") == "OPEN" and payload.get("isDraft", False):
|
|
328
|
+
config["status"] = STATUS_PAUSED_OPEN_PR
|
|
329
|
+
save_public_contribution_config(config)
|
|
330
|
+
return config
|
|
331
|
+
config["active_pr_url"] = ""
|
|
332
|
+
config["active_pr_number"] = None
|
|
333
|
+
config["active_branch"] = ""
|
|
334
|
+
cooldown_hours = COOLDOWN_HOURS_MERGED if payload.get("mergedAt") else COOLDOWN_HOURS_CLOSED
|
|
335
|
+
config["cooldown_until"] = _future_iso(cooldown_hours)
|
|
336
|
+
config["status"] = STATUS_COOLDOWN
|
|
337
|
+
save_public_contribution_config(config)
|
|
338
|
+
return config
|
|
339
|
+
|
|
340
|
+
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
341
|
+
if cooldown_until and cooldown_until > _utcnow():
|
|
342
|
+
config["status"] = STATUS_COOLDOWN
|
|
343
|
+
elif config["mode"] == MODE_PENDING_AUTH:
|
|
344
|
+
config["status"] = STATUS_PENDING_AUTH
|
|
345
|
+
elif config["mode"] == MODE_DRAFT_PRS:
|
|
346
|
+
config["status"] = STATUS_ACTIVE
|
|
347
|
+
save_public_contribution_config(config)
|
|
348
|
+
return config
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str, dict]:
|
|
352
|
+
config = refresh_public_contribution_state(config)
|
|
353
|
+
if config["mode"] == MODE_PENDING_AUTH or config["status"] == STATUS_PENDING_AUTH:
|
|
354
|
+
return False, "github authentication or fork setup is pending", config
|
|
355
|
+
if config["mode"] != MODE_DRAFT_PRS or not config.get("enabled"):
|
|
356
|
+
return False, "public contribution is disabled", config
|
|
357
|
+
if config["status"] == STATUS_PAUSED_OPEN_PR:
|
|
358
|
+
return False, "an active Draft PR is already open for this machine", config
|
|
359
|
+
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
360
|
+
if cooldown_until and cooldown_until > _utcnow():
|
|
361
|
+
return False, f"cooldown until {cooldown_until.isoformat()}", config
|
|
362
|
+
return True, "", config
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def mark_public_contribution_result(*, result: str, config: dict | None = None) -> dict:
|
|
366
|
+
config = normalize_public_contribution_config(config or load_public_contribution_config())
|
|
367
|
+
config["last_run_at"] = _utcnow().isoformat()
|
|
368
|
+
config["last_result"] = str(result or "")
|
|
369
|
+
save_public_contribution_config(config)
|
|
370
|
+
return config
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def mark_active_pr(*, pr_url: str, pr_number: int | None, branch: str, config: dict | None = None) -> dict:
|
|
374
|
+
config = normalize_public_contribution_config(config or load_public_contribution_config())
|
|
375
|
+
config["active_pr_url"] = pr_url
|
|
376
|
+
config["active_pr_number"] = pr_number
|
|
377
|
+
config["active_branch"] = branch
|
|
378
|
+
config["status"] = STATUS_PAUSED_OPEN_PR
|
|
379
|
+
config["last_run_at"] = _utcnow().isoformat()
|
|
380
|
+
config["last_result"] = "draft_pr_created"
|
|
381
|
+
save_public_contribution_config(config)
|
|
382
|
+
return config
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def disable_public_contribution() -> dict:
|
|
386
|
+
config = load_public_contribution_config()
|
|
387
|
+
config.update({
|
|
388
|
+
"enabled": False,
|
|
389
|
+
"mode": MODE_OFF,
|
|
390
|
+
"status": STATUS_OFF,
|
|
391
|
+
"active_pr_url": "",
|
|
392
|
+
"active_pr_number": None,
|
|
393
|
+
"active_branch": "",
|
|
394
|
+
})
|
|
395
|
+
save_public_contribution_config(config)
|
|
396
|
+
return config
|