nexo-brain 7.30.30 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.29",
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,13 @@
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.28` is the current packaged-runtime line. Patch release over v7.30.27 - F0.6 runtime repairs now run through an existing post-install hook, so older updaters execute script-conflict recovery and `core/current` refresh on the first upgrade.
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.
24
+
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.
26
+
27
+ Previously in `7.30.29`: runtime disk guards now bound hourly database backups and pause Local Memory indexing before disk pressure becomes unsafe.
22
28
 
23
29
  Previously in `7.30.27`: patch release over v7.30.26 - post-update repair now recovers core scripts archived by older F0.6 shim reconciliation and refreshes `core/current` from `core`, so same-version snapshots cannot keep stale watchdog code.
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.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",
package/src/db/_schema.py CHANGED
@@ -3071,6 +3071,7 @@ def _m80_opportunity_orchestrator(conn):
3071
3071
 
3072
3072
  def _m81_core_rules_product_metadata(conn):
3073
3073
  """Add product-core provenance and protection metadata to core_rules."""
3074
+ _m15_core_rules_tables(conn)
3074
3075
  _migrate_add_column(conn, "core_rules", "source_artifact", "TEXT DEFAULT ''")
3075
3076
  _migrate_add_column(conn, "core_rules", "source_anchor", "TEXT DEFAULT ''")
3076
3077
  _migrate_add_column(conn, "core_rules", "content_hash", "TEXT DEFAULT ''")
@@ -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
 
@@ -31,25 +31,159 @@ def _get_db():
31
31
  return get_db()
32
32
 
33
33
 
34
+ _CLASSIC_RULE_CATEGORIES_BY_TASK = {
35
+ "edit": ["integrity", "execution"],
36
+ "execute": ["integrity", "execution", "delegation"],
37
+ "delegate": ["delegation"],
38
+ "analyze": ["execution", "memory"],
39
+ "answer": ["communication"],
40
+ }
41
+
42
+ _PRODUCT_RULE_IDS_BY_TASK = {
43
+ "answer": [
44
+ "PC1", # Context before asking
45
+ "PC2", # Capability before delegating work to the user
46
+ "PC4", # Evidence before closure claims
47
+ "PC8", # Do not invent product capabilities
48
+ "PC16", # Continuity of identity and sessions
49
+ "PC19", # Product language, not internal jargon
50
+ "PC24", # Read what NEXO already wrote before acting
51
+ "PC25", # External state claims require live evidence
52
+ "PC28", # Check real capability before denying
53
+ "PC29", # Operational explanation stays simple
54
+ "PC32", # Reuse prior work before researching from zero
55
+ "MEMORY_AUTHORITY",
56
+ "IDENTITY_CONTINUITY",
57
+ "SAFE_AUTONOMY_FIRST",
58
+ "DEFERRED_TOOL_DISCOVERY",
59
+ ],
60
+ "analyze": [
61
+ "PC1",
62
+ "PC2",
63
+ "PC5",
64
+ "PC16",
65
+ "PC24",
66
+ "PC25",
67
+ "PC28",
68
+ "PC31",
69
+ "PC32",
70
+ "MEMORY_AUTHORITY",
71
+ "CORE_SYSTEM_AWARENESS",
72
+ "SAFE_AUTONOMY_FIRST",
73
+ ],
74
+ "edit": [
75
+ "PC3",
76
+ "PC4",
77
+ "PC5",
78
+ "PC18",
79
+ "PC24",
80
+ "PC25",
81
+ "PC30",
82
+ "PC31",
83
+ "PC32",
84
+ "RUNTIME_CORE_PROTECTED",
85
+ "MEMORY_AUTHORITY",
86
+ "SAFE_AUTONOMY_FIRST",
87
+ ],
88
+ "execute": [
89
+ "PC2",
90
+ "PC3",
91
+ "PC4",
92
+ "PC10",
93
+ "PC11",
94
+ "PC12",
95
+ "PC13",
96
+ "PC14",
97
+ "PC15",
98
+ "PC17",
99
+ "PC25",
100
+ "PC26",
101
+ "PC27",
102
+ "RUNTIME_CORE_PROTECTED",
103
+ "SAFE_AUTONOMY_FIRST",
104
+ ],
105
+ "delegate": [
106
+ "PC1",
107
+ "PC2",
108
+ "PC10",
109
+ "PC11",
110
+ "PC12",
111
+ "PC16",
112
+ "PC24",
113
+ "PC31",
114
+ "PC32",
115
+ "MEMORY_AUTHORITY",
116
+ "IDENTITY_CONTINUITY",
117
+ ],
118
+ }
119
+
120
+ _DEFAULT_PRODUCT_RULE_IDS = [
121
+ "PC1",
122
+ "PC2",
123
+ "PC4",
124
+ "PC24",
125
+ "PC25",
126
+ "PC28",
127
+ "PC32",
128
+ "MEMORY_AUTHORITY",
129
+ "SAFE_AUTONOMY_FIRST",
130
+ ]
131
+
132
+
133
+ def _sync_core_rules_if_available() -> None:
134
+ try:
135
+ from plugins.core_rules import _sync_if_needed
136
+ _sync_if_needed()
137
+ except Exception:
138
+ pass
139
+
140
+
141
+ def _rule_rows_for_ids(conn, ids: list[str]) -> list:
142
+ unique_ids = list(dict.fromkeys(ids))
143
+ if not unique_ids:
144
+ return []
145
+ placeholders = ",".join("?" * len(unique_ids))
146
+ rows = conn.execute(
147
+ f"""SELECT id, rule
148
+ FROM core_rules
149
+ WHERE id IN ({placeholders}) AND is_active = 1 AND type = 'blocking'""",
150
+ unique_ids,
151
+ ).fetchall()
152
+ by_id = {row["id"]: row for row in rows}
153
+ return [by_id[rule_id] for rule_id in unique_ids if rule_id in by_id]
154
+
155
+
156
+ def _classic_rule_rows_for_task(conn, task_type: str, excluded_ids: set[str], limit: int = 5) -> list:
157
+ categories = _CLASSIC_RULE_CATEGORIES_BY_TASK.get(task_type, ["integrity", "execution"])
158
+ placeholders = ",".join("?" * len(categories))
159
+ rows = conn.execute(
160
+ f"""SELECT id, rule
161
+ FROM core_rules
162
+ WHERE category IN ({placeholders})
163
+ AND is_active = 1
164
+ AND type = 'blocking'
165
+ ORDER BY importance DESC, category, id
166
+ LIMIT ?""",
167
+ [*categories, limit + len(excluded_ids)],
168
+ ).fetchall()
169
+ filtered = [row for row in rows if row["id"] not in excluded_ids]
170
+ return filtered[:limit]
171
+
172
+
34
173
  def _get_core_rules_for_task(task_type: str) -> list[str]:
35
174
  """Get relevant Core Rules for the given task type."""
36
- conn = _get_db()
37
175
  try:
38
- # Map task type to rule categories
39
- category_map = {
40
- "edit": ["integrity", "execution"],
41
- "execute": ["integrity", "execution", "delegation"],
42
- "delegate": ["delegation"],
43
- "analyze": ["execution", "memory"],
44
- "answer": ["communication"],
45
- }
46
- categories = category_map.get(task_type, ["integrity", "execution"])
47
- placeholders = ",".join("?" * len(categories))
48
-
49
- rows = conn.execute(
50
- f"SELECT id, rule FROM core_rules WHERE category IN ({placeholders}) AND is_active = 1 AND type = 'blocking' ORDER BY importance DESC LIMIT 5",
51
- categories
52
- ).fetchall()
176
+ _sync_core_rules_if_available()
177
+ conn = _get_db()
178
+ clean_type = str(task_type or "").strip().lower()
179
+ product_ids = _PRODUCT_RULE_IDS_BY_TASK.get(clean_type, _DEFAULT_PRODUCT_RULE_IDS)
180
+ product_rows = _rule_rows_for_ids(conn, product_ids)
181
+ classic_rows = _classic_rule_rows_for_task(
182
+ conn,
183
+ clean_type,
184
+ {row["id"] for row in product_rows},
185
+ )
186
+ rows = classic_rows + product_rows
53
187
  return [f"{r['id']}: {r['rule']}" for r in rows]
54
188
  except Exception:
55
189
  return []
@@ -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)
@@ -30,6 +30,22 @@ Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name
30
30
  - Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
31
31
  <!-- nexo:end:tools_at_startup -->
32
32
 
33
+ ## Core Rules Summary
34
+ The full protected registry lives in NEXO Brain `core_rules`. Keep this compact summary active from the first turn:
35
+
36
+ - Check existing context, memory, tickets, files, credentials, and prior work before asking the user.
37
+ - Do not ask the user to do work that NEXO can safely do with available tools.
38
+ - Prepare up to the safe boundary; ask only for real decisions, missing credentials, approvals, payments, destructive actions, or legally required consent.
39
+ - Verify current reality before claiming facts about external state, product capabilities, dates, versions, servers, routes, ports, schemas, or tickets.
40
+ - Do not invent or deny NEXO capabilities without checking the live product/source of truth first.
41
+ - Preserve one continuous user-facing identity across supported clients and sessions.
42
+ - Treat Brain, calibration, profile, decisions, learnings, diary, and followups as stronger authority than legacy client memory files.
43
+ - Keep product-managed `CORE` separate from tenant/operator-managed `USER`; updates may rewrite `CORE` but must preserve `USER`.
44
+ - Never leak personal or tenant-specific configuration into global product rules.
45
+ - Reuse recorded work, decisions, skills, and successful procedures before researching or building from zero.
46
+ - Review existing architecture before adding parallel queues, supervisors, recovery layers, or duplicate systems.
47
+ - Close work only with evidence, and keep actions, promises, followups, and ticket decisions in a single traceable ledger.
48
+
33
49
  ## Protocol (7 rules)
34
50
  1. `nexo_startup` once per session and keep the returned `SID`.
35
51
  2. `nexo_heartbeat` on every user message.
@@ -25,6 +25,22 @@ Codex (and Claude Code) may list `mcp__nexo__*` tools as **deferred** at session
25
25
  - If discovery still cannot resolve a `nexo_*` tool, then (and only then) treat it as a real runtime gap and surface it as a blocker.
26
26
  - Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
27
27
 
28
+ ## Core Rules Summary
29
+ The full protected registry lives in NEXO Brain `core_rules`. Keep this compact summary active from the first turn:
30
+
31
+ - Check existing context, memory, tickets, files, credentials, and prior work before asking the user.
32
+ - Do not ask the user to do work that NEXO can safely do with available tools.
33
+ - Prepare up to the safe boundary; ask only for real decisions, missing credentials, approvals, payments, destructive actions, or legally required consent.
34
+ - Verify current reality before claiming facts about external state, product capabilities, dates, versions, servers, routes, ports, schemas, or tickets.
35
+ - Do not invent or deny NEXO capabilities without checking the live product/source of truth first.
36
+ - Preserve one continuous user-facing identity across supported clients and sessions.
37
+ - Treat Brain, calibration, profile, decisions, learnings, diary, and followups as stronger authority than legacy client memory files.
38
+ - Keep product-managed `CORE` separate from tenant/operator-managed `USER`; updates may rewrite `CORE` but must preserve `USER`.
39
+ - Never leak personal or tenant-specific configuration into global product rules.
40
+ - Reuse recorded work, decisions, skills, and successful procedures before researching or building from zero.
41
+ - Review existing architecture before adding parallel queues, supervisors, recovery layers, or duplicate systems.
42
+ - Close work only with evidence, and keep actions, promises, followups, and ticket decisions in a single traceable ledger.
43
+
28
44
  ## Protocol (7 rules)
29
45
  1. `nexo_startup` once per session, then keep the returned `SID`.
30
46
  2. `nexo_heartbeat` on every user message.