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
package/src/cli.py
CHANGED
|
@@ -22,6 +22,7 @@ Entry points:
|
|
|
22
22
|
nexo skills approve ID [--execution-level ...] [--approved-by ...] [--json]
|
|
23
23
|
nexo skills featured [--limit N] [--json]
|
|
24
24
|
nexo skills evolution [--json]
|
|
25
|
+
nexo contributor status|on|off [--json]
|
|
25
26
|
nexo doctor [--tier boot|runtime|deep|all] [--json] [--fix]
|
|
26
27
|
"""
|
|
27
28
|
from __future__ import annotations
|
|
@@ -451,9 +452,25 @@ def _update(args):
|
|
|
451
452
|
- Packaged/runtime-only install: delegate to plugins.update handle_update()
|
|
452
453
|
"""
|
|
453
454
|
from auto_update import manual_sync_update, _resolve_sync_source
|
|
454
|
-
from runtime_power import
|
|
455
|
+
from runtime_power import (
|
|
456
|
+
ensure_power_policy_choice,
|
|
457
|
+
apply_power_policy,
|
|
458
|
+
format_power_policy_label,
|
|
459
|
+
ensure_full_disk_access_choice,
|
|
460
|
+
format_full_disk_access_label,
|
|
461
|
+
)
|
|
462
|
+
from public_contribution import (
|
|
463
|
+
ensure_public_contribution_choice,
|
|
464
|
+
format_public_contribution_label,
|
|
465
|
+
)
|
|
455
466
|
|
|
456
467
|
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
468
|
+
progress_messages: list[str] = []
|
|
469
|
+
|
|
470
|
+
def progress(message: str) -> None:
|
|
471
|
+
progress_messages.append(message)
|
|
472
|
+
if not args.json:
|
|
473
|
+
print(f"[NEXO] {message}", flush=True)
|
|
457
474
|
|
|
458
475
|
dest = NEXO_HOME
|
|
459
476
|
src_dir, repo_dir = _resolve_sync_source()
|
|
@@ -469,16 +486,25 @@ def _update(args):
|
|
|
469
486
|
)
|
|
470
487
|
return 1
|
|
471
488
|
|
|
472
|
-
result = handle_update()
|
|
489
|
+
result = handle_update(progress_fn=progress)
|
|
473
490
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
474
491
|
power_result = apply_power_policy(choice.get("policy"))
|
|
492
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
493
|
+
contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
|
|
475
494
|
if args.json:
|
|
476
495
|
print(json.dumps({
|
|
477
496
|
"mode": "packaged",
|
|
478
497
|
"message": result,
|
|
498
|
+
"progress": progress_messages,
|
|
479
499
|
"power_policy": choice.get("policy"),
|
|
480
500
|
"power_action": power_result.get("action"),
|
|
481
501
|
"power_details": power_result.get("details"),
|
|
502
|
+
"full_disk_access_status": fda_choice.get("status"),
|
|
503
|
+
"full_disk_access_reasons": fda_choice.get("reasons"),
|
|
504
|
+
"full_disk_access_message": fda_choice.get("message"),
|
|
505
|
+
"public_contribution_mode": contrib_choice.get("mode"),
|
|
506
|
+
"public_contribution_status": contrib_choice.get("status"),
|
|
507
|
+
"public_contribution_message": contrib_choice.get("message"),
|
|
482
508
|
}, indent=2, ensure_ascii=False))
|
|
483
509
|
else:
|
|
484
510
|
print(result)
|
|
@@ -486,16 +512,35 @@ def _update(args):
|
|
|
486
512
|
print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
487
513
|
if power_result.get("message"):
|
|
488
514
|
print(f"Power helper: {power_result.get('message')}")
|
|
515
|
+
if fda_choice.get("prompted"):
|
|
516
|
+
print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
517
|
+
if fda_choice.get("message"):
|
|
518
|
+
print(f"Full Disk Access: {fda_choice.get('message')}")
|
|
519
|
+
if contrib_choice.get("prompted"):
|
|
520
|
+
print(f"Contributor mode: {format_public_contribution_label(contrib_choice)}")
|
|
521
|
+
if contrib_choice.get("message"):
|
|
522
|
+
print(f"Contributor mode: {contrib_choice.get('message')}")
|
|
489
523
|
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
490
524
|
|
|
491
525
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
492
526
|
power_result = apply_power_policy(choice.get("policy"))
|
|
493
|
-
|
|
527
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
528
|
+
contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
|
|
529
|
+
result = manual_sync_update(interactive=interactive, allow_source_pull=True, progress_fn=progress)
|
|
494
530
|
result["power_policy"] = choice.get("policy")
|
|
495
531
|
result["power_action"] = power_result.get("action")
|
|
496
532
|
result["power_details"] = power_result.get("details")
|
|
533
|
+
result["full_disk_access_status"] = fda_choice.get("status")
|
|
534
|
+
result["full_disk_access_reasons"] = fda_choice.get("reasons")
|
|
535
|
+
result["public_contribution_mode"] = contrib_choice.get("mode")
|
|
536
|
+
result["public_contribution_status"] = contrib_choice.get("status")
|
|
537
|
+
result["progress"] = progress_messages
|
|
497
538
|
if power_result.get("message"):
|
|
498
539
|
result["power_message"] = power_result.get("message")
|
|
540
|
+
if fda_choice.get("message"):
|
|
541
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
542
|
+
if contrib_choice.get("message"):
|
|
543
|
+
result["public_contribution_message"] = contrib_choice.get("message")
|
|
499
544
|
if args.json:
|
|
500
545
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
501
546
|
else:
|
|
@@ -511,11 +556,84 @@ def _update(args):
|
|
|
511
556
|
print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
512
557
|
if power_result.get("message"):
|
|
513
558
|
print(f" Power helper: {power_result.get('message')}")
|
|
559
|
+
if fda_choice.get("prompted"):
|
|
560
|
+
print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
561
|
+
if fda_choice.get("message"):
|
|
562
|
+
print(f" Full Disk Access: {fda_choice.get('message')}")
|
|
563
|
+
if contrib_choice.get("prompted"):
|
|
564
|
+
print(f" Contributor mode: {format_public_contribution_label(contrib_choice)}")
|
|
565
|
+
if contrib_choice.get("message"):
|
|
566
|
+
print(f" Contributor mode: {contrib_choice.get('message')}")
|
|
514
567
|
else:
|
|
515
568
|
print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
|
|
516
569
|
return 0 if result.get("ok") else 1
|
|
517
570
|
|
|
518
571
|
|
|
572
|
+
def _contributor_status(args):
|
|
573
|
+
from public_contribution import (
|
|
574
|
+
format_public_contribution_label,
|
|
575
|
+
load_public_contribution_config,
|
|
576
|
+
refresh_public_contribution_state,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
config = refresh_public_contribution_state(load_public_contribution_config())
|
|
580
|
+
payload = {
|
|
581
|
+
"enabled": bool(config.get("enabled")),
|
|
582
|
+
"mode": config.get("mode"),
|
|
583
|
+
"status": config.get("status"),
|
|
584
|
+
"label": format_public_contribution_label(config),
|
|
585
|
+
"github_user": config.get("github_user"),
|
|
586
|
+
"fork_repo": config.get("fork_repo"),
|
|
587
|
+
"active_pr_url": config.get("active_pr_url"),
|
|
588
|
+
"active_branch": config.get("active_branch"),
|
|
589
|
+
"cooldown_until": config.get("cooldown_until"),
|
|
590
|
+
"last_result": config.get("last_result"),
|
|
591
|
+
}
|
|
592
|
+
if args.json:
|
|
593
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
594
|
+
else:
|
|
595
|
+
print(f"Contributor mode: {payload['label']}")
|
|
596
|
+
if payload["github_user"]:
|
|
597
|
+
print(f" GitHub user: {payload['github_user']}")
|
|
598
|
+
if payload["fork_repo"]:
|
|
599
|
+
print(f" Fork: {payload['fork_repo']}")
|
|
600
|
+
if payload["active_pr_url"]:
|
|
601
|
+
print(f" Active Draft PR: {payload['active_pr_url']}")
|
|
602
|
+
if payload["cooldown_until"]:
|
|
603
|
+
print(f" Cooldown until: {payload['cooldown_until']}")
|
|
604
|
+
if payload["last_result"]:
|
|
605
|
+
print(f" Last result: {payload['last_result']}")
|
|
606
|
+
return 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _contributor_on(args):
|
|
610
|
+
from public_contribution import ensure_public_contribution_choice, format_public_contribution_label
|
|
611
|
+
|
|
612
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
613
|
+
if not interactive:
|
|
614
|
+
print("Contributor mode requires an interactive terminal to confirm GitHub Draft PR consent.", file=sys.stderr)
|
|
615
|
+
return 1
|
|
616
|
+
config = ensure_public_contribution_choice(interactive=True, reason="contributor", force_prompt=True)
|
|
617
|
+
if args.json:
|
|
618
|
+
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
619
|
+
else:
|
|
620
|
+
print(f"Contributor mode: {format_public_contribution_label(config)}")
|
|
621
|
+
if config.get("message"):
|
|
622
|
+
print(config.get("message"))
|
|
623
|
+
return 0 if config.get("mode") == "draft_prs" else 1
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _contributor_off(args):
|
|
627
|
+
from public_contribution import disable_public_contribution, format_public_contribution_label
|
|
628
|
+
|
|
629
|
+
config = disable_public_contribution()
|
|
630
|
+
if args.json:
|
|
631
|
+
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
632
|
+
else:
|
|
633
|
+
print(f"Contributor mode: {format_public_contribution_label(config)}")
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
|
|
519
637
|
def _service_control(service_name: str, action: str) -> int:
|
|
520
638
|
"""Control a LaunchAgent/systemd service: on, off, status."""
|
|
521
639
|
import platform as plat
|
|
@@ -744,6 +862,7 @@ Commands:
|
|
|
744
862
|
Personal scripts
|
|
745
863
|
nexo skills list|apply|sync|approve Executable skills
|
|
746
864
|
nexo update Update installed runtime
|
|
865
|
+
nexo contributor status|on|off Public Draft PR contribution mode
|
|
747
866
|
nexo dashboard on|off|status Web dashboard control
|
|
748
867
|
|
|
749
868
|
Run 'nexo <command> --help' for details.
|
|
@@ -838,6 +957,11 @@ def main():
|
|
|
838
957
|
doctor_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
839
958
|
doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
|
|
840
959
|
|
|
960
|
+
# -- contributor --
|
|
961
|
+
contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
|
|
962
|
+
contributor_parser.add_argument("action", choices=["status", "on", "off"], help="Manage contributor mode")
|
|
963
|
+
contributor_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
964
|
+
|
|
841
965
|
# -- skills --
|
|
842
966
|
skills_parser = sub.add_parser("skills", help="Skills v2 runtime")
|
|
843
967
|
skills_sub = skills_parser.add_subparsers(dest="skills_command")
|
|
@@ -923,6 +1047,15 @@ def main():
|
|
|
923
1047
|
return _update(args)
|
|
924
1048
|
elif args.command == "doctor":
|
|
925
1049
|
return _doctor(args)
|
|
1050
|
+
elif args.command == "contributor":
|
|
1051
|
+
if args.action == "status":
|
|
1052
|
+
return _contributor_status(args)
|
|
1053
|
+
elif args.action == "on":
|
|
1054
|
+
return _contributor_on(args)
|
|
1055
|
+
elif args.action == "off":
|
|
1056
|
+
return _contributor_off(args)
|
|
1057
|
+
contributor_parser.print_help()
|
|
1058
|
+
return 0
|
|
926
1059
|
elif args.command == "skills":
|
|
927
1060
|
if args.skills_command == "list":
|
|
928
1061
|
return _skills_list(args)
|
package/src/db/_episodic.py
CHANGED
|
@@ -573,7 +573,7 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
|
|
|
573
573
|
"""Read session diary entries.
|
|
574
574
|
|
|
575
575
|
- session_id: returns entries for that specific session
|
|
576
|
-
- last_day: returns
|
|
576
|
+
- last_day: returns the recent continuity window (~36h), including the previous evening
|
|
577
577
|
- last_n: returns last N entries (default)
|
|
578
578
|
- domain: filter by project context (nexo, other)
|
|
579
579
|
- include_automated: if False (default), excludes automated sessions (auto-close,
|
|
@@ -605,21 +605,11 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
|
|
|
605
605
|
(session_id,) + domain_params
|
|
606
606
|
).fetchall()
|
|
607
607
|
elif last_day:
|
|
608
|
-
# Get all entries from the most recent calendar day (human sessions only)
|
|
609
|
-
if domain:
|
|
610
|
-
latest = conn.execute(
|
|
611
|
-
f"SELECT date(created_at) as day FROM session_diary WHERE domain = ?{source_clause} ORDER BY created_at DESC LIMIT 1",
|
|
612
|
-
(domain,)
|
|
613
|
-
).fetchone()
|
|
614
|
-
else:
|
|
615
|
-
latest = conn.execute(
|
|
616
|
-
f"SELECT date(created_at) as day FROM session_diary WHERE 1=1{source_clause} ORDER BY created_at DESC LIMIT 1"
|
|
617
|
-
).fetchone()
|
|
618
|
-
if not latest:
|
|
619
|
-
return []
|
|
620
608
|
rows = conn.execute(
|
|
621
|
-
f"SELECT * FROM session_diary
|
|
622
|
-
(
|
|
609
|
+
f"SELECT * FROM session_diary "
|
|
610
|
+
f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
|
|
611
|
+
f"ORDER BY created_at DESC",
|
|
612
|
+
domain_params
|
|
623
613
|
).fetchall()
|
|
624
614
|
else:
|
|
625
615
|
rows = conn.execute(
|
|
@@ -770,4 +760,3 @@ def recall(query: str, days: int = 30) -> list[dict]:
|
|
|
770
760
|
results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
|
|
771
761
|
return results[:20]
|
|
772
762
|
|
|
773
|
-
|
|
@@ -747,6 +747,11 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
747
747
|
"and treat recent 'Operation not permitted' against Documents/Desktop/Downloads as a TCC/runtime path issue."
|
|
748
748
|
),
|
|
749
749
|
)
|
|
750
|
+
if tcc_risk:
|
|
751
|
+
check.repair_plan.append(
|
|
752
|
+
"On macOS, grant Full Disk Access manually if protected folders are required; "
|
|
753
|
+
"NEXO can only open the System Settings pane and verify best effort"
|
|
754
|
+
)
|
|
750
755
|
|
|
751
756
|
if fix:
|
|
752
757
|
sync_ok, sync_evidence = _sync_launchagents_from_manifest()
|
package/src/evolution_cycle.py
CHANGED
|
@@ -57,16 +57,20 @@ def normalize_objective(obj: dict | None) -> dict:
|
|
|
57
57
|
|
|
58
58
|
if "evolution_mode" in source:
|
|
59
59
|
mode = str(source.get("evolution_mode") or "auto").strip().lower()
|
|
60
|
+
if mode in {"public", "public_core", "contributor", "draft_prs"}:
|
|
61
|
+
mode = "public_core"
|
|
60
62
|
else:
|
|
61
63
|
legacy_mode = str(source.get("review_mode") or "").strip().lower()
|
|
62
64
|
if legacy_mode in {"manual", "review"}:
|
|
63
65
|
mode = "review"
|
|
64
66
|
elif legacy_mode in {"managed", "hybrid", "owner", "core"}:
|
|
65
67
|
mode = "managed"
|
|
68
|
+
elif legacy_mode in {"public", "public_core", "contributor", "draft_prs"}:
|
|
69
|
+
mode = "public_core"
|
|
66
70
|
else:
|
|
67
71
|
mode = "auto"
|
|
68
72
|
|
|
69
|
-
if mode not in {"auto", "review", "managed"}:
|
|
73
|
+
if mode not in {"auto", "review", "managed", "public_core"}:
|
|
70
74
|
mode = "auto"
|
|
71
75
|
|
|
72
76
|
dimensions = source.get("dimensions")
|
|
@@ -276,6 +280,10 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
|
|
|
276
280
|
mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
|
|
277
281
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
|
|
278
282
|
immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, personality.md, user-profile.md"
|
|
283
|
+
elif mode == "public_core":
|
|
284
|
+
mode_desc = "public core contribution via isolated checkout and Draft PR"
|
|
285
|
+
safe_zones = "isolated public repo checkout only"
|
|
286
|
+
immutable_files = "personal runtime, ~/.nexo/**, local DBs/logs, CLAUDE.md, user-profile.md"
|
|
279
287
|
else:
|
|
280
288
|
mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
|
|
281
289
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
|
|
@@ -340,6 +348,48 @@ Max 3 proposals. Quality over quantity. If nothing needs improving, say so."""
|
|
|
340
348
|
return prompt
|
|
341
349
|
|
|
342
350
|
|
|
351
|
+
def build_public_contribution_prompt(*, repo_root: str, cycle_number: int) -> str:
|
|
352
|
+
"""Prompt for the public-core contributor mode.
|
|
353
|
+
|
|
354
|
+
This prompt must never rely on private runtime state. It should inspect only
|
|
355
|
+
the isolated public repo checkout, make one coherent improvement, and end
|
|
356
|
+
by returning machine-readable summary JSON.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
return f"""You are NEXO Public Evolution.
|
|
360
|
+
|
|
361
|
+
You are running inside an isolated checkout of the public NEXO repository.
|
|
362
|
+
Your job is to make one technically coherent improvement to the public core and
|
|
363
|
+
prepare it for a Draft PR.
|
|
364
|
+
|
|
365
|
+
STRICT RULES:
|
|
366
|
+
- Work only inside this repository checkout: {repo_root}
|
|
367
|
+
- You may modify only public core surfaces: src/, bin/, tests/, templates/, hooks/, migrations/, .claude-plugin/
|
|
368
|
+
- Do not read or use ~/.nexo, local DBs, personal scripts, emails, logs, prompts, secrets, or any user-identifying paths
|
|
369
|
+
- Do not push, open PRs, or change git remotes yourself
|
|
370
|
+
- Do not touch README, website, gh-pages, changelog, or release metadata in this mode
|
|
371
|
+
- Focus on one concrete improvement only
|
|
372
|
+
- Run validation for the files you touched
|
|
373
|
+
|
|
374
|
+
What to do:
|
|
375
|
+
1. Inspect the repo and find a real, self-contained improvement in reliability, install/update behavior, cron recovery, diagnostics, hooks, tests, or other core infrastructure.
|
|
376
|
+
2. Implement the change directly in this checkout.
|
|
377
|
+
3. Run the smallest relevant validation commands.
|
|
378
|
+
4. Return ONLY valid JSON with this shape:
|
|
379
|
+
|
|
380
|
+
{{
|
|
381
|
+
"title": "type: short title",
|
|
382
|
+
"problem": "what was wrong",
|
|
383
|
+
"summary": "what you changed",
|
|
384
|
+
"tests": ["command 1", "command 2"],
|
|
385
|
+
"risks": ["risk 1", "risk 2"]
|
|
386
|
+
}}
|
|
387
|
+
|
|
388
|
+
Cycle: #{cycle_number}
|
|
389
|
+
Quality over quantity. One strong improvement is better than three weak ones.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
|
|
343
393
|
def max_auto_changes(total_evolutions: int) -> int:
|
|
344
394
|
"""Progressive trust: 1 for first 4 cycles, 2 for next 4, then 3."""
|
|
345
395
|
if total_evolutions < 4:
|
|
@@ -236,7 +236,7 @@ def handle_session_diary_read(session_id: str = '', last_n: int = 3, last_day: b
|
|
|
236
236
|
Args:
|
|
237
237
|
session_id: Specific session ID to read (optional)
|
|
238
238
|
last_n: Number of recent entries to return (default 3)
|
|
239
|
-
last_day: If true, returns
|
|
239
|
+
last_day: If true, returns the recent continuity window (~36h), including the previous evening. Use this at startup.
|
|
240
240
|
domain: Filter by project context: ecommerce, project-a, nexo, project-b, server, other
|
|
241
241
|
brief: If true, returns ONLY the last diary entry with summary + mental_state + context_next.
|
|
242
242
|
Use this at startup for fast context loading (~1K chars instead of full dump).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""NEXO Personal Plugins — scaffold persistent MCP tools in NEXO_HOME/plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from db import init_db
|
|
10
|
+
from script_registry import create_script
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
14
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _plugins_dir() -> Path:
|
|
18
|
+
path = NEXO_HOME / "plugins"
|
|
19
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_slug(value: str) -> str:
|
|
24
|
+
chars: list[str] = []
|
|
25
|
+
for ch in str(value or "").lower():
|
|
26
|
+
if ch.isalnum():
|
|
27
|
+
chars.append(ch)
|
|
28
|
+
elif ch in {"-", "_", " "}:
|
|
29
|
+
chars.append("-")
|
|
30
|
+
slug = "".join(chars).strip("-")
|
|
31
|
+
return slug or "plugin"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _template_path(name: str) -> Path | None:
|
|
35
|
+
candidates = [
|
|
36
|
+
NEXO_CODE.parent / "templates" / name,
|
|
37
|
+
NEXO_HOME / "templates" / name,
|
|
38
|
+
]
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
if candidate.is_file():
|
|
41
|
+
return candidate
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_template() -> str:
|
|
46
|
+
template = _template_path("plugin-template.py")
|
|
47
|
+
if template:
|
|
48
|
+
return template.read_text()
|
|
49
|
+
return (
|
|
50
|
+
"from __future__ import annotations\n"
|
|
51
|
+
"import json\n\n"
|
|
52
|
+
"def handle_example_tool(payload_json: str = \"{}\") -> str:\n"
|
|
53
|
+
" try:\n"
|
|
54
|
+
" payload = json.loads(payload_json or \"{}\")\n"
|
|
55
|
+
" except Exception as exc:\n"
|
|
56
|
+
" return json.dumps({\"ok\": False, \"error\": f\"invalid json: {exc}\"}, ensure_ascii=False)\n"
|
|
57
|
+
" return json.dumps({\"ok\": True, \"payload\": payload}, ensure_ascii=False)\n\n"
|
|
58
|
+
"TOOLS = [\n"
|
|
59
|
+
" (handle_example_tool, \"nexo_example_tool\", \"Example personal MCP tool scaffold.\"),\n"
|
|
60
|
+
"]\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _render_plugin_template(*, plugin_stem: str, tool_name: str, description: str) -> str:
|
|
65
|
+
content = _load_template()
|
|
66
|
+
handler_name = f"handle_{plugin_stem.replace('-', '_')}"
|
|
67
|
+
content = content.replace("handle_example_tool", handler_name)
|
|
68
|
+
content = content.replace("nexo_example_tool", tool_name)
|
|
69
|
+
content = content.replace(
|
|
70
|
+
"Personal plugin scaffold created. Edit this handler in NEXO_HOME/plugins.",
|
|
71
|
+
description or f"Personal plugin scaffold for {plugin_stem}.",
|
|
72
|
+
)
|
|
73
|
+
content = content.replace(
|
|
74
|
+
"Example personal MCP tool scaffold. Edit it in NEXO_HOME/plugins.",
|
|
75
|
+
description or f"Personal MCP tool scaffold for {plugin_stem}.",
|
|
76
|
+
)
|
|
77
|
+
return content
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_personal_plugin_create(
|
|
81
|
+
name: str,
|
|
82
|
+
description: str = "",
|
|
83
|
+
tool_name: str = "",
|
|
84
|
+
create_companion_script: bool = False,
|
|
85
|
+
script_runtime: str = "python",
|
|
86
|
+
force: bool = False,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Create a personal MCP plugin scaffold in NEXO_HOME/plugins.
|
|
89
|
+
|
|
90
|
+
Optionally also creates a companion script in NEXO_HOME/scripts.
|
|
91
|
+
"""
|
|
92
|
+
init_db()
|
|
93
|
+
plugin_stem = _safe_slug(name)
|
|
94
|
+
filename = f"{plugin_stem}.py"
|
|
95
|
+
tool_name = (tool_name or f"nexo_{plugin_stem.replace('-', '_')}").strip()
|
|
96
|
+
plugin_path = _plugins_dir() / filename
|
|
97
|
+
if plugin_path.exists() and not force:
|
|
98
|
+
return json.dumps({
|
|
99
|
+
"ok": False,
|
|
100
|
+
"error": f"Plugin already exists: {plugin_path}",
|
|
101
|
+
}, ensure_ascii=False)
|
|
102
|
+
|
|
103
|
+
content = _render_plugin_template(
|
|
104
|
+
plugin_stem=plugin_stem,
|
|
105
|
+
tool_name=tool_name,
|
|
106
|
+
description=description or f"Personal MCP tool for {name}.",
|
|
107
|
+
)
|
|
108
|
+
plugin_path.write_text(content)
|
|
109
|
+
|
|
110
|
+
script_result = None
|
|
111
|
+
if create_companion_script:
|
|
112
|
+
script_result = create_script(
|
|
113
|
+
plugin_stem,
|
|
114
|
+
description=f"Companion script for plugin {plugin_stem}",
|
|
115
|
+
runtime=script_runtime,
|
|
116
|
+
force=force,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return json.dumps({
|
|
120
|
+
"ok": True,
|
|
121
|
+
"name": plugin_stem,
|
|
122
|
+
"tool_name": tool_name,
|
|
123
|
+
"plugin_path": str(plugin_path),
|
|
124
|
+
"companion_script": script_result,
|
|
125
|
+
"next_step": f"Load the plugin with nexo_plugin_load(filename='{filename}') after editing it.",
|
|
126
|
+
}, ensure_ascii=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
TOOLS = [
|
|
130
|
+
(
|
|
131
|
+
handle_personal_plugin_create,
|
|
132
|
+
"nexo_personal_plugin_create",
|
|
133
|
+
"Create a persistent personal MCP plugin scaffold in NEXO_HOME/plugins, optionally with a companion script in NEXO_HOME/scripts.",
|
|
134
|
+
),
|
|
135
|
+
]
|
package/src/plugins/update.py
CHANGED
|
@@ -341,11 +341,20 @@ def _rollback_npm_package(target_version: str) -> str | None:
|
|
|
341
341
|
return None
|
|
342
342
|
|
|
343
343
|
|
|
344
|
-
def
|
|
344
|
+
def _emit_progress(progress_fn, message: str) -> None:
|
|
345
|
+
if callable(progress_fn):
|
|
346
|
+
try:
|
|
347
|
+
progress_fn(message)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _handle_packaged_update(progress_fn=None) -> str:
|
|
345
353
|
"""Update a packaged (npm) install — no git repo available."""
|
|
346
354
|
old_version = _read_version()
|
|
347
355
|
|
|
348
356
|
# 1. Backup databases BEFORE any changes
|
|
357
|
+
_emit_progress(progress_fn, "Backing up runtime databases...")
|
|
349
358
|
backup_dir, backup_err = _backup_databases()
|
|
350
359
|
if backup_err:
|
|
351
360
|
return f"ABORTED at backup: {backup_err}"
|
|
@@ -353,12 +362,14 @@ def _handle_packaged_update() -> str:
|
|
|
353
362
|
# 2. Backup NEXO_HOME code tree BEFORE npm update
|
|
354
363
|
# postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
|
|
355
364
|
# so we need a full snapshot to restore on failure.
|
|
365
|
+
_emit_progress(progress_fn, "Backing up runtime files...")
|
|
356
366
|
code_backup_dir, code_err = _backup_code_tree()
|
|
357
367
|
if code_err:
|
|
358
368
|
return f"ABORTED at code tree backup: {code_err}"
|
|
359
369
|
|
|
360
370
|
# 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
|
|
361
371
|
try:
|
|
372
|
+
_emit_progress(progress_fn, "Downloading and applying the latest npm package...")
|
|
362
373
|
result = subprocess.run(
|
|
363
374
|
["npm", "update", "-g", "nexo-brain"],
|
|
364
375
|
capture_output=True, text=True, timeout=120,
|
|
@@ -402,16 +413,19 @@ def _handle_packaged_update() -> str:
|
|
|
402
413
|
errors = []
|
|
403
414
|
|
|
404
415
|
# Reinstall pip deps for new version
|
|
416
|
+
_emit_progress(progress_fn, "Reconciling Python dependencies...")
|
|
405
417
|
pip_err = _reinstall_pip_deps()
|
|
406
418
|
if pip_err:
|
|
407
419
|
errors.append(f"pip deps: {pip_err}")
|
|
408
420
|
|
|
409
421
|
# Run migrations
|
|
422
|
+
_emit_progress(progress_fn, "Running runtime migrations...")
|
|
410
423
|
mig_err = _run_migrations()
|
|
411
424
|
if mig_err:
|
|
412
425
|
errors.append(f"migrations: {mig_err}")
|
|
413
426
|
|
|
414
427
|
# Verify server can still import
|
|
428
|
+
_emit_progress(progress_fn, "Verifying runtime import health...")
|
|
415
429
|
verify_err = _verify_import()
|
|
416
430
|
if verify_err:
|
|
417
431
|
errors.append(f"verification: {verify_err}")
|
|
@@ -457,7 +471,7 @@ def _handle_packaged_update() -> str:
|
|
|
457
471
|
return "\n".join(lines)
|
|
458
472
|
|
|
459
473
|
|
|
460
|
-
def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
474
|
+
def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None) -> str:
|
|
461
475
|
"""Pull latest NEXO code, backup databases, run migrations, and verify.
|
|
462
476
|
|
|
463
477
|
Supports both git checkouts and packaged (npm) installs.
|
|
@@ -477,7 +491,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
477
491
|
"""
|
|
478
492
|
# Packaged install — no git repo
|
|
479
493
|
if not _is_git_repo():
|
|
480
|
-
return _handle_packaged_update()
|
|
494
|
+
return _handle_packaged_update(progress_fn=progress_fn)
|
|
481
495
|
|
|
482
496
|
steps_done = []
|
|
483
497
|
old_commit = None
|
|
@@ -485,6 +499,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
485
499
|
|
|
486
500
|
try:
|
|
487
501
|
# Step 1: Check dirty (full worktree)
|
|
502
|
+
_emit_progress(progress_fn, "Checking repository state...")
|
|
488
503
|
dirty_err = _check_dirty()
|
|
489
504
|
if dirty_err:
|
|
490
505
|
return f"ABORTED: {dirty_err}"
|
|
@@ -498,12 +513,14 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
498
513
|
return "ABORTED: Not a git repository or git not available."
|
|
499
514
|
|
|
500
515
|
# Step 2: Backup databases
|
|
516
|
+
_emit_progress(progress_fn, "Backing up runtime databases...")
|
|
501
517
|
backup_dir, backup_err = _backup_databases()
|
|
502
518
|
if backup_err:
|
|
503
519
|
return f"ABORTED at backup: {backup_err}"
|
|
504
520
|
steps_done.append("backup")
|
|
505
521
|
|
|
506
522
|
# Step 3: git pull
|
|
523
|
+
_emit_progress(progress_fn, "Pulling latest source changes...")
|
|
507
524
|
rc, pull_out, pull_err = _git("pull", remote, branch)
|
|
508
525
|
if rc != 0:
|
|
509
526
|
return f"ABORTED at git pull: {pull_err or pull_out}"
|
|
@@ -517,6 +534,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
517
534
|
|
|
518
535
|
# Step 5: Reinstall pip dependencies if requirements.txt changed
|
|
519
536
|
if deps_changed or version_changed:
|
|
537
|
+
_emit_progress(progress_fn, "Reconciling Python dependencies...")
|
|
520
538
|
pip_err = _reinstall_pip_deps()
|
|
521
539
|
if pip_err:
|
|
522
540
|
raise RuntimeError(f"Pip install failed: {pip_err}")
|
|
@@ -524,12 +542,14 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
524
542
|
|
|
525
543
|
# Step 6: Run migrations if version changed
|
|
526
544
|
if version_changed:
|
|
545
|
+
_emit_progress(progress_fn, "Running runtime migrations...")
|
|
527
546
|
mig_err = _run_migrations()
|
|
528
547
|
if mig_err:
|
|
529
548
|
raise RuntimeError(f"Migration failed: {mig_err}")
|
|
530
549
|
steps_done.append("migrations")
|
|
531
550
|
|
|
532
551
|
# Step 7: Verify import
|
|
552
|
+
_emit_progress(progress_fn, "Verifying runtime import health...")
|
|
533
553
|
verify_err = _verify_import()
|
|
534
554
|
if verify_err:
|
|
535
555
|
raise RuntimeError(f"Verification failed: {verify_err}")
|
|
@@ -540,6 +560,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
540
560
|
try:
|
|
541
561
|
cron_sync_path = SRC_DIR / "crons" / "sync.py"
|
|
542
562
|
if cron_sync_path.exists():
|
|
563
|
+
_emit_progress(progress_fn, "Syncing core cron definitions...")
|
|
543
564
|
r = subprocess.run(
|
|
544
565
|
[sys.executable, str(cron_sync_path)],
|
|
545
566
|
capture_output=True, text=True, timeout=30,
|
|
@@ -557,6 +578,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
557
578
|
|
|
558
579
|
# Step 9: Sync hooks to NEXO_HOME
|
|
559
580
|
try:
|
|
581
|
+
_emit_progress(progress_fn, "Syncing core Claude hooks...")
|
|
560
582
|
_sync_hooks_to_home()
|
|
561
583
|
steps_done.append("hook-sync")
|
|
562
584
|
except Exception as e:
|