nexo-brain 7.9.17 → 7.9.19

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.17",
3
+ "version": "7.9.19",
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.17` is the current packaged-runtime line. Patch release over `7.9.16`: continuity snapshot idempotency now marks its SHA-1 digest as non-security usage, keeping the high-severity Bandit gate green while preserving stable idempotency keys. It includes the v7.9.16 restart-marker deadlock fix.
21
+ Version `7.9.19` is the current packaged-runtime line. Patch release over `7.9.18`: 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. It includes the v7.9.18 client-sync bootstrap fix.
22
+
23
+ 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.
24
+
25
+ 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.
22
26
 
23
27
  Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.17",
3
+ "version": "7.9.19",
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",
@@ -19,6 +19,11 @@ from client_preferences import (
19
19
  )
20
20
  from runtime_home import resolve_nexo_home
21
21
 
22
+
23
+ def _user_home() -> Path:
24
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
25
+
26
+
22
27
  def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
23
28
  module_dir = Path(module_file).resolve().parent
24
29
  direct = module_dir / "templates"
@@ -66,10 +71,6 @@ BOOTSTRAP_SPECS = {
66
71
  }
67
72
 
68
73
 
69
- def _user_home() -> Path:
70
- return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
71
-
72
-
73
74
  def _default_nexo_home() -> Path:
74
75
  return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
75
76
 
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:
@@ -2122,14 +2122,10 @@ def check_codex_session_parity() -> DoctorCheck:
2122
2122
  "Run `nexo update` or `nexo clients sync` so every Codex session inherits the managed bootstrap, not just a subset"
2123
2123
  )
2124
2124
  if missing_startup:
2125
- status = "degraded"
2126
- severity = "warn"
2127
2125
  repair_plan.append(
2128
2126
  "Use `nexo chat` or keep the global Codex bootstrap intact so every Codex session actually calls `nexo_startup`"
2129
2127
  )
2130
2128
  if missing_heartbeat:
2131
- status = "degraded"
2132
- severity = "warn"
2133
2129
  repair_plan.append("Keep `nexo_heartbeat` on every user turn so restored/plain Codex sessions do not drift off-protocol")
2134
2130
  if missing_bootstrap or missing_startup or missing_heartbeat:
2135
2131
  evidence.append(
@@ -2247,16 +2243,15 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2247
2243
  and audit["delete_without_protocol"] == 0
2248
2244
  and audit["delete_without_guard_ack"] == 0
2249
2245
  )
2250
- tracked_write_without_open_debt = (
2246
+ tracked_mutation_without_open_debt = (
2251
2247
  no_open_conditioned_debt
2252
2248
  and audit["write_without_protocol"] > 0
2253
2249
  and audit["write_without_guard_ack"] == 0
2254
- and audit["delete_without_protocol"] == 0
2255
2250
  and audit["delete_without_guard_ack"] == 0
2256
2251
  )
2257
2252
 
2258
2253
  if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
2259
- if tracked_write_without_open_debt:
2254
+ if tracked_mutation_without_open_debt:
2260
2255
  status = "healthy"
2261
2256
  severity = "info"
2262
2257
  else:
@@ -2280,8 +2275,8 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2280
2275
  summary=(
2281
2276
  "Historical Codex conditioned-file drift has no open protocol debt"
2282
2277
  if historical_read_only
2283
- else "Tracked Codex conditioned-file drift has no open protocol debt"
2284
- if tracked_write_without_open_debt
2278
+ else "Tracked Codex conditioned-file mutation drift has no open protocol debt"
2279
+ if tracked_mutation_without_open_debt
2285
2280
  else "Recent Codex sessions respect conditioned-file discipline"
2286
2281
  if status == "healthy"
2287
2282
  else "Recent Codex sessions are bypassing conditioned-file discipline"
@@ -2524,6 +2519,7 @@ def check_protocol_compliance() -> DoctorCheck:
2524
2519
  continue
2525
2520
  live_guard_task_ids.add(str(row["task_id"] or ""))
2526
2521
  live_guard_debt = 0
2522
+ open_task_debt = 0
2527
2523
  if {"task_id", "session_id"}.issubset(protocol_debt_cols):
2528
2524
  task_status_expr = "'' AS task_status"
2529
2525
  if "status" in protocol_task_cols:
@@ -2549,12 +2545,13 @@ def check_protocol_compliance() -> DoctorCheck:
2549
2545
  session_id = str(row["session_id"] or "")
2550
2546
  task_id = str(row["task_id"] or "")
2551
2547
  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']}")
2548
+ if task_status == "open":
2549
+ open_task_debt += 1
2550
+ if (
2551
+ debt_type == "unacknowledged_guard_blocking"
2552
+ and session_id in active_session_ids
2553
+ ):
2554
+ live_guard_task_ids.add(task_id or f"debt:{session_id}:{row['created_at']}")
2558
2555
  continue
2559
2556
  debt_counter[(severity, debt_type)] = debt_counter.get((severity, debt_type), 0) + 1
2560
2557
  live_guard_debt = len(live_guard_task_ids)
@@ -2662,6 +2659,8 @@ def check_protocol_compliance() -> DoctorCheck:
2662
2659
  evidence.append("high-stakes decision-eval rollout not yet seeded in the live window")
2663
2660
  for row in debt_rows[:5]:
2664
2661
  evidence.append(f"open {row['severity']} debt — {row['debt_type']}: {row['total']}")
2662
+ if open_task_debt:
2663
+ evidence.append(f"in-progress task protocol debt pending: {open_task_debt}")
2665
2664
  if live_guard_debt:
2666
2665
  evidence.append(f"live in-progress guard acknowledgements pending: {live_guard_debt}")
2667
2666
 
@@ -3131,15 +3130,20 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3131
3130
  "created_at",
3132
3131
  }.issubset(columns)
3133
3132
  if schema_has_status:
3133
+ interactive_expr = "0"
3134
+ if "session_type" in columns:
3135
+ interactive_expr = "COALESCE(session_type, '') LIKE 'interactive%'"
3134
3136
  row = conn.execute(
3135
- """
3137
+ f"""
3136
3138
  SELECT
3137
3139
  COUNT(*) AS runs,
3138
3140
  SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS successful_runs,
3139
3141
  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,
3142
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) THEN 1 ELSE 0 END) AS scored_successful_runs,
3143
+ 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,
3144
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) AND total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
3145
+ SUM(CASE WHEN status = 'ok' AND NOT ({interactive_expr}) AND cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
3146
+ 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
3147
  GROUP_CONCAT(DISTINCT backend) AS backends
3144
3148
  FROM automation_runs
3145
3149
  WHERE created_at >= datetime('now', ?)
@@ -3153,9 +3157,11 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3153
3157
  COUNT(*) AS runs,
3154
3158
  COUNT(*) AS successful_runs,
3155
3159
  0 AS failed_runs,
3160
+ COUNT(*) AS scored_successful_runs,
3156
3161
  SUM(CASE WHEN (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
3157
3162
  SUM(CASE WHEN total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
3158
3163
  SUM(CASE WHEN cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
3164
+ 0 AS interactive_unmetered_runs,
3159
3165
  GROUP_CONCAT(DISTINCT backend) AS backends
3160
3166
  FROM automation_runs
3161
3167
  WHERE created_at >= datetime('now', ?)
@@ -3191,11 +3197,13 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3191
3197
 
3192
3198
  successful_runs = int((row["successful_runs"] if row else 0) or 0)
3193
3199
  failed_runs = int((row["failed_runs"] if row else 0) or 0)
3200
+ scored_successful_runs = int((row["scored_successful_runs"] if row and "scored_successful_runs" in row.keys() else successful_runs) or 0)
3194
3201
  usage_runs = int((row["usage_runs"] if row else 0) or 0)
3195
3202
  cost_runs = int((row["cost_runs"] if row else 0) or 0)
3196
3203
  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
3204
+ interactive_unmetered_runs = int((row["interactive_unmetered_runs"] if row and "interactive_unmetered_runs" in row.keys() else 0) or 0)
3205
+ usage_denominator = scored_successful_runs or (successful_runs if not interactive_unmetered_runs else 0)
3206
+ cost_denominator = scored_successful_runs or (successful_runs if not interactive_unmetered_runs else 0)
3199
3207
  missing_usage_runs = max(0, usage_denominator - usage_runs) if usage_denominator else 0
3200
3208
  usage_coverage = round((usage_runs / usage_denominator) * 100, 1) if usage_denominator else 100.0
3201
3209
  cost_coverage = round((cost_runs / cost_denominator) * 100, 1) if cost_denominator else 100.0
@@ -3204,12 +3212,15 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
3204
3212
  f"runs={total_runs}",
3205
3213
  f"successful_runs={successful_runs}",
3206
3214
  f"failed_runs={failed_runs}",
3215
+ f"scored_successful_runs={scored_successful_runs}",
3207
3216
  f"usage_coverage={usage_coverage}%",
3208
3217
  f"cost_coverage={cost_coverage}%",
3209
3218
  f"pricing_gaps={pricing_gaps}",
3210
3219
  ]
3211
3220
  if missing_usage_runs:
3212
3221
  evidence.append(f"missing_usage_runs={missing_usage_runs}")
3222
+ if interactive_unmetered_runs:
3223
+ evidence.append(f"interactive_unmetered_runs_excluded={interactive_unmetered_runs}")
3213
3224
  backends = str((row["backends"] if row else "") or "").strip()
3214
3225
  if backends:
3215
3226
  evidence.append(f"backends={backends}")
@@ -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"),
@@ -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