nexo-brain 7.30.31 → 7.30.32
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 +3 -1
- package/package.json +1 -1
- package/src/doctor/providers/runtime.py +172 -2
- package/src/hook_guardrails.py +8 -0
- package/src/plugins/update.py +152 -3
- package/src/script_registry.py +45 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.32",
|
|
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,9 @@
|
|
|
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.
|
|
21
|
+
Version `7.30.32` is the current packaged-runtime line. 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.
|
|
22
|
+
|
|
23
|
+
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
24
|
|
|
23
25
|
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
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.32",
|
|
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",
|
|
@@ -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
|
|
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)
|
package/src/hook_guardrails.py
CHANGED
|
@@ -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
|
|
package/src/plugins/update.py
CHANGED
|
@@ -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
|
-
|
|
1399
|
-
|
|
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=
|
|
1550
|
+
env=install_env,
|
|
1402
1551
|
)
|
|
1403
1552
|
if result.returncode != 0:
|
|
1404
1553
|
# npm failed (including postinstall failures) — full rollback
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
|
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)
|