nexo-brain 7.9.18 → 7.9.20

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.9.18",
3
+ "version": "7.9.20",
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.9.18` is the current packaged-runtime line. Patch release over `7.9.17`: packaged client-sync imports now work when `NEXO_HOME` is unset, so `nexo clients sync`, `nexo update`, and runtime doctor bootstrap checks no longer hit the `_user_home` import-order crash. It includes the v7.9.17 Bandit gate fix and the v7.9.16 restart-marker deadlock fix.
21
+ Version `7.9.20` is the current packaged-runtime line. Patch release over `7.9.19`: packaged update/doctor repair now finds `runtime/crons/sync.py`, LaunchAgent PATH includes the managed Claude runtime installed under `~/.nexo/runtime/bootstrap/npm-global/bin`, root runtime backfill includes `claude_cli.py`, and Immune no longer treats the legacy optional `~/.claude-mem/claude-mem.db` as a required database.
22
+
23
+ Previously in `7.9.19`: runtime doctor now distinguishes real install breakage from tracked in-progress work, interactive Desktop sessions no longer poison automation telemetry scoring, stale filesystem skill rows are pruned during sync, stale protocol debt draining marks rows resolved, and watchdog treats LaunchAgent SIGTERM reloads as supervisor interruptions instead of failures.
24
+
25
+ Previously in `7.9.18`: packaged client-sync imports now work when `NEXO_HOME` is unset, so `nexo clients sync`, `nexo update`, and runtime doctor bootstrap checks no longer hit the `_user_home` import-order crash.
22
26
 
23
27
  Previously in `7.9.17`: continuity snapshot idempotency marks its SHA-1 digest as non-security usage, keeping the high-severity Bandit gate green while preserving stable idempotency keys.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.18",
3
+ "version": "7.9.20",
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",
@@ -3816,7 +3816,7 @@ def _auto_update_check_locked() -> dict:
3816
3816
 
3817
3817
  # Backfill runtime CLI modules for existing installs
3818
3818
  try:
3819
- for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "agent_runner.py", "bootstrap_docs.py"):
3819
+ for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "claude_cli.py", "agent_runner.py", "bootstrap_docs.py"):
3820
3820
  src_file = SRC_DIR / fname
3821
3821
  dest_file = NEXO_HOME / fname
3822
3822
  if src_file.is_file() and (not dest_file.exists() or src_file.stat().st_mtime > dest_file.stat().st_mtime):
package/src/crons/sync.py CHANGED
@@ -39,8 +39,15 @@ except ImportError:
39
39
  def resolve_launchagent_path() -> str:
40
40
  """Fallback when runtime_power is not importable."""
41
41
  home = Path.home()
42
- parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
43
- str(home / ".local/bin"), str(home / ".nexo/bin")]
42
+ parts = [
43
+ str(home / ".nexo/runtime/bootstrap/npm-global/bin"),
44
+ "/opt/homebrew/bin",
45
+ "/usr/local/bin",
46
+ "/usr/bin",
47
+ "/bin",
48
+ str(home / ".local/bin"),
49
+ str(home / ".nexo/bin"),
50
+ ]
44
51
  nvm_dir = home / ".nvm/versions/node"
45
52
  if nvm_dir.is_dir():
46
53
  versions = sorted(nvm_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
package/src/db/_skills.py CHANGED
@@ -90,8 +90,16 @@ def _normalize_level(value: str | None) -> str:
90
90
 
91
91
  def _normalize_mode(value: str | None, *, has_script: bool = False, has_content: bool = False) -> str:
92
92
  mode = (value or "").strip().lower()
93
- if mode in VALID_MODES:
93
+ if mode == "guide":
94
94
  return mode
95
+ if mode == "execute":
96
+ return "execute" if has_script else "guide"
97
+ if mode == "hybrid":
98
+ if has_script and has_content:
99
+ return "hybrid"
100
+ if has_script:
101
+ return "execute"
102
+ return "guide"
95
103
  if has_script and has_content:
96
104
  return "hybrid"
97
105
  if has_script:
@@ -161,6 +169,14 @@ def _sync_dirs() -> list[tuple[str, Path]]:
161
169
  ]
162
170
 
163
171
 
172
+ def _is_relative_to(path: Path, parent: Path) -> bool:
173
+ try:
174
+ path.resolve(strict=False).relative_to(parent.resolve(strict=False))
175
+ return True
176
+ except (OSError, ValueError):
177
+ return False
178
+
179
+
164
180
  def _ensure_skill_dirs():
165
181
  PERSONAL_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
166
182
  RUNTIME_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
@@ -1379,7 +1395,50 @@ def sync_skill_directories() -> dict:
1379
1395
  for skill in discovered.values():
1380
1396
  synced.append(_upsert_filesystem_skill(skill)["id"])
1381
1397
 
1382
- return {"synced": len(synced), "ids": sorted(synced), "issues": issues}
1398
+ pruned: list[str] = []
1399
+ discovered_ids = set(discovered)
1400
+ conn = get_db()
1401
+ rows = conn.execute(
1402
+ """SELECT id, source_kind, definition_path, file_path
1403
+ FROM skills
1404
+ WHERE source_kind IN ('core', 'community', 'personal')
1405
+ AND definition_path != ''"""
1406
+ ).fetchall()
1407
+ sync_roots = [root.resolve(strict=False) for _source_kind, root in _sync_dirs()]
1408
+ sync_roots.extend(
1409
+ path.resolve(strict=False)
1410
+ for path in (
1411
+ NEXO_HOME / "core" / "skills",
1412
+ NEXO_HOME / "skills-core",
1413
+ NEXO_HOME / "personal" / "skills",
1414
+ NEXO_HOME / "skills",
1415
+ NEXO_HOME / "community" / "skills",
1416
+ )
1417
+ )
1418
+ for row in rows:
1419
+ skill_id = str(row["id"] or "")
1420
+ if not skill_id or skill_id in discovered_ids:
1421
+ continue
1422
+ definition_path = Path(str(row["definition_path"] or "")).expanduser()
1423
+ try:
1424
+ definition_resolved = definition_path.resolve(strict=False)
1425
+ except OSError:
1426
+ definition_resolved = definition_path
1427
+ if not any(_is_relative_to(definition_resolved, root) for root in sync_roots):
1428
+ continue
1429
+ if definition_path.is_file():
1430
+ continue
1431
+ runtime_file = Path(str(row["file_path"] or "")).expanduser()
1432
+ conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (skill_id,))
1433
+ conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
1434
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (skill_id,))
1435
+ if runtime_file.is_file() and _is_relative_to(runtime_file, RUNTIME_SKILLS_DIR):
1436
+ runtime_file.unlink(missing_ok=True)
1437
+ pruned.append(skill_id)
1438
+ if pruned:
1439
+ conn.commit()
1440
+
1441
+ return {"synced": len(synced), "ids": sorted(synced), "pruned": sorted(pruned), "issues": issues}
1383
1442
 
1384
1443
 
1385
1444
  def import_skill_from_directory(path: str, source_kind: str = "personal") -> dict:
@@ -1131,17 +1131,20 @@ def _repair_special_launchagent_plists(items: list[tuple[str, Path]]) -> tuple[b
1131
1131
 
1132
1132
 
1133
1133
  def _sync_launchagents_from_manifest() -> tuple[bool, list[str]]:
1134
- sync_path = NEXO_CODE / "crons" / "sync.py"
1134
+ sync_path = paths.crons_dir() / "sync.py"
1135
+ if not sync_path.is_file():
1136
+ sync_path = NEXO_CODE / "crons" / "sync.py"
1135
1137
  if not sync_path.is_file():
1136
1138
  return False, [f"cron sync script not found at {sync_path}"]
1137
1139
 
1138
1140
  try:
1141
+ runtime_code = paths.core_dir()
1139
1142
  result = subprocess.run(
1140
1143
  [sys.executable, str(sync_path)],
1141
1144
  capture_output=True,
1142
1145
  text=True,
1143
1146
  timeout=30,
1144
- env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
1147
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(runtime_code)},
1145
1148
  )
1146
1149
  except Exception as e:
1147
1150
  return False, [f"cron sync failed: {e}"]
@@ -2122,14 +2125,10 @@ def check_codex_session_parity() -> DoctorCheck:
2122
2125
  "Run `nexo update` or `nexo clients sync` so every Codex session inherits the managed bootstrap, not just a subset"
2123
2126
  )
2124
2127
  if missing_startup:
2125
- status = "degraded"
2126
- severity = "warn"
2127
2128
  repair_plan.append(
2128
2129
  "Use `nexo chat` or keep the global Codex bootstrap intact so every Codex session actually calls `nexo_startup`"
2129
2130
  )
2130
2131
  if missing_heartbeat:
2131
- status = "degraded"
2132
- severity = "warn"
2133
2132
  repair_plan.append("Keep `nexo_heartbeat` on every user turn so restored/plain Codex sessions do not drift off-protocol")
2134
2133
  if missing_bootstrap or missing_startup or missing_heartbeat:
2135
2134
  evidence.append(
@@ -2247,16 +2246,15 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2247
2246
  and audit["delete_without_protocol"] == 0
2248
2247
  and audit["delete_without_guard_ack"] == 0
2249
2248
  )
2250
- tracked_write_without_open_debt = (
2249
+ tracked_mutation_without_open_debt = (
2251
2250
  no_open_conditioned_debt
2252
2251
  and audit["write_without_protocol"] > 0
2253
2252
  and audit["write_without_guard_ack"] == 0
2254
- and audit["delete_without_protocol"] == 0
2255
2253
  and audit["delete_without_guard_ack"] == 0
2256
2254
  )
2257
2255
 
2258
2256
  if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
2259
- if tracked_write_without_open_debt:
2257
+ if tracked_mutation_without_open_debt:
2260
2258
  status = "healthy"
2261
2259
  severity = "info"
2262
2260
  else:
@@ -2280,8 +2278,8 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2280
2278
  summary=(
2281
2279
  "Historical Codex conditioned-file drift has no open protocol debt"
2282
2280
  if historical_read_only
2283
- else "Tracked Codex conditioned-file drift has no open protocol debt"
2284
- if tracked_write_without_open_debt
2281
+ else "Tracked Codex conditioned-file mutation drift has no open protocol debt"
2282
+ if tracked_mutation_without_open_debt
2285
2283
  else "Recent Codex sessions respect conditioned-file discipline"
2286
2284
  if status == "healthy"
2287
2285
  else "Recent Codex sessions are bypassing conditioned-file discipline"
@@ -2524,6 +2522,7 @@ def check_protocol_compliance() -> DoctorCheck:
2524
2522
  continue
2525
2523
  live_guard_task_ids.add(str(row["task_id"] or ""))
2526
2524
  live_guard_debt = 0
2525
+ open_task_debt = 0
2527
2526
  if {"task_id", "session_id"}.issubset(protocol_debt_cols):
2528
2527
  task_status_expr = "'' AS task_status"
2529
2528
  if "status" in protocol_task_cols:
@@ -2549,12 +2548,13 @@ def check_protocol_compliance() -> DoctorCheck:
2549
2548
  session_id = str(row["session_id"] or "")
2550
2549
  task_id = str(row["task_id"] or "")
2551
2550
  task_status = str(row["task_status"] or "")
2552
- if (
2553
- debt_type == "unacknowledged_guard_blocking"
2554
- and task_status == "open"
2555
- and session_id in active_session_ids
2556
- ):
2557
- live_guard_task_ids.add(task_id or f"debt:{session_id}:{row['created_at']}")
2551
+ if task_status == "open":
2552
+ open_task_debt += 1
2553
+ if (
2554
+ debt_type == "unacknowledged_guard_blocking"
2555
+ and session_id in active_session_ids
2556
+ ):
2557
+ live_guard_task_ids.add(task_id or f"debt:{session_id}:{row['created_at']}")
2558
2558
  continue
2559
2559
  debt_counter[(severity, debt_type)] = debt_counter.get((severity, debt_type), 0) + 1
2560
2560
  live_guard_debt = len(live_guard_task_ids)
@@ -2662,6 +2662,8 @@ def check_protocol_compliance() -> DoctorCheck:
2662
2662
  evidence.append("high-stakes decision-eval rollout not yet seeded in the live window")
2663
2663
  for row in debt_rows[:5]:
2664
2664
  evidence.append(f"open {row['severity']} debt — {row['debt_type']}: {row['total']}")
2665
+ if open_task_debt:
2666
+ evidence.append(f"in-progress task protocol debt pending: {open_task_debt}")
2665
2667
  if live_guard_debt:
2666
2668
  evidence.append(f"live in-progress guard acknowledgements pending: {live_guard_debt}")
2667
2669
 
@@ -3131,15 +3133,20 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3131
3133
  "created_at",
3132
3134
  }.issubset(columns)
3133
3135
  if schema_has_status:
3136
+ interactive_expr = "0"
3137
+ if "session_type" in columns:
3138
+ interactive_expr = "COALESCE(session_type, '') LIKE 'interactive%'"
3134
3139
  row = conn.execute(
3135
- """
3140
+ f"""
3136
3141
  SELECT
3137
3142
  COUNT(*) AS runs,
3138
3143
  SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS successful_runs,
3139
3144
  SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END) AS failed_runs,
3140
- SUM(CASE WHEN status = 'ok' AND (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
3141
- SUM(CASE WHEN status = 'ok' AND total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
3142
- SUM(CASE WHEN status = 'ok' AND cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
3145
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) THEN 1 ELSE 0 END) AS scored_successful_runs,
3146
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) AND (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
3147
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) AND total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
3148
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) AND cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
3149
+ SUM(CASE WHEN status = 'ok' AND ({interactive_expr}) AND ((input_tokens + cached_input_tokens + output_tokens) = 0 OR total_cost_usd IS NULL) THEN 1 ELSE 0 END) AS interactive_unmetered_runs,
3143
3150
  GROUP_CONCAT(DISTINCT backend) AS backends
3144
3151
  FROM automation_runs
3145
3152
  WHERE created_at >= datetime('now', ?)
@@ -3153,9 +3160,11 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3153
3160
  COUNT(*) AS runs,
3154
3161
  COUNT(*) AS successful_runs,
3155
3162
  0 AS failed_runs,
3163
+ COUNT(*) AS scored_successful_runs,
3156
3164
  SUM(CASE WHEN (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
3157
3165
  SUM(CASE WHEN total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
3158
3166
  SUM(CASE WHEN cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
3167
+ 0 AS interactive_unmetered_runs,
3159
3168
  GROUP_CONCAT(DISTINCT backend) AS backends
3160
3169
  FROM automation_runs
3161
3170
  WHERE created_at >= datetime('now', ?)
@@ -3191,11 +3200,13 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3191
3200
 
3192
3201
  successful_runs = int((row["successful_runs"] if row else 0) or 0)
3193
3202
  failed_runs = int((row["failed_runs"] if row else 0) or 0)
3203
+ scored_successful_runs = int((row["scored_successful_runs"] if row and "scored_successful_runs" in row.keys() else successful_runs) or 0)
3194
3204
  usage_runs = int((row["usage_runs"] if row else 0) or 0)
3195
3205
  cost_runs = int((row["cost_runs"] if row else 0) or 0)
3196
3206
  pricing_gaps = int((row["pricing_gaps"] if row else 0) or 0)
3197
- usage_denominator = successful_runs or total_runs
3198
- cost_denominator = successful_runs or total_runs
3207
+ interactive_unmetered_runs = int((row["interactive_unmetered_runs"] if row and "interactive_unmetered_runs" in row.keys() else 0) or 0)
3208
+ usage_denominator = scored_successful_runs or (successful_runs if not interactive_unmetered_runs else 0)
3209
+ cost_denominator = scored_successful_runs or (successful_runs if not interactive_unmetered_runs else 0)
3199
3210
  missing_usage_runs = max(0, usage_denominator - usage_runs) if usage_denominator else 0
3200
3211
  usage_coverage = round((usage_runs / usage_denominator) * 100, 1) if usage_denominator else 100.0
3201
3212
  cost_coverage = round((cost_runs / cost_denominator) * 100, 1) if cost_denominator else 100.0
@@ -3204,12 +3215,15 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3204
3215
  f"runs={total_runs}",
3205
3216
  f"successful_runs={successful_runs}",
3206
3217
  f"failed_runs={failed_runs}",
3218
+ f"scored_successful_runs={scored_successful_runs}",
3207
3219
  f"usage_coverage={usage_coverage}%",
3208
3220
  f"cost_coverage={cost_coverage}%",
3209
3221
  f"pricing_gaps={pricing_gaps}",
3210
3222
  ]
3211
3223
  if missing_usage_runs:
3212
3224
  evidence.append(f"missing_usage_runs={missing_usage_runs}")
3225
+ if interactive_unmetered_runs:
3226
+ evidence.append(f"interactive_unmetered_runs_excluded={interactive_unmetered_runs}")
3213
3227
  backends = str((row["backends"] if row else "") or "").strip()
3214
3228
  if backends:
3215
3229
  evidence.append(f"backends={backends}")
@@ -80,8 +80,15 @@ DEFAULT_CODEX_REASONING_EFFORT = _CODEX_DEFAULTS["reasoning_effort"]
80
80
  def resolve_launchagent_path() -> str:
81
81
  """Build a PATH string for LaunchAgent plists that includes nvm node if present."""
82
82
  home = Path.home()
83
- parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
84
- str(home / ".local/bin"), str(home / ".nexo/bin")]
83
+ parts = [
84
+ str(home / ".nexo/runtime/bootstrap/npm-global/bin"),
85
+ "/opt/homebrew/bin",
86
+ "/usr/local/bin",
87
+ "/usr/bin",
88
+ "/bin",
89
+ str(home / ".local/bin"),
90
+ str(home / ".nexo/bin"),
91
+ ]
85
92
  # Detect nvm node
86
93
  nvm_dir = home / ".nvm/versions/node"
87
94
  if nvm_dir.is_dir():
@@ -207,7 +207,7 @@ def run(
207
207
  report["drained_ids"].append(int(row["id"]))
208
208
  if not dry_run:
209
209
  conn.execute(
210
- "UPDATE protocol_debt SET resolved_at = ?, resolution = ? "
210
+ "UPDATE protocol_debt SET status = 'resolved', resolved_at = ?, resolution = ? "
211
211
  "WHERE id = ? AND resolved_at IS NULL",
212
212
  (
213
213
  now.strftime("%Y-%m-%d %H:%M:%S"),
@@ -367,8 +367,9 @@ def check_databases():
367
367
  dbs = [
368
368
  ("nexo.db", paths.db_path()),
369
369
  ("cognitive.db", paths.data_dir() / "cognitive.db"),
370
- ("claude-mem.db", CLAUDE_MEM_DB),
371
370
  ]
371
+ if CLAUDE_MEM_DB.exists():
372
+ dbs.append(("claude-mem.db", CLAUDE_MEM_DB))
372
373
 
373
374
  for name, path in dbs:
374
375
  result = {"name": name, "status": "OK", "detail": ""}
@@ -652,10 +652,14 @@ for monitor in "${MONITORS[@]}"; do
652
652
  fi
653
653
  else
654
654
  if [ -n "$last_exit" ] && [ "$last_exit" != "0" ]; then
655
- latest_run_failed=true
656
- status="FAIL"
657
- details="${details}Last run exited ${last_exit}. "
658
- [ -n "$last_error" ] && details="${details}Error: ${last_error}. "
655
+ if [ "$last_exit" = "143" ] && echo "$last_error" | grep -qi "SIGTERM"; then
656
+ details="${details}Last run ended by SIGTERM; treated as supervisor reload/interruption, not cron failure. "
657
+ else
658
+ latest_run_failed=true
659
+ status="FAIL"
660
+ details="${details}Last run exited ${last_exit}. "
661
+ [ -n "$last_error" ] && details="${details}Error: ${last_error}. "
662
+ fi
659
663
  fi
660
664
  if [ "$age" -gt $(( max_stale * 3 )) ]; then
661
665
  if [ "$recovery_policy" = "catchup" ]; then