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/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 ensure_power_policy_choice, apply_power_policy, format_power_policy_label
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
- result = manual_sync_update(interactive=interactive, allow_source_pull=True)
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)
@@ -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 ALL entries from the most recent day (multi-terminal aware)
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 WHERE date(created_at) = ?{domain_clause}{source_clause} ORDER BY created_at DESC",
622
- (latest['day'],) + domain_params
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()
@@ -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 ALL entries from the most recent day (multi-terminal aware). Use this at startup.
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
+ ]
@@ -341,11 +341,20 @@ def _rollback_npm_package(target_version: str) -> str | None:
341
341
  return None
342
342
 
343
343
 
344
- def _handle_packaged_update() -> str:
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: