nexo-brain 2.6.6 → 2.6.9

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.
@@ -13,6 +13,7 @@ PROPOSE proposals are logged for the user's review.
13
13
  import json
14
14
  import os
15
15
  import py_compile
16
+ import re
16
17
  import sqlite3
17
18
  import subprocess
18
19
  import sys
@@ -108,8 +109,11 @@ def _normalize_mode(mode: str) -> str:
108
109
  "core": "managed",
109
110
  "hybrid": "managed",
110
111
  "manual": "review",
112
+ "public": "public_core",
113
+ "contributor": "public_core",
114
+ "draft_prs": "public_core",
111
115
  }
112
- return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
116
+ return aliases.get(value, value if value in {"auto", "review", "managed", "public_core"} else "auto")
113
117
 
114
118
 
115
119
  def _immutable_files_for_mode(mode: str) -> set[str]:
@@ -140,6 +144,15 @@ def _resolve_claude_cli() -> Path:
140
144
  return Path.home() / ".local" / "bin" / "claude"
141
145
 
142
146
  CLAUDE_CLI = _resolve_claude_cli()
147
+ PUBLIC_ALLOWED_PREFIXES = (
148
+ "src/",
149
+ "bin/",
150
+ "tests/",
151
+ "templates/",
152
+ "hooks/",
153
+ "migrations/",
154
+ ".claude-plugin/",
155
+ )
143
156
 
144
157
  # ── Logging ──────────────────────────────────────────────────────────────
145
158
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -158,7 +171,17 @@ def log(msg: str):
158
171
  sys.path.insert(0, str(NEXO_CODE))
159
172
  from evolution_cycle import (
160
173
  load_objective, save_objective, get_week_data, build_evolution_prompt,
161
- dry_run_restore_test, max_auto_changes, create_snapshot
174
+ dry_run_restore_test, max_auto_changes, create_snapshot, build_public_contribution_prompt
175
+ )
176
+ from public_contribution import (
177
+ CONTRIB_ARTIFACTS_DIR,
178
+ CONTRIB_REPO_DIR,
179
+ CONTRIB_WORKTREES_DIR,
180
+ UPSTREAM_REPO,
181
+ can_run_public_contribution,
182
+ load_public_contribution_config,
183
+ mark_active_pr,
184
+ mark_public_contribution_result,
162
185
  )
163
186
 
164
187
 
@@ -213,6 +236,364 @@ def call_claude_cli(prompt: str) -> str:
213
236
  return result.stdout
214
237
 
215
238
 
239
+ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
240
+ """Run Claude CLI in an isolated public repo checkout."""
241
+ env = os.environ.copy()
242
+ env["NEXO_HEADLESS"] = "1"
243
+ env["NEXO_PUBLIC_CONTRIBUTION"] = "1"
244
+ env.pop("CLAUDECODE", None)
245
+ env.pop("CLAUDE_CODE", None)
246
+
247
+ result = subprocess.run(
248
+ [
249
+ str(CLAUDE_CLI),
250
+ "-p",
251
+ prompt,
252
+ "--model",
253
+ "opus",
254
+ "--output-format",
255
+ "text",
256
+ "--allowedTools",
257
+ "Read,Write,Edit,Glob,Grep,Bash",
258
+ ],
259
+ cwd=str(cwd),
260
+ capture_output=True,
261
+ text=True,
262
+ timeout=CLI_TIMEOUT,
263
+ env=env,
264
+ )
265
+ if result.returncode != 0:
266
+ raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
267
+ return result.stdout
268
+
269
+
270
+ def _git(cwd: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
271
+ return subprocess.run(
272
+ ["git", *args],
273
+ cwd=str(cwd),
274
+ capture_output=True,
275
+ text=True,
276
+ timeout=timeout,
277
+ )
278
+
279
+
280
+ def _gh(*args: str, cwd: Path | None = None, timeout: int = 60) -> subprocess.CompletedProcess:
281
+ return subprocess.run(
282
+ ["gh", *args],
283
+ cwd=str(cwd) if cwd else None,
284
+ capture_output=True,
285
+ text=True,
286
+ timeout=timeout,
287
+ )
288
+
289
+
290
+ def _branch_slug(text: str) -> str:
291
+ raw = re.sub(r"[^a-z0-9._-]+", "-", text.lower()).strip("-")
292
+ return raw[:48] or "proposal"
293
+
294
+
295
+ def _ensure_public_repo_cache(config: dict) -> None:
296
+ CONTRIB_REPO_DIR.parent.mkdir(parents=True, exist_ok=True)
297
+ if not (CONTRIB_REPO_DIR / ".git").exists():
298
+ clone = _git(CONTRIB_REPO_DIR.parent, "clone", f"https://github.com/{config['upstream_repo']}.git", str(CONTRIB_REPO_DIR), timeout=180)
299
+ if clone.returncode != 0:
300
+ raise RuntimeError(clone.stderr.strip() or clone.stdout.strip() or "git clone failed")
301
+ fetch = _git(CONTRIB_REPO_DIR, "fetch", "origin", timeout=120)
302
+ if fetch.returncode != 0:
303
+ raise RuntimeError(fetch.stderr.strip() or fetch.stdout.strip() or "git fetch failed")
304
+
305
+ remote_url = f"https://github.com/{config['fork_repo']}.git"
306
+ current = _git(CONTRIB_REPO_DIR, "remote", "get-url", "fork", timeout=10)
307
+ if current.returncode != 0:
308
+ add = _git(CONTRIB_REPO_DIR, "remote", "add", "fork", remote_url, timeout=10)
309
+ if add.returncode != 0:
310
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git remote add fork failed")
311
+ elif current.stdout.strip() != remote_url:
312
+ set_url = _git(CONTRIB_REPO_DIR, "remote", "set-url", "fork", remote_url, timeout=10)
313
+ if set_url.returncode != 0:
314
+ raise RuntimeError(set_url.stderr.strip() or set_url.stdout.strip() or "git remote set-url failed")
315
+
316
+
317
+ def _prepare_public_worktree(config: dict, title_hint: str = "evolution") -> tuple[Path, str]:
318
+ _ensure_public_repo_cache(config)
319
+ CONTRIB_WORKTREES_DIR.mkdir(parents=True, exist_ok=True)
320
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
321
+ branch_name = f"contrib/{config['machine_id']}/{timestamp}-{_branch_slug(title_hint)}"
322
+ worktree_dir = CONTRIB_WORKTREES_DIR / f"{timestamp}-{_branch_slug(title_hint)}"
323
+ add = _git(CONTRIB_REPO_DIR, "worktree", "add", "--detach", str(worktree_dir), "origin/main", timeout=120)
324
+ if add.returncode != 0:
325
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git worktree add failed")
326
+ return worktree_dir, branch_name
327
+
328
+
329
+ def _prime_public_git_identity(worktree_dir: Path, config: dict) -> None:
330
+ github_user = str(config.get("github_user") or "nexo-public-evolution").strip() or "nexo-public-evolution"
331
+ email = f"{github_user}@users.noreply.github.com"
332
+ name = f"{github_user} via NEXO Public Evolution"
333
+ for key, value in (("user.name", name), ("user.email", email)):
334
+ result = _git(worktree_dir, "config", key, value, timeout=15)
335
+ if result.returncode != 0:
336
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"git config {key} failed")
337
+
338
+
339
+ def _remove_public_worktree(worktree_dir: Path) -> None:
340
+ if not worktree_dir.exists():
341
+ return
342
+ _git(CONTRIB_REPO_DIR, "worktree", "remove", str(worktree_dir), "--force", timeout=60)
343
+
344
+
345
+ def _parse_summary_json(text: str) -> dict:
346
+ payload = text.strip()
347
+ if "```json" in payload:
348
+ payload = payload.split("```json", 1)[1].split("```", 1)[0]
349
+ elif "```" in payload:
350
+ payload = payload.split("```", 1)[1].split("```", 1)[0]
351
+ try:
352
+ summary = json.loads(payload.strip())
353
+ if isinstance(summary, dict):
354
+ return summary
355
+ except Exception:
356
+ pass
357
+ return {}
358
+
359
+
360
+ def _changed_public_files(worktree_dir: Path) -> list[str]:
361
+ result = _git(worktree_dir, "status", "--porcelain", timeout=30)
362
+ if result.returncode != 0:
363
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "git status failed")
364
+ changed: list[str] = []
365
+ for line in result.stdout.splitlines():
366
+ if not line:
367
+ continue
368
+ path_text = line[3:].strip()
369
+ if " -> " in path_text:
370
+ path_text = path_text.split(" -> ", 1)[1].strip()
371
+ if path_text:
372
+ changed.append(path_text)
373
+ return changed
374
+
375
+
376
+ def _is_allowed_public_path(rel_path: str) -> bool:
377
+ return any(rel_path.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES)
378
+
379
+
380
+ def _sanitize_public_diff(worktree_dir: Path, changed_files: list[str]) -> tuple[bool, str]:
381
+ if not changed_files:
382
+ return False, "No repository changes were produced."
383
+ for rel_path in changed_files:
384
+ if not _is_allowed_public_path(rel_path):
385
+ return False, f"Changed path is not allowed for public contribution: {rel_path}"
386
+
387
+ diff = _git(worktree_dir, "diff", "--no-ext-diff", "--", *changed_files, timeout=60)
388
+ if diff.returncode != 0:
389
+ return False, diff.stderr.strip() or diff.stdout.strip() or "git diff failed"
390
+ diff_text = diff.stdout
391
+ private_markers = [
392
+ str(Path.home()),
393
+ str(NEXO_HOME),
394
+ "/Users/",
395
+ "/home/",
396
+ "CLAUDE.md",
397
+ ".nexo/",
398
+ ]
399
+ for marker in private_markers:
400
+ if marker and marker in diff_text:
401
+ return False, f"Sanitization blocked private marker in diff: {marker}"
402
+ return True, ""
403
+
404
+
405
+ def _run_public_validation(worktree_dir: Path, changed_files: list[str]) -> list[str]:
406
+ validations: list[str] = []
407
+ py_files = [str(worktree_dir / rel_path) for rel_path in changed_files if rel_path.endswith(".py")]
408
+ if py_files:
409
+ result = subprocess.run(
410
+ [sys.executable, "-m", "py_compile", *py_files],
411
+ cwd=str(worktree_dir),
412
+ capture_output=True,
413
+ text=True,
414
+ timeout=120,
415
+ )
416
+ if result.returncode != 0:
417
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "py_compile failed")
418
+ validations.append("python3 -m py_compile " + " ".join(changed_files))
419
+
420
+ js_files = [str(worktree_dir / rel_path) for rel_path in changed_files if rel_path.endswith(".js")]
421
+ for js_file in js_files:
422
+ result = subprocess.run(
423
+ ["node", "--check", js_file],
424
+ cwd=str(worktree_dir),
425
+ capture_output=True,
426
+ text=True,
427
+ timeout=60,
428
+ )
429
+ if result.returncode != 0:
430
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"node --check failed for {js_file}")
431
+ if js_files:
432
+ validations.append("node --check " + " ".join(changed_files))
433
+
434
+ tests = subprocess.run(
435
+ ["pytest", "-q", "tests"],
436
+ cwd=str(worktree_dir),
437
+ capture_output=True,
438
+ text=True,
439
+ timeout=900,
440
+ env={**os.environ, "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"},
441
+ )
442
+ if tests.returncode != 0:
443
+ raise RuntimeError(tests.stderr.strip() or tests.stdout.strip() or "pytest failed")
444
+ validations.append("PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q tests")
445
+ return validations
446
+
447
+
448
+ def _write_public_artifacts(worktree_dir: Path, branch_name: str, summary: dict) -> Path:
449
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
450
+ artifact_dir = CONTRIB_ARTIFACTS_DIR / timestamp
451
+ artifact_dir.mkdir(parents=True, exist_ok=True)
452
+ diff = _git(worktree_dir, "diff", "--no-ext-diff", "origin/main...HEAD", timeout=60)
453
+ patch_text = diff.stdout if diff.returncode == 0 else ""
454
+ (artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n")
455
+ (artifact_dir / "branch.txt").write_text(branch_name + "\n")
456
+ (artifact_dir / "diff.patch").write_text(patch_text)
457
+ return artifact_dir
458
+
459
+
460
+ def _create_draft_pr(worktree_dir: Path, config: dict, branch_name: str, summary: dict) -> tuple[str, int | None]:
461
+ title = str(summary.get("title") or "chore: public evolution contribution").strip()
462
+ body_lines = [
463
+ summary.get("problem", "Problem: see diff."),
464
+ "",
465
+ "Summary:",
466
+ str(summary.get("summary") or "See diff."),
467
+ "",
468
+ "Tests:",
469
+ ]
470
+ tests = summary.get("tests") or []
471
+ if isinstance(tests, list) and tests:
472
+ body_lines.extend(f"- {item}" for item in tests)
473
+ else:
474
+ body_lines.append("- See CI / local validation")
475
+ risks = summary.get("risks") or []
476
+ if isinstance(risks, list) and risks:
477
+ body_lines.extend(["", "Risks:"])
478
+ body_lines.extend(f"- {item}" for item in risks)
479
+ body_lines.extend(["", "Source: automated public core evolution from an opt-in machine."])
480
+ body_file = worktree_dir / ".nexo-public-pr-body.md"
481
+ body_file.write_text("\n".join(body_lines) + "\n")
482
+ head = f"{config['github_user']}:{branch_name}"
483
+ result = _gh(
484
+ "pr",
485
+ "create",
486
+ "--repo",
487
+ config["upstream_repo"],
488
+ "--head",
489
+ head,
490
+ "--base",
491
+ "main",
492
+ "--title",
493
+ title,
494
+ "--body-file",
495
+ str(body_file),
496
+ "--draft",
497
+ cwd=worktree_dir,
498
+ timeout=120,
499
+ )
500
+ if result.returncode != 0:
501
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr create failed")
502
+ pr_url = (result.stdout or "").strip().splitlines()[-1].strip()
503
+ match = re.search(r"/pull/(\d+)", pr_url)
504
+ pr_number = int(match.group(1)) if match else None
505
+ return pr_url, pr_number
506
+
507
+
508
+ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
509
+ config = load_public_contribution_config()
510
+ ready, reason, config = can_run_public_contribution(config)
511
+ if not ready:
512
+ log(f"Public core contribution paused: {reason}")
513
+ mark_public_contribution_result(result=f"skipped:{reason}", config=config)
514
+ return
515
+
516
+ if not verify_claude_cli():
517
+ log("Claude CLI not available or not authenticated. Skipping public contribution run.")
518
+ mark_public_contribution_result(result="skipped:claude_cli_unavailable", config=config)
519
+ return
520
+
521
+ worktree_dir: Path | None = None
522
+ branch_name = ""
523
+ summary: dict = {}
524
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
525
+ try:
526
+ worktree_dir, branch_name = _prepare_public_worktree(config, title_hint="public-core")
527
+ _prime_public_git_identity(worktree_dir, config)
528
+ prompt = build_public_contribution_prompt(repo_root=str(worktree_dir), cycle_number=cycle_num)
529
+ raw_response = call_public_claude_cli(prompt, cwd=worktree_dir)
530
+ summary = _parse_summary_json(raw_response)
531
+ changed_files = _changed_public_files(worktree_dir)
532
+ ok, reason = _sanitize_public_diff(worktree_dir, changed_files)
533
+ if not ok:
534
+ raise RuntimeError(reason)
535
+
536
+ tests_run = _run_public_validation(worktree_dir, changed_files)
537
+ existing_tests = summary.get("tests")
538
+ summary["tests"] = existing_tests if isinstance(existing_tests, list) and existing_tests else tests_run
539
+ commit_title = str(summary.get("title") or "chore: public evolution contribution").strip()
540
+
541
+ add = _git(worktree_dir, "add", "--", *changed_files, timeout=60)
542
+ if add.returncode != 0:
543
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git add failed")
544
+ commit = _git(worktree_dir, "commit", "-m", commit_title, timeout=120)
545
+ if commit.returncode != 0:
546
+ raise RuntimeError(commit.stderr.strip() or commit.stdout.strip() or "git commit failed")
547
+ push = _git(worktree_dir, "push", "fork", f"HEAD:refs/heads/{branch_name}", "--force-with-lease", timeout=180)
548
+ if push.returncode != 0:
549
+ raise RuntimeError(push.stderr.strip() or push.stdout.strip() or "git push failed")
550
+
551
+ pr_url, pr_number = _create_draft_pr(worktree_dir, config, branch_name, summary)
552
+ artifact_dir = _write_public_artifacts(worktree_dir, branch_name, summary)
553
+ mark_active_pr(pr_url=pr_url, pr_number=pr_number, branch=branch_name, config=config)
554
+
555
+ conn.execute(
556
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
557
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
558
+ (
559
+ cycle_num,
560
+ "public_core",
561
+ commit_title,
562
+ "draft_pr",
563
+ summary.get("problem", "Public core contribution"),
564
+ "draft_pr_created",
565
+ json.dumps(changed_files),
566
+ json.dumps({"tests": summary.get("tests", []), "pr_url": pr_url, "artifact_dir": str(artifact_dir)}),
567
+ ),
568
+ )
569
+ conn.commit()
570
+
571
+ objective["last_evolution"] = str(date.today())
572
+ objective["total_evolutions"] = cycle_num
573
+ objective["total_proposals_made"] = objective.get("total_proposals_made", 0) + 1
574
+ objective.setdefault("history", []).insert(0, {
575
+ "cycle": cycle_num,
576
+ "date": str(date.today()),
577
+ "mode": "public_core",
578
+ "proposals": 1,
579
+ "auto_count": 0,
580
+ "auto_applied": 0,
581
+ "analysis": (summary.get("summary") or commit_title)[:200],
582
+ "pr_url": pr_url,
583
+ })
584
+ objective["history"] = objective["history"][:12]
585
+ save_objective(objective)
586
+ mark_public_contribution_result(result=f"draft_pr_created:{pr_url}", config=config)
587
+ log(f"Public core contribution complete: Draft PR created at {pr_url}")
588
+ except Exception as exc:
589
+ mark_public_contribution_result(result=f"failed:{exc}", config=config)
590
+ raise
591
+ finally:
592
+ conn.close()
593
+ if worktree_dir is not None:
594
+ _remove_public_worktree(worktree_dir)
595
+
596
+
216
597
  # ── File safety validation ───────────────────────────────────────────────
217
598
  def is_safe_path(filepath: str, mode: str = "auto") -> bool:
218
599
  """Check if a file path is within safe zones and not immutable.
@@ -518,6 +899,17 @@ def run():
518
899
  save_objective(objective)
519
900
  return
520
901
 
902
+ public_config = load_public_contribution_config()
903
+ if str(public_config.get("mode") or "").strip().lower() in {"draft_prs", "pending_auth"}:
904
+ cycle_num = objective.get("total_evolutions", 0) + 1
905
+ try:
906
+ run_public_contribution_cycle(objective=objective, cycle_num=cycle_num)
907
+ set_consecutive_failures(0)
908
+ except Exception as e:
909
+ log(f"Public core contribution failed: {e}")
910
+ set_consecutive_failures(failures + 1)
911
+ return
912
+
521
913
  # Dry-run restore test
522
914
  log("Running restore dry-run test...")
523
915
  if not dry_run_restore_test():
@@ -0,0 +1,36 @@
1
+ """Personal NEXO MCP plugin scaffold.
2
+
3
+ This file lives in NEXO_HOME/plugins/ and is loaded by the NEXO MCP server.
4
+ Edit the handler below to implement your personal capability.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+
12
+ def handle_example_tool(payload_json: str = "{}") -> str:
13
+ """Example personal MCP tool.
14
+
15
+ Args:
16
+ payload_json: JSON string with your input payload.
17
+ """
18
+ try:
19
+ payload = json.loads(payload_json or "{}")
20
+ except Exception as exc:
21
+ return json.dumps({"ok": False, "error": f"invalid json: {exc}"}, ensure_ascii=False)
22
+
23
+ return json.dumps({
24
+ "ok": True,
25
+ "message": "Personal plugin scaffold created. Edit this handler in NEXO_HOME/plugins.",
26
+ "payload": payload,
27
+ }, ensure_ascii=False)
28
+
29
+
30
+ TOOLS = [
31
+ (
32
+ handle_example_tool,
33
+ "nexo_example_tool",
34
+ "Example personal MCP tool scaffold. Edit it in NEXO_HOME/plugins.",
35
+ ),
36
+ ]