nexo-brain 7.30.31 → 7.30.33

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.31",
3
+ "version": "7.30.33",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.30.31` is the current packaged-runtime line. Patch release over v7.30.30 - Core Rules now reach agents both through a compact managed bootstrap summary and task-specific `cortex/task_open` injection from the protected `core_rules` registry.
21
+ Version `7.30.33` is the current packaged-runtime line. Patch release over v7.30.32 - personal agent/script status now keeps the newest real run between manual executions and cron history, so a successful manual agent run cannot be hidden behind an older scheduled failure.
22
+
23
+ Previously in `7.30.32`: patch release over v7.30.31 - packaged update/doctor now repair npm/npx wrapper drift, archive stale personal script backups, validate observable automation health contracts, and block legacy Claude/Codex project memory writes.
24
+
25
+ Previously in `7.30.31`: patch release over v7.30.30 - Core Rules now reach agents both through a compact managed bootstrap summary and task-specific `cortex/task_open` injection from the protected `core_rules` registry.
22
26
 
23
27
  Previously in `7.30.30`: product-managed Core Rules now sync from `src/rules/core-rules.json` into protected DB rows for bootstrap and product behavior, with provenance, hashes, severity, and install/update synchronization.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.31",
3
+ "version": "7.30.33",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -23,6 +23,30 @@ def _now_text() -> str:
23
23
  return datetime.datetime.now().isoformat(timespec="seconds")
24
24
 
25
25
 
26
+ def _run_timestamp_sort_key(value: str | None) -> tuple[int, str]:
27
+ text = str(value or "").strip()
28
+ if not text:
29
+ return (0, "")
30
+ normalized = text.replace("Z", "+00:00")
31
+ try:
32
+ parsed = datetime.datetime.fromisoformat(normalized)
33
+ except ValueError:
34
+ return (1, text)
35
+ if parsed.tzinfo is not None:
36
+ parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
37
+ return (2, parsed.isoformat(timespec="seconds"))
38
+
39
+
40
+ def _newer_run(candidate: dict | None, current: dict | None) -> dict | None:
41
+ if not candidate or not candidate.get("started_at"):
42
+ return current
43
+ if not current or not current.get("started_at"):
44
+ return candidate
45
+ if _run_timestamp_sort_key(candidate.get("started_at")) > _run_timestamp_sort_key(current.get("started_at")):
46
+ return candidate
47
+ return current
48
+
49
+
26
50
  def _get_db():
27
51
  """Resolve db._core lazily so reload-heavy tests use the live connection module."""
28
52
  return importlib.import_module("db._core").get_db()
@@ -406,11 +430,16 @@ def list_personal_scripts(include_disabled: bool = True, *, include_core: bool =
406
430
  schedule["last_exit_code"] = latest["exit_code"]
407
431
  script["schedules"] = script_schedules
408
432
  script["has_schedule"] = bool(script_schedules)
409
- latest = None
433
+ latest = _newer_run(
434
+ {"started_at": script.get("last_run_at"), "exit_code": script.get("last_exit_code")},
435
+ None,
436
+ )
410
437
  for schedule in script_schedules:
411
438
  started_at = schedule.get("last_run_at")
412
- if started_at and (latest is None or started_at > latest.get("started_at", "")):
413
- latest = {"started_at": started_at, "exit_code": schedule.get("last_exit_code")}
439
+ latest = _newer_run(
440
+ {"started_at": started_at, "exit_code": schedule.get("last_exit_code")},
441
+ latest,
442
+ )
414
443
  if latest:
415
444
  script["last_run_at"] = latest["started_at"]
416
445
  script["last_exit_code"] = latest["exit_code"]
@@ -2016,9 +2016,14 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
2016
2016
  """Check the DB-backed personal script registry against filesystem/plists."""
2017
2017
  try:
2018
2018
  from db import init_db, get_personal_script_health_report
2019
- from script_registry import repair_orphan_personal_schedule_metadata, sync_personal_scripts
2019
+ from script_registry import (
2020
+ archive_ignored_personal_script_artifacts,
2021
+ repair_orphan_personal_schedule_metadata,
2022
+ sync_personal_scripts,
2023
+ )
2020
2024
 
2021
2025
  init_db()
2026
+ backup_cleanup = archive_ignored_personal_script_artifacts(dry_run=not fix)
2022
2027
  if fix:
2023
2028
  repair_orphan_personal_schedule_metadata(dry_run=False)
2024
2029
  sync_personal_scripts(prune_missing=True)
@@ -2032,7 +2037,17 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
2032
2037
  summary=f"Personal scripts registry check failed: {e}",
2033
2038
  )
2034
2039
 
2035
- issues = report.get("issues", [])
2040
+ issues = list(report.get("issues", []))
2041
+ backup_candidates = backup_cleanup.get("candidates", []) if isinstance(backup_cleanup, dict) else []
2042
+ backup_archived = backup_cleanup.get("archived", []) if isinstance(backup_cleanup, dict) else []
2043
+ if backup_candidates and not fix:
2044
+ issues.append({
2045
+ "severity": "warn",
2046
+ "message": (
2047
+ f"{len(backup_candidates)} ignored personal script backup artifact(s) still carry NEXO metadata; "
2048
+ "doctor --fix will archive them outside personal/scripts"
2049
+ ),
2050
+ })
2036
2051
  if not issues:
2037
2052
  audit = report.get("schedule_audit", {}).get("summary", {})
2038
2053
  summary = (
@@ -2047,6 +2062,8 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
2047
2062
  )
2048
2063
  if fix:
2049
2064
  summary += " (fixed)"
2065
+ if backup_archived:
2066
+ summary += f", archived {len(backup_archived)} stale backup artifact(s)"
2050
2067
  return DoctorCheck(
2051
2068
  id="runtime.personal_scripts",
2052
2069
  tier="runtime",
@@ -2079,6 +2096,103 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
2079
2096
  )
2080
2097
 
2081
2098
 
2099
+ def _truthy_metadata(value) -> bool:
2100
+ if isinstance(value, bool):
2101
+ return value
2102
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
2103
+
2104
+
2105
+ def _metadata_int(value, fallback: int = 0) -> int:
2106
+ try:
2107
+ return int(str(value or "").strip())
2108
+ except Exception:
2109
+ return fallback
2110
+
2111
+
2112
+ def _resolve_personal_health_path(raw: str) -> Path:
2113
+ path = Path(str(raw or "").strip()).expanduser()
2114
+ if path.is_absolute():
2115
+ return path
2116
+ return NEXO_HOME / path
2117
+
2118
+
2119
+ def check_personal_automation_health_contracts() -> DoctorCheck:
2120
+ """Check observable success contracts for scheduled personal automations."""
2121
+ try:
2122
+ from script_registry import list_scripts
2123
+ scripts = list_scripts(include_core=False)
2124
+ except Exception as exc:
2125
+ return DoctorCheck(
2126
+ id="runtime.personal_automation_health",
2127
+ tier="runtime",
2128
+ status="degraded",
2129
+ severity="warn",
2130
+ summary=f"Personal automation health check failed: {exc}",
2131
+ )
2132
+
2133
+ issues: list[str] = []
2134
+ checked = 0
2135
+ scheduled = 0
2136
+ now = time.time()
2137
+ for script in scripts:
2138
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
2139
+ declared = script.get("declared_schedule") if isinstance(script.get("declared_schedule"), dict) else {}
2140
+ if not declared.get("required"):
2141
+ continue
2142
+ scheduled += 1
2143
+ name = str(script.get("name") or metadata.get("name") or Path(str(script.get("path") or "")).name)
2144
+ health_file_raw = str(metadata.get("health_file") or "").strip()
2145
+ if not health_file_raw:
2146
+ issues.append(f"{name}: scheduled automation has no health_file contract")
2147
+ continue
2148
+ checked += 1
2149
+ health_file = _resolve_personal_health_path(health_file_raw)
2150
+ if not health_file.is_file():
2151
+ issues.append(f"{name}: health_file missing ({health_file})")
2152
+ continue
2153
+ try:
2154
+ stat = health_file.stat()
2155
+ content = health_file.read_text(errors="ignore")
2156
+ except Exception as exc:
2157
+ issues.append(f"{name}: health_file unreadable ({exc})")
2158
+ continue
2159
+ max_age = _metadata_int(metadata.get("health_max_age_seconds"), 0)
2160
+ if max_age > 0 and now - stat.st_mtime > max_age:
2161
+ issues.append(f"{name}: health_file stale ({int(now - stat.st_mtime)}s > {max_age}s)")
2162
+ if _truthy_metadata(metadata.get("health_nonempty")) and not content.strip():
2163
+ issues.append(f"{name}: health_file is empty")
2164
+ must_contain = str(metadata.get("health_must_contain") or "").strip()
2165
+ if must_contain and must_contain not in content:
2166
+ issues.append(f"{name}: health_file does not contain required marker `{must_contain}`")
2167
+
2168
+ if not issues:
2169
+ return DoctorCheck(
2170
+ id="runtime.personal_automation_health",
2171
+ tier="runtime",
2172
+ status="healthy",
2173
+ severity="info",
2174
+ summary=f"Scheduled personal automation health contracts OK ({checked}/{scheduled} checked)",
2175
+ )
2176
+
2177
+ return DoctorCheck(
2178
+ id="runtime.personal_automation_health",
2179
+ tier="runtime",
2180
+ status="degraded",
2181
+ severity="warn",
2182
+ summary=f"Personal automation health contract issues detected in {len(issues)} item(s)",
2183
+ evidence=issues[:12],
2184
+ repair_plan=[
2185
+ "Add `# nexo: health_file=...` to scheduled personal automations that must prove useful output",
2186
+ "Use `health_max_age_seconds`, `health_nonempty=true`, or `health_must_contain=...` for semantic success checks",
2187
+ "Run `nexo doctor --tier runtime` after updates to catch stale or missing automation outputs",
2188
+ ],
2189
+ escalation_prompt=(
2190
+ "Scheduled personal automations need observable success contracts; a cron exit code alone is not enough "
2191
+ "to know that useful work happened."
2192
+ ),
2193
+ )
2194
+
2195
+
2082
2196
  def check_client_backend_preferences(fix: bool = False) -> DoctorCheck:
2083
2197
  schedule = {}
2084
2198
  try:
@@ -2212,6 +2326,60 @@ def check_client_backend_preferences(fix: bool = False) -> DoctorCheck:
2212
2326
  )
2213
2327
 
2214
2328
 
2329
+ def check_packaged_update_npm_toolchain(fix: bool = False) -> DoctorCheck:
2330
+ """Verify the npm runtime used by packaged Brain updates can actually run."""
2331
+ try:
2332
+ from plugins.update import packaged_npm_toolchain_status
2333
+ status = packaged_npm_toolchain_status(repair=fix)
2334
+ except Exception as exc:
2335
+ return DoctorCheck(
2336
+ id="runtime.packaged_update_npm",
2337
+ tier="runtime",
2338
+ status="degraded",
2339
+ severity="warn",
2340
+ summary=f"Packaged update npm check failed: {exc}",
2341
+ )
2342
+
2343
+ attempts = status.get("attempts") if isinstance(status, dict) else []
2344
+ evidence: list[str] = []
2345
+ for item in (attempts[:6] if isinstance(attempts, list) else []):
2346
+ cmd = " ".join(str(part) for part in (item.get("cmd") or []))
2347
+ if item.get("ok"):
2348
+ evidence.append(f"ok: {cmd}")
2349
+ else:
2350
+ evidence.append(f"failed: {cmd}: {item.get('error') or 'unknown error'}")
2351
+
2352
+ repaired = status.get("repaired") if isinstance(status, dict) else []
2353
+ if status.get("ok"):
2354
+ summary = "Packaged update npm runtime is healthy"
2355
+ if repaired:
2356
+ summary += f" (repaired wrappers: {', '.join(str(item) for item in repaired)})"
2357
+ return DoctorCheck(
2358
+ id="runtime.packaged_update_npm",
2359
+ tier="runtime",
2360
+ status="healthy",
2361
+ severity="info",
2362
+ summary=summary,
2363
+ evidence=evidence,
2364
+ fixed=bool(fix and repaired),
2365
+ )
2366
+
2367
+ return DoctorCheck(
2368
+ id="runtime.packaged_update_npm",
2369
+ tier="runtime",
2370
+ status="critical",
2371
+ severity="error",
2372
+ summary="Packaged update cannot find a healthy npm runtime",
2373
+ evidence=evidence,
2374
+ repair_plan=[
2375
+ "Run `nexo doctor --tier runtime --fix` to repair NEXO npm/npx wrappers when a healthy Node/npm exists",
2376
+ "Install Node.js or restore an nvm Node version if no healthy npm candidate exists",
2377
+ "Retry `nexo update` after the npm runtime check is healthy",
2378
+ ],
2379
+ escalation_prompt="Packaged Brain update cannot run npm safely; repair the local Node/npm runtime before updating.",
2380
+ )
2381
+
2382
+
2215
2383
  def check_client_bootstrap_parity(fix: bool = False) -> DoctorCheck:
2216
2384
  """Check managed Claude/Codex bootstrap documents and CORE/USER markers."""
2217
2385
  try:
@@ -4137,6 +4305,7 @@ def run_runtime_checks(fix: bool = False, plane: str = "") -> list[DoctorCheck]:
4137
4305
  safe_check(check_stale_sessions),
4138
4306
  safe_check(check_cron_freshness),
4139
4307
  safe_check(check_client_backend_preferences, fix=fix),
4308
+ safe_check(check_packaged_update_npm_toolchain, fix=fix),
4140
4309
  safe_check(check_client_bootstrap_parity, fix=fix),
4141
4310
  safe_check(check_codex_session_parity),
4142
4311
  safe_check(check_bootstrap_reached_startup),
@@ -4156,6 +4325,7 @@ def run_runtime_checks(fix: bool = False, plane: str = "") -> list[DoctorCheck]:
4156
4325
  safe_check(check_launchagent_inventory),
4157
4326
  safe_check(check_launchagent_integrity, fix=fix),
4158
4327
  safe_check(check_personal_script_registry, fix=fix),
4328
+ safe_check(check_personal_automation_health_contracts),
4159
4329
  safe_check(check_skill_health, fix=fix),
4160
4330
  ]
4161
4331
  return _filter_runtime_checks_for_plane(checks, plane=plane)
@@ -575,6 +575,14 @@ def _is_legacy_client_memory_path(path_value: str) -> bool:
575
575
  return True
576
576
  if len(parts) >= 2 and parts[0] in {".claude", ".codex"} and parts[1] == "memories":
577
577
  return True
578
+ if (
579
+ len(parts) >= 5
580
+ and parts[0] in {".claude", ".codex"}
581
+ and parts[1] == "projects"
582
+ and parts[-2] == "memory"
583
+ and parts[-1] == "MEMORY.md"
584
+ ):
585
+ return True
578
586
  return False
579
587
 
580
588
 
@@ -6,11 +6,13 @@
6
6
  "chrome-devtools-mcp": {
7
7
  "source_type": "npm",
8
8
  "package": "chrome-devtools-mcp",
9
- "version": "1.1.1",
10
- "integrity": "sha512-Fs/ASXAkQqvYCbJjHIx/pnShjyIoZoPxdg4J3wjaA9FLkRb2ngGnisu2AGcBIXdw5qrPkOuV/cOlGOonpsE1qw==",
11
- "tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.1.1.tgz",
9
+ "version": "1.2.0",
10
+ "integrity": "sha512-xHd8hoLZQArDsYhu8OUHvKBIiihx1Co9DgAPHWaM4kzRf41TpZ0IuxKioIWTEGzFKpRqQzIxpFqydY4AKqP5sQ==",
11
+ "tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.2.0.tgz",
12
12
  "bin": "chrome-devtools-mcp",
13
- "engines": {"node": "^20.19.0 || ^22.12.0 || >=23"}
13
+ "engines": {
14
+ "node": "^20.19.0 || ^22.12.0 || >=23"
15
+ }
14
16
  },
15
17
  "mac-use-mcp": {
16
18
  "source_type": "npm",
@@ -19,7 +21,9 @@
19
21
  "integrity": "sha512-UcVkzvHuw+f21nEZwb3MwdqWGxLK/nYlhN55SRD6FZ2yn2t3ji0zKLD2XvQLp0ugeYyS92TqVgnw6E4P5VB+bg==",
20
22
  "tarball": "https://registry.npmjs.org/mac-use-mcp/-/mac-use-mcp-1.1.1.tgz",
21
23
  "bin": "mac-use-mcp",
22
- "engines": {"node": ">=22"}
24
+ "engines": {
25
+ "node": ">=22"
26
+ }
23
27
  },
24
28
  "native-devtools-mcp": {
25
29
  "source_type": "npm",
@@ -28,7 +32,9 @@
28
32
  "integrity": "sha512-TIR8QCKzYCaHY+N1IWB7OM6pZH49HJxRj1dZjT4RNkviA1QpgINm8H95ohMCbf4ZC5jdFssMlb9KmWNZnCeCSw==",
29
33
  "tarball": "https://registry.npmjs.org/native-devtools-mcp/-/native-devtools-mcp-0.10.1.tgz",
30
34
  "bin": "native-devtools-mcp",
31
- "engines": {"node": ">=18"}
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
32
38
  },
33
39
  "open-computer-use": {
34
40
  "source_type": "npm",
@@ -37,7 +43,7 @@
37
43
  "integrity": "sha512-KlOHmFvXHe2IEMGE/O+zMN5ASo+FQ42copj4j1xEOnyeLq4oxUxhtHqEdPUACCUcMZaHzKXfZboL1dk5a2GjLA==",
38
44
  "tarball": "https://registry.npmjs.org/open-computer-use/-/open-computer-use-0.1.52.tgz",
39
45
  "bin": "open-computer-use-mcp",
40
- "engines": {"node": "^20.19.0 || ^22.12.0 || >=23"}
46
+ "engines": {}
41
47
  },
42
48
  "desktop-commander": {
43
49
  "source_type": "npm",
@@ -46,7 +52,9 @@
46
52
  "integrity": "sha512-ZgdBDihpaLfrzQQQGQCPmElYMx91oUXeVEWbxbygeUfq2aOZvHrcVMeuTGy9oMDp9vxjq6d/+ZGE0mQLJnAWkw==",
47
53
  "tarball": "https://registry.npmjs.org/@wonderwhy-er/desktop-commander/-/desktop-commander-0.2.42.tgz",
48
54
  "bin": "desktop-commander",
49
- "engines": {"node": ">=18.0.0"}
55
+ "engines": {
56
+ "node": ">=18.0.0"
57
+ }
50
58
  }
51
59
  }
52
60
  }
@@ -410,6 +410,142 @@ def _apply_desktop_npm_prefix(env: dict[str, str]) -> None:
410
410
  env["PATH"] = os.pathsep.join([prefix_bin, *[entry for entry in entries if entry != prefix_bin]])
411
411
 
412
412
 
413
+ def _prepend_path(env: dict[str, str], directory: Path | str) -> None:
414
+ directory = Path(directory)
415
+ if not str(directory):
416
+ return
417
+ current_path = str(env.get("PATH", ""))
418
+ entries = [entry for entry in current_path.split(os.pathsep) if entry]
419
+ directory_text = str(directory)
420
+ env["PATH"] = os.pathsep.join([directory_text, *[entry for entry in entries if entry != directory_text]])
421
+
422
+
423
+ def _candidate_npm_paths() -> list[Path]:
424
+ candidates: list[Path] = []
425
+ seen: set[str] = set()
426
+
427
+ def add(candidate: str | Path | None) -> None:
428
+ if not candidate:
429
+ return
430
+ path = Path(str(candidate)).expanduser()
431
+ key = str(path)
432
+ if key in seen:
433
+ return
434
+ seen.add(key)
435
+ candidates.append(path)
436
+
437
+ add(shutil.which("npm"))
438
+ for base in (
439
+ Path("/opt/homebrew/bin/npm"),
440
+ Path("/usr/local/bin/npm"),
441
+ Path.home() / ".nexo" / "bin" / "npm",
442
+ ):
443
+ add(base)
444
+ nvm_root = Path.home() / ".nvm" / "versions" / "node"
445
+ if nvm_root.is_dir():
446
+ for npm_path in sorted(nvm_root.glob("*/bin/npm"), reverse=True):
447
+ add(npm_path)
448
+ return candidates
449
+
450
+
451
+ def _npm_probe(cmd: list[str], env: dict[str, str]) -> dict:
452
+ evidence: list[str] = []
453
+ try:
454
+ version = subprocess.run([*cmd, "--version"], env=env, capture_output=True, text=True, timeout=10)
455
+ except FileNotFoundError as exc:
456
+ return {"ok": False, "error": f"not found: {exc}", "evidence": evidence}
457
+ except Exception as exc:
458
+ return {"ok": False, "error": str(exc), "evidence": evidence}
459
+ evidence.append(f"npm --version rc={version.returncode} out={str(version.stdout or version.stderr).strip()[:160]}")
460
+ if version.returncode != 0:
461
+ return {"ok": False, "error": str(version.stderr or version.stdout).strip()[:300], "evidence": evidence}
462
+ try:
463
+ root = subprocess.run([*cmd, "root", "-g"], env=env, capture_output=True, text=True, timeout=10)
464
+ except Exception as exc:
465
+ return {"ok": False, "error": f"npm root -g failed: {exc}", "evidence": evidence}
466
+ evidence.append(f"npm root -g rc={root.returncode} out={str(root.stdout or root.stderr).strip()[:160]}")
467
+ if root.returncode != 0:
468
+ return {"ok": False, "error": str(root.stderr or root.stdout).strip()[:300], "evidence": evidence}
469
+ return {"ok": True, "error": "", "evidence": evidence}
470
+
471
+
472
+ def _write_nexo_bin_wrapper(wrapper: Path, target: Path) -> bool:
473
+ if not target.exists():
474
+ return False
475
+ content = f'#!/bin/sh\nexec "{target}" "$@"\n'
476
+ try:
477
+ if wrapper.exists() and wrapper.read_text(errors="ignore") == content:
478
+ return False
479
+ except Exception:
480
+ pass
481
+ wrapper.parent.mkdir(parents=True, exist_ok=True)
482
+ if wrapper.exists():
483
+ backup_dir = NEXO_HOME / "runtime" / "backups" / "npm-shim-repair"
484
+ backup_dir.mkdir(parents=True, exist_ok=True)
485
+ shutil.copy2(wrapper, backup_dir / f"{int(time.time())}-{wrapper.name}")
486
+ wrapper.write_text(content)
487
+ wrapper.chmod(0o755)
488
+ return True
489
+
490
+
491
+ def _repair_nexo_npm_wrappers(selected_npm: Path | None) -> list[str]:
492
+ if not selected_npm:
493
+ return []
494
+ selected_bin = selected_npm.parent
495
+ repaired: list[str] = []
496
+ wrapper_dir = NEXO_HOME / "bin"
497
+ for name in ("npm", "npx"):
498
+ target = selected_bin / name
499
+ if _write_nexo_bin_wrapper(wrapper_dir / name, target):
500
+ repaired.append(name)
501
+ return repaired
502
+
503
+
504
+ def packaged_npm_toolchain_status(*, repair: bool = False) -> dict:
505
+ """Resolve a healthy npm invocation for packaged update/doctor paths."""
506
+ default_cmd, default_env = _npm_command_parts()
507
+ attempts: list[dict] = []
508
+ default_probe = _npm_probe(default_cmd, default_env)
509
+ attempts.append({"cmd": default_cmd, **default_probe})
510
+ if default_probe.get("ok"):
511
+ return {"ok": True, "cmd": default_cmd, "env": default_env, "attempts": attempts, "repaired": []}
512
+
513
+ for npm_path in _candidate_npm_paths():
514
+ if not npm_path.exists():
515
+ continue
516
+ env = dict(os.environ)
517
+ _prepend_path(env, npm_path.parent)
518
+ probe = _npm_probe([str(npm_path)], env)
519
+ attempts.append({"cmd": [str(npm_path)], **probe})
520
+ if not probe.get("ok"):
521
+ continue
522
+ repaired = _repair_nexo_npm_wrappers(npm_path) if repair else []
523
+ if repaired:
524
+ # Re-probe through the normal path after repair so updates use the
525
+ # same command a fresh shell will resolve.
526
+ refreshed_cmd, refreshed_env = _npm_command_parts()
527
+ refreshed_probe = _npm_probe(refreshed_cmd, refreshed_env)
528
+ attempts.append({"cmd": refreshed_cmd, **refreshed_probe, "after_repair": True})
529
+ if refreshed_probe.get("ok"):
530
+ return {
531
+ "ok": True,
532
+ "cmd": refreshed_cmd,
533
+ "env": refreshed_env,
534
+ "attempts": attempts,
535
+ "repaired": repaired,
536
+ }
537
+ return {"ok": True, "cmd": [str(npm_path)], "env": env, "attempts": attempts, "repaired": repaired}
538
+
539
+ return {
540
+ "ok": False,
541
+ "cmd": default_cmd,
542
+ "env": default_env,
543
+ "attempts": attempts,
544
+ "repaired": [],
545
+ "error": "No healthy npm runtime found.",
546
+ }
547
+
548
+
413
549
  def _run_npm(args: list[str], **kwargs):
414
550
  cmd, env = _npm_command_parts()
415
551
  extra_env = kwargs.pop("env", None)
@@ -1377,6 +1513,18 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1377
1513
  if wipe_err:
1378
1514
  return f"ABORTED (wipe guard): {wipe_err}"
1379
1515
 
1516
+ _emit_progress(progress_fn, "Checking npm runtime for packaged update...")
1517
+ npm_status = packaged_npm_toolchain_status(repair=True)
1518
+ if not npm_status.get("ok"):
1519
+ attempts = "; ".join(
1520
+ f"{' '.join(item.get('cmd') or [])}: {item.get('error') or 'failed'}"
1521
+ for item in npm_status.get("attempts", [])[:6]
1522
+ if isinstance(item, dict)
1523
+ )
1524
+ return f"ABORTED: npm runtime unavailable. {attempts or npm_status.get('error') or 'No npm candidate worked.'}"
1525
+ npm_cmd = list(npm_status.get("cmd") or ["npm"])
1526
+ npm_env = dict(npm_status.get("env") or os.environ)
1527
+
1380
1528
  # 1. Backup databases BEFORE any changes
1381
1529
  _emit_progress(progress_fn, "Backing up runtime databases...")
1382
1530
  backup_dir, backup_err = _backup_databases()
@@ -1395,10 +1543,11 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1395
1543
  # 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
1396
1544
  try:
1397
1545
  _emit_progress(progress_fn, "Downloading and applying the latest npm package...")
1398
- result = _run_npm(
1399
- ["install", "-g", "nexo-brain@latest"],
1546
+ install_env = {**npm_env, "NEXO_HOME": str(NEXO_HOME)}
1547
+ result = subprocess.run(
1548
+ [*npm_cmd, "install", "-g", "nexo-brain@latest"],
1400
1549
  capture_output=True, text=True, timeout=120,
1401
- env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
1550
+ env=install_env,
1402
1551
  )
1403
1552
  if result.returncode != 0:
1404
1553
  # npm failed (including postinstall failures) — full rollback
@@ -102,6 +102,10 @@ METADATA_KEYS = {
102
102
  "agent_archived",
103
103
  "agent_enabled_before_archive",
104
104
  "agent_icon",
105
+ "health_file",
106
+ "health_max_age_seconds",
107
+ "health_nonempty",
108
+ "health_must_contain",
105
109
  }
106
110
  AGENT_METADATA_KEYS = {
107
111
  "agent",
@@ -125,6 +129,10 @@ METADATA_WRITE_ORDER = [
125
129
  "agent_archived",
126
130
  "agent_enabled_before_archive",
127
131
  "agent_icon",
132
+ "health_file",
133
+ "health_max_age_seconds",
134
+ "health_nonempty",
135
+ "health_must_contain",
128
136
  "cron_id",
129
137
  "schedule_required",
130
138
  "schedule",
@@ -179,6 +187,10 @@ PRODUCT_AUTOMATION_NAMES = (
179
187
  "followup-runner",
180
188
  "morning-agent",
181
189
  )
190
+ _BACKUP_ARTIFACT_RE = re.compile(
191
+ r"(?:\.bak(?:[-.][\w.-]+)?|\.backup(?:[-.][\w.-]+)?|\.old(?:[-.][\w.-]+)?)$",
192
+ re.IGNORECASE,
193
+ )
182
194
 
183
195
 
184
196
  def get_nexo_home() -> Path:
@@ -531,7 +543,7 @@ def _is_ignored(path: Path) -> bool:
531
543
  """Check if file should be ignored entirely."""
532
544
  if path.name in _IGNORED_FILES:
533
545
  return True
534
- if re.search(r"\.bak(?:-[\w.-]+)?$", path.name, re.IGNORECASE):
546
+ if _BACKUP_ARTIFACT_RE.search(path.name):
535
547
  return True
536
548
  if path.name.endswith("~"):
537
549
  return True
@@ -547,6 +559,38 @@ def _is_ignored(path: Path) -> bool:
547
559
  return False
548
560
 
549
561
 
562
+ def archive_ignored_personal_script_artifacts(*, dry_run: bool = True) -> dict:
563
+ """Move ignored backup artifacts with NEXO metadata out of personal/scripts."""
564
+ scripts_dir = get_scripts_dir()
565
+ result = {"ok": True, "candidates": [], "archived": [], "errors": []}
566
+ if not scripts_dir.is_dir():
567
+ return result
568
+ backup_root = paths.backups_dir() / "personal-script-backups"
569
+ for path in sorted(scripts_dir.iterdir()):
570
+ if not path.is_file() or not _BACKUP_ARTIFACT_RE.search(path.name):
571
+ continue
572
+ meta = parse_inline_metadata(path)
573
+ if not meta:
574
+ continue
575
+ item = {"path": str(path), "name": str(meta.get("name") or path.name)}
576
+ result["candidates"].append(item)
577
+ if dry_run:
578
+ continue
579
+ try:
580
+ backup_root.mkdir(parents=True, exist_ok=True)
581
+ target = backup_root / f"{int(time.time())}-{path.name}"
582
+ counter = 1
583
+ while target.exists():
584
+ target = backup_root / f"{int(time.time())}-{counter}-{path.name}"
585
+ counter += 1
586
+ shutil.move(str(path), str(target))
587
+ result["archived"].append({**item, "backup_path": str(target)})
588
+ except Exception as exc:
589
+ result["ok"] = False
590
+ result["errors"].append({"path": str(path), "error": str(exc)})
591
+ return result
592
+
593
+
550
594
  def _is_script_candidate(path: Path, metadata: dict | None = None) -> bool:
551
595
  metadata = metadata or {}
552
596
  runtime = classify_runtime(path, metadata)