nexo-brain 2.6.0 → 2.6.2

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
@@ -450,62 +450,14 @@ def _update(args):
450
450
  - Explicit dev env: sync from NEXO_CODE/src
451
451
  - Packaged/runtime-only install: delegate to plugins.update handle_update()
452
452
  """
453
- import shutil
453
+ from auto_update import manual_sync_update, _resolve_sync_source
454
+ from runtime_power import ensure_power_policy_choice, apply_power_policy
454
455
 
455
- dest = NEXO_HOME
456
-
457
- def _runtime_version_source() -> Path | None:
458
- version_file = NEXO_HOME / "version.json"
459
- if not version_file.is_file():
460
- return None
461
- try:
462
- data = json.loads(version_file.read_text())
463
- except Exception:
464
- return None
465
- source = str(data.get("source", "")).strip()
466
- if not source:
467
- return None
468
- candidate = Path(source).expanduser()
469
- if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
470
- return candidate
471
- return None
472
-
473
- def _resolve_sync_source() -> tuple[Path | None, Path | None]:
474
- try:
475
- same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
476
- except Exception:
477
- same_as_runtime = NEXO_CODE == dest
478
-
479
- # Explicit dev mode: NEXO_CODE points at repo/src, never the installed runtime itself.
480
- if (
481
- not same_as_runtime
482
- and (NEXO_CODE / "db").is_dir()
483
- and (NEXO_CODE.parent / "package.json").is_file()
484
- ):
485
- return NEXO_CODE, NEXO_CODE.parent
486
-
487
- # Installed runtime linked back to a source checkout
488
- version_source = _runtime_version_source()
489
- if version_source:
490
- return version_source / "src", version_source
491
-
492
- return None, None
456
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
493
457
 
458
+ dest = NEXO_HOME
494
459
  src_dir, repo_dir = _resolve_sync_source()
495
460
 
496
- if src_dir is not None:
497
- try:
498
- if src_dir.resolve() == dest.resolve():
499
- version_source = _runtime_version_source()
500
- if version_source:
501
- src_dir = version_source / "src"
502
- repo_dir = version_source
503
- else:
504
- src_dir = None
505
- repo_dir = None
506
- except Exception:
507
- pass
508
-
509
461
  if src_dir is None or repo_dir is None:
510
462
  try:
511
463
  from plugins.update import handle_update
@@ -518,151 +470,42 @@ def _update(args):
518
470
  return 1
519
471
 
520
472
  result = handle_update()
473
+ choice = ensure_power_policy_choice(interactive=interactive, reason="update")
474
+ power_result = apply_power_policy(choice.get("policy"))
521
475
  if args.json:
522
476
  print(json.dumps({
523
477
  "mode": "packaged",
524
478
  "message": result,
479
+ "power_policy": choice.get("policy"),
480
+ "power_action": power_result.get("action"),
525
481
  }, indent=2, ensure_ascii=False))
526
482
  else:
527
483
  print(result)
484
+ if choice.get("prompted"):
485
+ print(f"Power policy: {choice.get('policy')}")
528
486
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
529
487
 
530
- # Packages (directories with __init__.py or known structure)
531
- packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
532
- copied_packages = 0
533
- for pkg in packages:
534
- pkg_src = src_dir / pkg
535
- pkg_dest = dest / pkg
536
- if pkg_src.is_dir():
537
- if pkg_dest.exists():
538
- shutil.rmtree(str(pkg_dest), ignore_errors=True)
539
- shutil.copytree(
540
- str(pkg_src), str(pkg_dest),
541
- ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db"),
542
- )
543
- copied_packages += 1
544
-
545
- # Flat Python files
546
- flat_files = [
547
- "server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
548
- "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
549
- "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
550
- "auto_update.py", "tools_sessions.py", "tools_coordination.py",
551
- "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
552
- "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
553
- "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
554
- "cron_recovery.py",
555
- "requirements.txt",
556
- ]
557
- copied_files = 0
558
- for f in flat_files:
559
- src_f = src_dir / f
560
- if src_f.is_file():
561
- shutil.copy2(str(src_f), str(dest / f))
562
- copied_files += 1
563
-
564
- # Plugins
565
- plugins_src = src_dir / "plugins"
566
- plugins_dest = dest / "plugins"
567
- if plugins_src.is_dir():
568
- plugins_dest.mkdir(parents=True, exist_ok=True)
569
- for f in plugins_src.iterdir():
570
- if f.is_file() and f.suffix == ".py":
571
- shutil.copy2(str(f), str(plugins_dest / f.name))
572
-
573
- # Scripts
574
- scripts_src = src_dir / "scripts"
575
- scripts_dest = dest / "scripts"
576
- if scripts_src.is_dir():
577
- scripts_dest.mkdir(parents=True, exist_ok=True)
578
- for f in scripts_src.iterdir():
579
- if f.name == "__pycache__" or f.name.startswith("."):
580
- continue
581
- dst = scripts_dest / f.name
582
- if f.is_dir():
583
- if dst.exists():
584
- shutil.rmtree(str(dst), ignore_errors=True)
585
- shutil.copytree(str(f), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
586
- elif f.is_file():
587
- shutil.copy2(str(f), str(dst))
588
- if f.suffix == ".sh":
589
- dst.chmod(0o755)
590
-
591
- # Templates
592
- templates_src = repo_dir / "templates"
593
- templates_dest = dest / "templates"
594
- if templates_src.is_dir():
595
- templates_dest.mkdir(parents=True, exist_ok=True)
596
- for f in templates_src.iterdir():
597
- if f.is_file():
598
- shutil.copy2(str(f), str(templates_dest / f.name))
599
-
600
- # Runtime version metadata
601
- package_json = repo_dir / "package.json"
602
- if package_json.is_file():
603
- shutil.copy2(str(package_json), str(dest / "package.json"))
604
- try:
605
- pkg = json.loads(package_json.read_text())
606
- version_payload = {
607
- "version": pkg.get("version", "?"),
608
- "source": str(repo_dir),
609
- }
610
- (dest / "version.json").write_text(json.dumps(version_payload, indent=2))
611
- except Exception:
612
- pass
613
-
614
- # Core skills
615
- skills_src = src_dir / "skills"
616
- skills_dest = dest / "skills-core"
617
- if skills_src.is_dir():
618
- if skills_dest.exists():
619
- shutil.rmtree(str(skills_dest), ignore_errors=True)
620
- shutil.copytree(
621
- str(skills_src), str(skills_dest),
622
- ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
623
- )
624
-
625
- # Runtime CLI wrapper
626
- bin_dir = dest / "bin"
627
- bin_dir.mkdir(parents=True, exist_ok=True)
628
- wrapper = bin_dir / "nexo"
629
- wrapper_content = (
630
- "#!/usr/bin/env bash\n"
631
- "set -euo pipefail\n\n"
632
- f'NEXO_HOME="{dest}"\n'
633
- 'PYTHON="$NEXO_HOME/.venv/bin/python3"\n'
634
- 'if [ ! -x "$PYTHON" ]; then\n'
635
- ' if command -v python3 >/dev/null 2>&1; then PYTHON="python3"; else PYTHON="python"; fi\n'
636
- 'fi\n'
637
- 'export NEXO_HOME\n'
638
- 'export NEXO_CODE="$NEXO_HOME"\n'
639
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"\n'
640
- )
641
- wrapper.write_text(wrapper_content)
642
- wrapper.chmod(0o755)
643
-
644
- try:
645
- from db import init_db
646
- from script_registry import sync_personal_scripts
647
-
648
- init_db()
649
- sync_personal_scripts()
650
- except Exception:
651
- pass
652
-
653
- result = {
654
- "mode": "sync",
655
- "packages": copied_packages,
656
- "files": copied_files,
657
- "nexo_home": str(dest),
658
- "source": str(src_dir),
659
- }
488
+ choice = ensure_power_policy_choice(interactive=interactive, reason="update")
489
+ power_result = apply_power_policy(choice.get("policy"))
490
+ result = manual_sync_update(interactive=interactive, allow_source_pull=True)
491
+ result["power_policy"] = choice.get("policy")
492
+ result["power_action"] = power_result.get("action")
660
493
  if args.json:
661
- print(json.dumps(result, indent=2))
494
+ print(json.dumps(result, indent=2, ensure_ascii=False))
662
495
  else:
663
- print(f"Updated NEXO_HOME ({dest})")
664
- print(f" {copied_packages} packages, {copied_files} files synced from {src_dir}")
665
- return 0
496
+ if result.get("ok"):
497
+ print(f"Updated NEXO_HOME ({dest})")
498
+ print(
499
+ f" {result.get('packages', 0)} packages, {result.get('files', 0)} files synced from "
500
+ f"{result.get('source', src_dir)}"
501
+ )
502
+ if result.get("pulled_source"):
503
+ print(" Source repo: pulled latest fast-forward before sync")
504
+ if choice.get("prompted"):
505
+ print(f" Power policy: {choice.get('policy')}")
506
+ else:
507
+ print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
508
+ return 0 if result.get("ok") else 1
666
509
 
667
510
 
668
511
  def _service_control(service_name: str, action: str) -> int:
@@ -733,6 +576,23 @@ def _chat(args):
733
576
  print("Claude Code launcher not found in PATH. Install `claude` first.", file=sys.stderr)
734
577
  return 1
735
578
 
579
+ try:
580
+ from auto_update import startup_preflight
581
+
582
+ preflight = startup_preflight(entrypoint="chat", interactive=False)
583
+ if preflight.get("updated"):
584
+ print("[NEXO] Startup update applied before chat.", file=sys.stderr)
585
+ elif preflight.get("deferred_reason"):
586
+ print(f"[NEXO] Update deferred: {preflight['deferred_reason']}", file=sys.stderr)
587
+ elif preflight.get("git_update"):
588
+ print(f"[NEXO] {preflight['git_update']}", file=sys.stderr)
589
+ elif preflight.get("npm_notice"):
590
+ print(f"[NEXO] {preflight['npm_notice']}", file=sys.stderr)
591
+ if preflight.get("error"):
592
+ print(f"[NEXO] Startup preflight warning: {preflight['error']}", file=sys.stderr)
593
+ except Exception:
594
+ pass
595
+
736
596
  result = subprocess.run(
737
597
  [claude_bin, "--dangerously-skip-permissions", target],
738
598
  env=os.environ.copy(),
@@ -57,6 +57,60 @@ def load_enabled_crons() -> list[dict]:
57
57
  return []
58
58
 
59
59
 
60
+ def _calendar_payload_from_declared(value: str) -> dict | None:
61
+ parts = str(value or "").split(":")
62
+ if len(parts) not in {2, 3}:
63
+ return None
64
+ try:
65
+ hour = int(parts[0])
66
+ minute = int(parts[1])
67
+ weekday = int(parts[2]) if len(parts) == 3 else None
68
+ except ValueError:
69
+ return None
70
+ payload = {"hour": hour, "minute": minute}
71
+ if weekday is not None:
72
+ payload["weekday"] = weekday
73
+ return payload
74
+
75
+
76
+ def load_managed_personal_crons() -> list[dict]:
77
+ try:
78
+ from script_registry import classify_scripts_dir, discover_personal_schedules
79
+ except Exception:
80
+ return []
81
+
82
+ scripts_by_path: dict[str, dict] = {}
83
+ for entry in classify_scripts_dir().get("entries", []):
84
+ if entry.get("classification") != "personal":
85
+ continue
86
+ scripts_by_path[str(entry.get("path", ""))] = entry
87
+
88
+ personal: list[dict] = []
89
+ for schedule in discover_personal_schedules():
90
+ script = scripts_by_path.get(str(schedule.get("script_path", "")))
91
+ declared = (script or {}).get("declared_schedule", {})
92
+ if not script or not declared.get("valid"):
93
+ continue
94
+ schedule_type = declared.get("schedule_type")
95
+ if schedule_type not in {"calendar", "interval"}:
96
+ continue
97
+ personal.append({
98
+ "id": schedule["cron_id"],
99
+ "script": schedule["script_path"],
100
+ "type": script.get("runtime", "python"),
101
+ "schedule": declared.get("schedule", ""),
102
+ "interval_seconds": int(declared.get("interval_seconds", 0) or 0),
103
+ "schedule_type": schedule_type,
104
+ "recovery_policy": declared.get("recovery_policy", "none"),
105
+ "idempotent": bool(declared.get("idempotent", False)),
106
+ "max_catchup_age": int(declared.get("max_catchup_age", 0) or 0),
107
+ "run_on_boot": bool(declared.get("run_on_boot", False)),
108
+ "run_on_wake": bool(declared.get("run_on_wake", False)),
109
+ "personal_managed": True,
110
+ })
111
+ return personal
112
+
113
+
60
114
  def default_recovery_policy(cron: dict) -> str:
61
115
  if cron.get("keep_alive") or cron.get("interval_seconds"):
62
116
  return "restart"
@@ -122,6 +176,23 @@ def launchagent_schedule(cron_id: str) -> dict:
122
176
 
123
177
 
124
178
  def effective_schedule(cron: dict) -> dict:
179
+ if cron.get("personal_managed"):
180
+ if cron.get("schedule_type") == "interval":
181
+ return {
182
+ "source": "personal",
183
+ "schedule_type": "interval",
184
+ "interval_seconds": int(cron.get("interval_seconds", 0) or 0),
185
+ "run_at_load": bool(cron.get("run_on_boot")),
186
+ }
187
+ if cron.get("schedule_type") == "calendar":
188
+ calendar = _calendar_payload_from_declared(str(cron.get("schedule", ""))) or {}
189
+ return {
190
+ "source": "personal",
191
+ "schedule_type": "calendar",
192
+ "calendar": calendar,
193
+ "run_at_load": bool(cron.get("run_on_boot")),
194
+ }
195
+
125
196
  actual = launchagent_schedule(cron["id"])
126
197
  if actual.get("schedule_type"):
127
198
  return actual
@@ -241,7 +312,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
241
312
  if now.tzinfo is None:
242
313
  now = now.replace(tzinfo=_local_timezone())
243
314
 
244
- crons = load_enabled_crons()
315
+ crons = load_enabled_crons() + load_managed_personal_crons()
245
316
  contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
246
317
  successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
247
318
  legacy = legacy_state_runs(state_file=STATE_FILE)
@@ -253,14 +324,22 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
253
324
  continue
254
325
  contract = contracts[cron_id]
255
326
  schedule = effective_schedule(cron)
256
- if contract["recovery_policy"] != "catchup":
257
- continue
258
- if schedule.get("schedule_type") != "calendar":
327
+ schedule_type = schedule.get("schedule_type")
328
+ if schedule_type not in {"calendar", "interval"}:
259
329
  continue
260
330
  if not contract["idempotent"]:
261
331
  continue
262
-
263
- due_at = last_scheduled_time(schedule["calendar"], now)
332
+ if schedule_type == "calendar":
333
+ if contract["recovery_policy"] != "catchup":
334
+ continue
335
+ due_at = last_scheduled_time(schedule["calendar"], now)
336
+ else:
337
+ if contract["recovery_policy"] not in {"catchup", "run_once_on_wake"}:
338
+ continue
339
+ interval_seconds = int(schedule.get("interval_seconds", 0) or 0)
340
+ if interval_seconds <= 0:
341
+ continue
342
+ due_at = now - timedelta(seconds=interval_seconds)
264
343
  last_success = successes.get(cron_id) or legacy.get(cron_id)
265
344
  age_seconds = max(int((now - due_at).total_seconds()), 0)
266
345
  missed = last_success is None or last_success < due_at
@@ -270,6 +349,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
270
349
  "cron_id": cron_id,
271
350
  "script": cron.get("script", ""),
272
351
  "type": cron.get("type", "python"),
352
+ "personal_managed": bool(cron.get("personal_managed")),
273
353
  "contract": contract,
274
354
  "schedule": schedule,
275
355
  "last_due_at": due_at,
package/src/crons/sync.py CHANGED
@@ -370,7 +370,7 @@ def sync_linux(dry_run: bool = False):
370
370
  LOG_DIR.mkdir(parents=True, exist_ok=True)
371
371
 
372
372
  manifest_crons = load_manifest()
373
- wrapper_src = NEXO_CODE / "scripts" / "nexo-cron-wrapper.sh"
373
+ wrapper_src = SOURCE_ROOT / "scripts" / "nexo-cron-wrapper.sh"
374
374
  wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
375
375
 
376
376
  log(f"Manifest: {len(manifest_crons)} core crons")
@@ -383,13 +383,13 @@ def sync_linux(dry_run: bool = False):
383
383
 
384
384
  for cron in manifest_crons:
385
385
  cron_id = cron["id"]
386
- script_src = NEXO_CODE / cron["script"]
386
+ script_src = SOURCE_ROOT / cron["script"]
387
387
  script_dest = _copy_script_to_nexo_home(script_src)
388
388
  script_type = cron.get("type", "python")
389
389
 
390
390
  # Copy subdirectories
391
391
  subdir_name = script_src.stem.replace("nexo-", "")
392
- subdir_src = NEXO_CODE / "scripts" / subdir_name
392
+ subdir_src = SOURCE_ROOT / "scripts" / subdir_name
393
393
  if subdir_src.is_dir():
394
394
  _copy_script_to_nexo_home(subdir_src)
395
395
 
@@ -411,7 +411,7 @@ Description=NEXO: {cron.get('description', cron_id)}
411
411
  Type=oneshot
412
412
  ExecStart={exec_cmd}
413
413
  Environment=NEXO_HOME={NEXO_HOME}
414
- Environment=NEXO_CODE={NEXO_CODE}
414
+ Environment=NEXO_CODE={SOURCE_ROOT}
415
415
  Environment=HOME={Path.home()}
416
416
  StandardOutput=append:{stdout_log}
417
417
  StandardError=append:{stderr_log}
package/src/nexo.db CHANGED
Binary file
@@ -10,7 +10,12 @@ from db import (
10
10
  init_db, cron_runs_recent, cron_runs_summary,
11
11
  upsert_personal_script, register_personal_script_schedule,
12
12
  )
13
- from script_registry import PERSONAL_SCHEDULE_MANAGED_ENV, parse_inline_metadata, classify_runtime
13
+ from script_registry import (
14
+ PERSONAL_SCHEDULE_MANAGED_ENV,
15
+ parse_inline_metadata,
16
+ classify_runtime,
17
+ get_declared_schedule,
18
+ )
14
19
 
15
20
 
16
21
  def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
@@ -76,6 +81,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
76
81
 
77
82
  script_meta = parse_inline_metadata(script_path)
78
83
  detected_runtime = classify_runtime(script_path, script_meta)
84
+ declared = get_declared_schedule(script_meta, script_meta.get("name", script_path.stem))
79
85
  script_type = (script_type or "auto").strip().lower()
80
86
  if script_type == "auto":
81
87
  script_type = detected_runtime if detected_runtime != "unknown" else "python"
@@ -96,6 +102,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
96
102
  description or script_meta.get("description", ""),
97
103
  script_type,
98
104
  nexo_home,
105
+ declared=declared,
99
106
  )
100
107
  elif system == "Linux":
101
108
  return _add_systemd_timer(
@@ -107,6 +114,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
107
114
  description or script_meta.get("description", ""),
108
115
  script_type,
109
116
  nexo_home,
117
+ declared=declared,
110
118
  )
111
119
  else:
112
120
  return f"ERROR: unsupported platform: {system}"
@@ -166,7 +174,7 @@ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds
166
174
 
167
175
 
168
176
  def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seconds,
169
- description, script_type, nexo_home):
177
+ description, script_type, nexo_home, *, declared: dict | None = None):
170
178
  """Create and load a macOS LaunchAgent."""
171
179
  import plistlib
172
180
 
@@ -202,6 +210,10 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
202
210
  cal["Weekday"] = int(parts[2])
203
211
  plist["StartCalendarInterval"] = cal
204
212
 
213
+ declared = declared or {}
214
+ if declared.get("run_on_boot"):
215
+ plist["RunAtLoad"] = True
216
+
205
217
  with open(plist_path, "wb") as f:
206
218
  plistlib.dump(plist, f)
207
219
 
@@ -222,7 +234,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
222
234
 
223
235
 
224
236
  def _add_systemd_timer(cron_id, script_path, wrapper_path, schedule, interval_seconds,
225
- description, script_type, nexo_home):
237
+ description, script_type, nexo_home, *, declared: dict | None = None):
226
238
  """Create and enable a systemd user timer (Linux)."""
227
239
  unit_dir = Path.home() / ".config" / "systemd" / "user"
228
240
  unit_dir.mkdir(parents=True, exist_ok=True)
@@ -246,8 +258,12 @@ Environment=NEXO_PERSONAL_CRON_ID={cron_id}
246
258
  service_path.write_text(service_content)
247
259
 
248
260
  # Timer unit
261
+ declared = declared or {}
262
+
249
263
  if interval_seconds:
250
- timer_spec = f"OnUnitActiveSec={interval_seconds}s\nOnBootSec=60s"
264
+ timer_spec = f"OnUnitActiveSec={interval_seconds}s"
265
+ if declared.get("run_on_boot") or not declared.get("required"):
266
+ timer_spec += "\nOnBootSec=60s"
251
267
  elif schedule:
252
268
  parts = schedule.split(":")
253
269
  hour, minute = int(parts[0]), int(parts[1])
@@ -9,24 +9,22 @@ import sys
9
9
  import time
10
10
  from pathlib import Path
11
11
 
12
- # Repo root: go up from src/plugins/ -> src/ -> repo/
12
+ # Code root is the parent of plugins/:
13
+ # - source checkout: <repo>/src
14
+ # - packaged runtime: <NEXO_HOME>
13
15
  _THIS_DIR = Path(__file__).resolve().parent
14
- REPO_DIR = _THIS_DIR.parent.parent
15
- PACKAGE_JSON = REPO_DIR / "package.json"
16
+ CODE_ROOT = _THIS_DIR.parent
17
+ _REPO_CANDIDATE = CODE_ROOT.parent
16
18
 
17
19
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
20
  DATA_DIR = NEXO_HOME / "data"
19
21
  BACKUP_BASE = NEXO_HOME / "backups"
20
22
 
21
- # In packaged installs, update.py lives at ~/.nexo/plugins/update.py
22
- # so REPO_DIR would be ~/ (wrong). Detect this and fix paths.
23
- _PACKAGED_INSTALL = not (REPO_DIR / ".git").exists() and not (REPO_DIR / ".git").is_file()
24
-
25
- if _PACKAGED_INSTALL:
26
- # In packaged mode, core .py files live directly in NEXO_HOME
27
- SRC_DIR = NEXO_HOME
28
- else:
29
- SRC_DIR = REPO_DIR / "src"
23
+ # In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
24
+ _PACKAGED_INSTALL = not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
25
+ REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
26
+ SRC_DIR = CODE_ROOT
27
+ PACKAGE_JSON = REPO_DIR / "package.json"
30
28
 
31
29
 
32
30
  def _find_npm_pkg_src() -> Path | None:
@@ -64,13 +62,27 @@ def _refresh_installed_manifest():
64
62
 
65
63
 
66
64
  def _read_version() -> str:
67
- """Read version from package.json or NEXO_HOME/version.json (packaged installs)."""
65
+ """Read the installed/runtime version."""
66
+ if _PACKAGED_INSTALL:
67
+ # version.json is the runtime truth for packaged installs.
68
+ try:
69
+ version_file = NEXO_HOME / "version.json"
70
+ if version_file.exists():
71
+ return json.loads(version_file.read_text()).get("version", "unknown")
72
+ except Exception:
73
+ pass
74
+ try:
75
+ package_file = NEXO_HOME / "package.json"
76
+ if package_file.exists():
77
+ return json.loads(package_file.read_text()).get("version", "unknown")
78
+ except Exception:
79
+ pass
80
+
68
81
  try:
69
82
  if PACKAGE_JSON.exists():
70
83
  return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
71
84
  except Exception:
72
85
  pass
73
- # Packaged installs don't ship package.json — check version.json in NEXO_HOME
74
86
  try:
75
87
  version_file = NEXO_HOME / "version.json"
76
88
  if version_file.exists():