nexo-brain 7.23.0 → 7.23.2

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/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.23.0` is the current packaged-runtime line. Minor release over v7.22.0 - pre-answer routing now consults continuity evidence before visible replies, Memory Observations queue processing converges through a bounded processor, and audits expose saved-but-not-used stores, automation drift, MCP live/catalog gaps, artifact location and transcript coverage.
21
+ Version `7.23.1` is the current packaged-runtime line. Express patch over v7.23.0 - headless automations no longer hang on silent Claude children, synthetic followup prompts no longer trigger session-end loops, and runtime backups self-prune under a hard cap before creating new large artifacts.
22
+
23
+ Previously in `7.23.0`: minor release over v7.22.0 - pre-answer routing now consults continuity evidence before visible replies, Memory Observations queue processing converges through a bounded processor, and audits expose saved-but-not-used stores, automation drift, MCP live/catalog gaps, artifact location and transcript coverage.
22
24
 
23
25
  Previously in `7.22.0`: minor release over v7.21.0 - heartbeat stays fast in Desktop-managed sessions, MCP writes can be accepted through a durable file-backed queue before SQLite commit, Brain exposes compliance state for Desktop gates, and Local Context adds Entity Dossier for open-domain local evidence aggregation.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.0",
3
+ "version": "7.23.2",
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",
@@ -1052,9 +1052,10 @@ BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset({
1052
1052
  # Execution contracts keep background agents disciplined without polluting
1053
1053
  # machine-only child calls that must return strict JSON.
1054
1054
  AUTOMATION_CONTRACT_FULL_NEXO_AGENT = "full_nexo_agent"
1055
+ AUTOMATION_CONTRACT_SUPERVISED_CHILD = "supervised_child"
1055
1056
  AUTOMATION_CONTRACT_STRICT_CHILD = "strict_child_json"
1056
1057
  AUTOMATION_CONTRACT_PUBLIC_CHILD = "public_isolated_child"
1057
- AUTOMATION_CONTRACT_DEFAULT = AUTOMATION_CONTRACT_FULL_NEXO_AGENT
1058
+ AUTOMATION_CONTRACT_DEFAULT = AUTOMATION_CONTRACT_SUPERVISED_CHILD
1058
1059
 
1059
1060
  FULL_NEXO_AGENT_CALLERS: frozenset[str] = frozenset({
1060
1061
  "catchup/morning",
@@ -1087,17 +1088,30 @@ MACHINE_ONLY_LANGUAGE_CONTRACT_CALLERS: frozenset[str] = frozenset({
1087
1088
 
1088
1089
  def _automation_contract_for_caller(caller: str) -> str:
1089
1090
  clean = str(caller or "").strip()
1091
+ if clean in FULL_NEXO_AGENT_CALLERS:
1092
+ return AUTOMATION_CONTRACT_FULL_NEXO_AGENT
1090
1093
  if clean in STRICT_CHILD_CALLERS:
1091
1094
  return AUTOMATION_CONTRACT_STRICT_CHILD
1092
1095
  if clean in PUBLIC_CHILD_CALLERS:
1093
1096
  return AUTOMATION_CONTRACT_PUBLIC_CHILD
1094
- return AUTOMATION_CONTRACT_FULL_NEXO_AGENT
1097
+ return AUTOMATION_CONTRACT_DEFAULT
1095
1098
 
1096
1099
 
1097
1100
  def _caller_uses_global_discipline(caller: str) -> bool:
1098
1101
  return _automation_contract_for_caller(caller) == AUTOMATION_CONTRACT_FULL_NEXO_AGENT
1099
1102
 
1100
1103
 
1104
+ def _build_supervised_child_system_prompt() -> str:
1105
+ return (
1106
+ "You are running as a supervised NEXO automation child. "
1107
+ "Return the requested result for this job only. Do not open or close "
1108
+ "NEXO tasks, reminders, diary entries, sessions, or followups from "
1109
+ "inside this child process; the parent NEXO runner owns lifecycle, "
1110
+ "timeouts, evidence, retries, and durable state. If the requested "
1111
+ "work cannot be completed safely, return a concise failure reason."
1112
+ )
1113
+
1114
+
1101
1115
  def _should_apply_operator_language_contract(caller: str) -> bool:
1102
1116
  clean = str(caller or "").strip()
1103
1117
  if not clean:
@@ -1191,6 +1205,12 @@ def run_automation_prompt(
1191
1205
  append_system_prompt = append_system_prompt + "\n\n" + enforcement_fragment
1192
1206
  else:
1193
1207
  append_system_prompt = enforcement_fragment
1208
+ elif automation_contract == AUTOMATION_CONTRACT_SUPERVISED_CHILD:
1209
+ supervised_fragment = _build_supervised_child_system_prompt()
1210
+ if append_system_prompt:
1211
+ append_system_prompt = append_system_prompt + "\n\n" + supervised_fragment
1212
+ else:
1213
+ append_system_prompt = supervised_fragment
1194
1214
 
1195
1215
  prompt = _apply_operator_language_contract(prompt, caller=caller)
1196
1216
 
@@ -108,6 +108,69 @@ def _backup_validation_tables(db_file: Path) -> tuple[str, ...]:
108
108
  return PROTECTED_BACKUP_TABLES
109
109
 
110
110
 
111
+ def _env_int(name: str, default: int) -> int:
112
+ try:
113
+ return int(os.environ.get(name, str(default)))
114
+ except (TypeError, ValueError):
115
+ return default
116
+
117
+
118
+ BACKUP_MAX_BYTES = _env_int("NEXO_BACKUP_MAX_BYTES", 50 * 1024 * 1024 * 1024)
119
+ BACKUP_MIN_FREE_BYTES = _env_int("NEXO_BACKUP_MIN_FREE_BYTES", 5 * 1024 * 1024 * 1024)
120
+ LOCAL_CONTEXT_MAX_BACKUP_BYTES = _env_int("NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES", 2 * 1024 * 1024 * 1024)
121
+ _LAST_BACKUP_ERROR = ""
122
+
123
+
124
+ def _run_runtime_backup_prune() -> None:
125
+ script = SRC_DIR / "scripts" / "prune_runtime_backups.py"
126
+ if not script.is_file():
127
+ return
128
+ try:
129
+ subprocess.run(
130
+ [
131
+ sys.executable,
132
+ str(script),
133
+ "--root",
134
+ str(paths.backups_dir()),
135
+ "--apply",
136
+ "--max-bytes",
137
+ str(BACKUP_MAX_BYTES),
138
+ ],
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=120,
142
+ )
143
+ except Exception as e:
144
+ _log(f"Backup self-clean warning: {e}")
145
+
146
+
147
+ def _backup_free_bytes() -> int | None:
148
+ backup_root = paths.backups_dir()
149
+ try:
150
+ usage = shutil.disk_usage(backup_root if backup_root.exists() else backup_root.parent)
151
+ return int(usage.free)
152
+ except Exception:
153
+ return None
154
+
155
+
156
+ def _backup_space_error() -> str | None:
157
+ _run_runtime_backup_prune()
158
+ free = _backup_free_bytes()
159
+ if free is not None and free < BACKUP_MIN_FREE_BYTES:
160
+ return (
161
+ "free disk below NEXO backup safety floor after automatic cleanup "
162
+ f"({free}B < {BACKUP_MIN_FREE_BYTES}B)"
163
+ )
164
+ return None
165
+
166
+
167
+ def _should_include_local_context_backup(path: Path) -> bool:
168
+ try:
169
+ return path.stat().st_size <= LOCAL_CONTEXT_MAX_BACKUP_BYTES
170
+ except OSError:
171
+ return False
172
+
173
+
111
174
  CLASSIFIER_INSTALL_TIMEOUT_SECONDS = 1800
112
175
  CLASSIFIER_INSTALL_JOIN_SECONDS = 1500
113
176
  CLASSIFIER_INSTALL_LOG = paths.logs_dir() / "classifier-install.log"
@@ -380,6 +443,17 @@ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
380
443
  """Create a DB backup and validate that critical tables still contain data."""
381
444
  backup_dir = _backup_dbs()
382
445
  if not backup_dir:
446
+ if _LAST_BACKUP_ERROR:
447
+ return None, {
448
+ "ok": False,
449
+ "reports": [{
450
+ "ok": False,
451
+ "source_db": "",
452
+ "backup_db": "",
453
+ "errors": [_LAST_BACKUP_ERROR],
454
+ "regressions": [],
455
+ }],
456
+ }
383
457
  return None, None
384
458
 
385
459
  source_dbs: list[Path] = []
@@ -387,7 +461,7 @@ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
387
461
  if primary_db is not None:
388
462
  source_dbs.append(primary_db)
389
463
  local_context_db = paths.memory_dir() / "local-context.db"
390
- if local_context_db.is_file():
464
+ if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
391
465
  source_dbs.append(local_context_db)
392
466
  if not source_dbs:
393
467
  return backup_dir, None
@@ -2061,6 +2135,8 @@ def _backup_dbs() -> str | None:
2061
2135
  """Snapshot all .db files before migration. Returns backup dir or None."""
2062
2136
  import sqlite3
2063
2137
  import time as _time
2138
+ global _LAST_BACKUP_ERROR
2139
+ _LAST_BACKUP_ERROR = ""
2064
2140
  # Drop 0-byte .db orphans first — they mask the real DB during primary
2065
2141
  # path selection and turn into empty shells in the backup, breaking both
2066
2142
  # validation and rollback paths. Safe no-op when there are none.
@@ -2070,7 +2146,7 @@ def _backup_dbs() -> str | None:
2070
2146
 
2071
2147
  db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
2072
2148
  local_context_db = paths.memory_dir() / "local-context.db"
2073
- if local_context_db.is_file():
2149
+ if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
2074
2150
  db_files.append(local_context_db)
2075
2151
  db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
2076
2152
  src_db = SRC_DIR / "nexo.db"
@@ -2080,6 +2156,12 @@ def _backup_dbs() -> str | None:
2080
2156
  if not db_files:
2081
2157
  return None
2082
2158
 
2159
+ space_err = _backup_space_error()
2160
+ if space_err:
2161
+ _LAST_BACKUP_ERROR = space_err
2162
+ _log(f"DB backup aborted: {space_err}")
2163
+ return None
2164
+
2083
2165
  backup_dir.mkdir(parents=True, exist_ok=True)
2084
2166
  for db_file in db_files:
2085
2167
  src_conn = None
package/src/cli.py CHANGED
@@ -413,13 +413,29 @@ def _version_sort_key(raw: str) -> tuple[tuple[int, ...], int, str]:
413
413
  return (tuple(parts), 1 if not suffix else 0, suffix)
414
414
 
415
415
 
416
- def _version_status_payload() -> dict:
416
+ def _version_status_payload(*, force_refresh: bool = False) -> dict:
417
417
  installed = _get_version()
418
418
  latest = _load_latest_version_cache()
419
419
  latest_source = "cache" if latest else ""
420
- if latest is None and _should_refresh_latest_version():
421
- latest = _fetch_latest_version()
422
- latest_source = "npm" if latest else ""
420
+ should_refresh = False
421
+ if force_refresh and _should_refresh_latest_version():
422
+ # Desktop reads `nexo --version --json` to decide whether to show the
423
+ # Brain update CTA. If a user checked minutes before a release, the
424
+ # 6-hour cache can otherwise hide the new npm dist-tag until it expires.
425
+ should_refresh = (
426
+ latest is None
427
+ or (
428
+ bool(installed)
429
+ and _version_sort_key(latest) <= _version_sort_key(installed)
430
+ )
431
+ )
432
+ elif latest is None and _should_refresh_latest_version():
433
+ should_refresh = True
434
+ if should_refresh:
435
+ refreshed = _fetch_latest_version()
436
+ if refreshed:
437
+ latest = refreshed
438
+ latest_source = "npm"
423
439
  if latest and installed and _version_sort_key(latest) < _version_sort_key(installed):
424
440
  latest = installed
425
441
  latest_source = "installed"
@@ -4030,7 +4046,7 @@ def main():
4030
4046
  _print_help()
4031
4047
  return 0
4032
4048
  if args.version:
4033
- payload = _version_status_payload()
4049
+ payload = _version_status_payload(force_refresh=bool(args.json))
4034
4050
  if args.json:
4035
4051
  print(json.dumps(payload, ensure_ascii=False))
4036
4052
  else:
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import queue
13
14
  import subprocess
14
15
  import threading
15
16
  import time
@@ -2816,7 +2817,10 @@ def run_with_enforcement(
2816
2817
  # headless runtime. Without this, correction detection and project-
2817
2818
  # context rules only run when tests invoke on_user_message directly.
2818
2819
  try:
2819
- enforcer.on_user_message(prompt or "")
2820
+ enforcer.on_user_message(
2821
+ prompt or "",
2822
+ session_end_detector=lambda _text: False,
2823
+ )
2820
2824
  except Exception as _init_exc: # noqa: BLE001 — fail-closed
2821
2825
  _logger.warning("on_user_message (initial prompt) failed: %s", _init_exc)
2822
2826
 
@@ -2831,6 +2835,16 @@ def run_with_enforcement(
2831
2835
  stderr_lines = []
2832
2836
  start_time = time.time()
2833
2837
  waiting_for_injection_response = False
2838
+ timed_out = False
2839
+ stdout_closed = False
2840
+ stdout_lines: queue.Queue[str | None] = queue.Queue()
2841
+
2842
+ def _kill_proc(reason: str) -> None:
2843
+ _logger.warning("%s after %ds", reason, timeout)
2844
+ try:
2845
+ proc.kill()
2846
+ except Exception:
2847
+ pass
2834
2848
 
2835
2849
  def _inject(text: str):
2836
2850
  nonlocal waiting_for_injection_response
@@ -2847,6 +2861,15 @@ def run_with_enforcement(
2847
2861
  except Exception as e:
2848
2862
  _logger.error("INJECT_FAILED: %s", e)
2849
2863
 
2864
+ def _read_stdout():
2865
+ try:
2866
+ for line in proc.stdout:
2867
+ stdout_lines.put(line)
2868
+ except Exception:
2869
+ pass
2870
+ finally:
2871
+ stdout_lines.put(None)
2872
+
2850
2873
  def _read_stderr():
2851
2874
  try:
2852
2875
  for line in proc.stderr:
@@ -2854,92 +2877,125 @@ def run_with_enforcement(
2854
2877
  except Exception:
2855
2878
  pass
2856
2879
 
2880
+ stdout_thread = threading.Thread(target=_read_stdout, daemon=True)
2857
2881
  stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
2882
+ stdout_thread.start()
2858
2883
  stderr_thread.start()
2859
2884
 
2860
2885
  last_periodic_check = time.time()
2861
2886
 
2862
- try:
2863
- for raw_line in proc.stdout:
2864
- line = raw_line.strip()
2865
- if not line:
2866
- continue
2867
-
2868
- if time.time() - start_time > timeout:
2869
- _logger.warning("TIMEOUT after %ds", timeout)
2870
- proc.kill()
2871
- break
2887
+ def _handle_stdout_line(raw_line: str) -> bool:
2888
+ """Process one Claude stream-json line.
2872
2889
 
2873
- try:
2874
- event = json.loads(line)
2875
- except json.JSONDecodeError:
2876
- continue
2890
+ Return True when the stream turn is complete and the caller should
2891
+ leave the main read loop.
2892
+ """
2893
+ nonlocal waiting_for_injection_response
2894
+ line = raw_line.strip()
2895
+ if not line:
2896
+ return False
2877
2897
 
2878
- event_type = event.get("type", "")
2879
-
2880
- if event_type == "assistant" and event.get("message", {}).get("content"):
2881
- for block in event["message"]["content"]:
2882
- if block.get("type") == "tool_use":
2883
- # v7.7 Gap 7.3 — wire before_tool in the live
2884
- # stream. Desktop already calls onBeforeToolCall
2885
- # before onToolCall; Brain's stream was only
2886
- # calling on_tool_call, silently skipping every
2887
- # before_tool rule the map declared.
2888
- enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
2889
- enforcer.on_tool_call(block.get("name", ""), block.get("input"))
2890
- elif event_type == "content_block_start":
2891
- cb = event.get("content_block", {})
2892
- if cb.get("type") == "tool_use":
2893
- enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
2894
- enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
2895
-
2896
- if event_type == "assistant" and not waiting_for_injection_response:
2897
- msg = event.get("message", {})
2898
- for block in msg.get("content", []):
2899
- if block.get("type") == "text":
2900
- collected_text.append(block["text"])
2901
- # R16 — probe each assistant text block as it arrives
2902
- # so a declared-done line is caught on the same turn
2903
- # rather than only at session end.
2904
- try:
2905
- enforcer.on_assistant_text(block["text"])
2906
- except Exception as _r16_exc: # noqa: BLE001
2907
- _logger.warning("on_assistant_text failed: %s", _r16_exc)
2908
- try:
2909
- enforcer.on_assistant_text_r17(block["text"])
2910
- except Exception as _r17_exc: # noqa: BLE001
2911
- _logger.warning("on_assistant_text_r17 failed: %s", _r17_exc)
2912
-
2913
- if event_type == "result":
2914
- if waiting_for_injection_response:
2915
- waiting_for_injection_response = False
2916
- _logger.info("INJECTION_RESPONSE received")
2917
- item = enforcer.flush()
2918
- if item:
2919
- _inject(item["prompt"])
2920
- continue
2898
+ try:
2899
+ event = json.loads(line)
2900
+ except json.JSONDecodeError:
2901
+ return False
2921
2902
 
2922
- enforcer.check_periodic()
2903
+ event_type = event.get("type", "")
2904
+
2905
+ if event_type == "assistant" and event.get("message", {}).get("content"):
2906
+ for block in event["message"]["content"]:
2907
+ if block.get("type") == "tool_use":
2908
+ # v7.7 Gap 7.3 — wire before_tool in the live
2909
+ # stream. Desktop already calls onBeforeToolCall
2910
+ # before onToolCall; Brain's stream was only
2911
+ # calling on_tool_call, silently skipping every
2912
+ # before_tool rule the map declared.
2913
+ enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
2914
+ enforcer.on_tool_call(block.get("name", ""), block.get("input"))
2915
+ elif event_type == "content_block_start":
2916
+ cb = event.get("content_block", {})
2917
+ if cb.get("type") == "tool_use":
2918
+ enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
2919
+ enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
2920
+
2921
+ if event_type == "assistant" and not waiting_for_injection_response:
2922
+ msg = event.get("message", {})
2923
+ for block in msg.get("content", []):
2924
+ if block.get("type") == "text":
2925
+ collected_text.append(block["text"])
2926
+ # R16 — probe each assistant text block as it arrives
2927
+ # so a declared-done line is caught on the same turn
2928
+ # rather than only at session end.
2929
+ try:
2930
+ enforcer.on_assistant_text(block["text"])
2931
+ except Exception as _r16_exc: # noqa: BLE001
2932
+ _logger.warning("on_assistant_text failed: %s", _r16_exc)
2933
+ try:
2934
+ enforcer.on_assistant_text_r17(block["text"])
2935
+ except Exception as _r17_exc: # noqa: BLE001
2936
+ _logger.warning("on_assistant_text_r17 failed: %s", _r17_exc)
2937
+
2938
+ if event_type == "result":
2939
+ if waiting_for_injection_response:
2940
+ waiting_for_injection_response = False
2941
+ _logger.info("INJECTION_RESPONSE received")
2923
2942
  item = enforcer.flush()
2924
2943
  if item:
2925
2944
  _inject(item["prompt"])
2926
- else:
2927
- _logger.info("TURN_END — no pending enforcements, done")
2945
+ return False
2946
+
2947
+ enforcer.check_periodic()
2948
+ item = enforcer.flush()
2949
+ if item:
2950
+ _inject(item["prompt"])
2951
+ else:
2952
+ _logger.info("TURN_END — no pending enforcements, done")
2953
+ return True
2954
+
2955
+ return False
2956
+
2957
+ try:
2958
+ while True:
2959
+ if time.time() - start_time > timeout:
2960
+ timed_out = True
2961
+ _kill_proc("TIMEOUT")
2962
+ break
2963
+
2964
+ try:
2965
+ raw_line = stdout_lines.get(timeout=0.2)
2966
+ except queue.Empty:
2967
+ if stdout_closed and proc.poll() is not None:
2928
2968
  break
2969
+ if time.time() - last_periodic_check > 30:
2970
+ enforcer.check_periodic()
2971
+ last_periodic_check = time.time()
2972
+ continue
2929
2973
 
2930
- if time.time() - last_periodic_check > 30:
2931
- enforcer.check_periodic()
2932
- last_periodic_check = time.time()
2974
+ if raw_line is None:
2975
+ stdout_closed = True
2976
+ break
2977
+
2978
+ if _handle_stdout_line(raw_line):
2979
+ break
2933
2980
 
2934
2981
  except Exception as e:
2935
2982
  _logger.error("EXCEPTION: %s", e)
2936
2983
  finally:
2937
- end_prompts = enforcer.get_end_prompts()
2984
+ end_prompts = [] if timed_out else enforcer.get_end_prompts()
2938
2985
  for ep in end_prompts:
2939
2986
  try:
2940
2987
  _inject(ep)
2941
2988
  deadline = time.time() + 15
2942
- for raw_line in proc.stdout:
2989
+ while time.time() <= deadline:
2990
+ try:
2991
+ raw_line = stdout_lines.get(timeout=0.2)
2992
+ except queue.Empty:
2993
+ if proc.poll() is not None:
2994
+ break
2995
+ continue
2996
+ if raw_line is None:
2997
+ stdout_closed = True
2998
+ break
2943
2999
  if time.time() > deadline:
2944
3000
  _logger.warning("END_PROMPT timeout")
2945
3001
  break
@@ -2968,10 +3024,18 @@ def run_with_enforcement(
2968
3024
  except subprocess.TimeoutExpired:
2969
3025
  proc.kill()
2970
3026
 
3027
+ stdout_thread.join(timeout=2)
2971
3028
  stderr_thread.join(timeout=2)
2972
3029
  final_text = "\n".join(collected_text)
2973
3030
  final_stderr = "".join(stderr_lines)
3031
+ returncode = proc.returncode
3032
+ if timed_out:
3033
+ returncode = 124
3034
+ timeout_msg = f"NEXO enforcement timeout after {timeout}s"
3035
+ final_stderr = f"{final_stderr.rstrip()}\n{timeout_msg}".lstrip()
3036
+ elif returncode is None:
3037
+ returncode = 0
2974
3038
 
2975
3039
  return subprocess.CompletedProcess(
2976
- stream_cmd, proc.returncode or 0, final_text, final_stderr
3040
+ stream_cmd, returncode, final_text, final_stderr
2977
3041
  )
@@ -147,6 +147,18 @@ DATA_DIR = paths.data_dir()
147
147
  BACKUP_BASE = paths.backups_dir()
148
148
  TECHNICAL_BACKUP_KEEP = 5
149
149
 
150
+
151
+ def _env_int(name: str, default: int) -> int:
152
+ try:
153
+ return int(os.environ.get(name, str(default)))
154
+ except (TypeError, ValueError):
155
+ return default
156
+
157
+
158
+ BACKUP_MAX_BYTES = _env_int("NEXO_BACKUP_MAX_BYTES", 50 * 1024 * 1024 * 1024)
159
+ BACKUP_MIN_FREE_BYTES = _env_int("NEXO_BACKUP_MIN_FREE_BYTES", 5 * 1024 * 1024 * 1024)
160
+ LOCAL_CONTEXT_MAX_BACKUP_BYTES = _env_int("NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES", 2 * 1024 * 1024 * 1024)
161
+
150
162
  # In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
151
163
  _PACKAGED_INSTALL = _is_packaged_install()
152
164
  REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
@@ -186,6 +198,55 @@ def _rotate_backup_family(prefix: str, keep: int = TECHNICAL_BACKUP_KEEP) -> int
186
198
  return removed
187
199
 
188
200
 
201
+ def _run_runtime_backup_prune() -> None:
202
+ script = SRC_DIR / "scripts" / "prune_runtime_backups.py"
203
+ if not script.is_file():
204
+ return
205
+ try:
206
+ subprocess.run(
207
+ [
208
+ sys.executable,
209
+ str(script),
210
+ "--root",
211
+ str(BACKUP_BASE),
212
+ "--apply",
213
+ "--max-bytes",
214
+ str(BACKUP_MAX_BYTES),
215
+ ],
216
+ capture_output=True,
217
+ text=True,
218
+ timeout=120,
219
+ )
220
+ except Exception:
221
+ pass
222
+
223
+
224
+ def _backup_free_bytes() -> int | None:
225
+ try:
226
+ usage = shutil.disk_usage(BACKUP_BASE if BACKUP_BASE.exists() else BACKUP_BASE.parent)
227
+ return int(usage.free)
228
+ except Exception:
229
+ return None
230
+
231
+
232
+ def _backup_space_error() -> str | None:
233
+ _run_runtime_backup_prune()
234
+ free = _backup_free_bytes()
235
+ if free is not None and free < BACKUP_MIN_FREE_BYTES:
236
+ return (
237
+ "free disk below NEXO backup safety floor after automatic cleanup "
238
+ f"({free}B < {BACKUP_MIN_FREE_BYTES}B)"
239
+ )
240
+ return None
241
+
242
+
243
+ def _should_include_local_context_backup(path: Path) -> bool:
244
+ try:
245
+ return path.stat().st_size <= LOCAL_CONTEXT_MAX_BACKUP_BYTES
246
+ except OSError:
247
+ return False
248
+
249
+
189
250
  def _venv_python_path(runtime_root: Path | None = None) -> Path:
190
251
  root = runtime_root or _nexo_home()
191
252
  if sys.platform == "win32":
@@ -522,7 +583,7 @@ def _backup_databases() -> tuple[str, str | None]:
522
583
 
523
584
  db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
524
585
  local_context_db = paths.memory_dir() / "local-context.db"
525
- if local_context_db.is_file():
586
+ if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
526
587
  db_files.append(local_context_db)
527
588
  # Also check NEXO_HOME root for legacy db location
528
589
  db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
@@ -534,6 +595,10 @@ def _backup_databases() -> tuple[str, str | None]:
534
595
  if not db_files:
535
596
  return str(backup_dir), None # No DBs to backup, not an error
536
597
 
598
+ space_err = _backup_space_error()
599
+ if space_err:
600
+ return str(backup_dir), space_err
601
+
537
602
  backup_dir.mkdir(parents=True, exist_ok=True)
538
603
 
539
604
  for db_file in db_files:
@@ -17,10 +17,21 @@ LOCAL_CONTEXT_RETENTION_HOURS="${NEXO_LOCAL_CONTEXT_BACKUP_RETENTION_HOURS:-24}"
17
17
  LOCAL_CONTEXT_KEEP_LAST="${NEXO_LOCAL_CONTEXT_BACKUP_KEEP_LAST:-2}"
18
18
  BUSY_TIMEOUT_MS="${NEXO_BACKUP_BUSY_TIMEOUT_MS:-5000}"
19
19
  RECENT_BACKUP_HOURS="${NEXO_BACKUP_RECENT_BACKUP_HOURS:-6}"
20
+ BACKUP_MAX_BYTES="${NEXO_BACKUP_MAX_BYTES:-53687091200}"
21
+ MIN_FREE_BYTES="${NEXO_BACKUP_MIN_FREE_BYTES:-5368709120}"
22
+ LOCAL_CONTEXT_MAX_BACKUP_BYTES="${NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES:-2147483648}"
20
23
 
21
24
  mkdir -p "$BACKUP_DIR" "$WEEKLY_DIR"
22
25
 
23
26
  cleanup_backups() {
27
+ PRUNER="$NEXO_HOME/core/scripts/prune_runtime_backups.py"
28
+ if [ ! -f "$PRUNER" ]; then
29
+ PRUNER="$(dirname "$0")/prune_runtime_backups.py"
30
+ fi
31
+ if [ -f "$PRUNER" ]; then
32
+ python3 "$PRUNER" --root "$BACKUP_DIR" --apply --max-bytes "$BACKUP_MAX_BYTES" >/dev/null 2>&1 || true
33
+ fi
34
+
24
35
  python3 - "$BACKUP_DIR" "$RETENTION_HOURS" "$KEEP_LAST" "$FAMILY_KEEP_LAST" "$LOCAL_CONTEXT_RETENTION_HOURS" "$LOCAL_CONTEXT_KEEP_LAST" <<'PY'
25
36
  from __future__ import annotations
26
37
 
@@ -110,7 +121,30 @@ has_recent_local_context_backup() {
110
121
  find "$BACKUP_DIR" -maxdepth 1 -name "local-context-*.db" -mmin "-$((RECENT_BACKUP_HOURS * 60))" -print -quit | grep -q .
111
122
  }
112
123
 
113
- cleanup_backups
124
+ available_backup_bytes() {
125
+ df -Pk "$BACKUP_DIR" 2>/dev/null | awk 'NR==2 { printf "%.0f\n", $4 * 1024 }'
126
+ }
127
+
128
+ file_size_bytes() {
129
+ wc -c < "$1" 2>/dev/null | tr -d ' '
130
+ }
131
+
132
+ ensure_backup_space() {
133
+ cleanup_backups
134
+ avail="$(available_backup_bytes)"
135
+ if [ -n "$avail" ] && [ "$avail" -lt "$MIN_FREE_BYTES" ]; then
136
+ echo "NEXO backup skipped: free disk below safety floor after self-cleanup (${avail}B < ${MIN_FREE_BYTES}B)" >&2
137
+ return 1
138
+ fi
139
+ return 0
140
+ }
141
+
142
+ if ! ensure_backup_space; then
143
+ if has_recent_backup; then
144
+ exit 0
145
+ fi
146
+ exit 1
147
+ fi
114
148
 
115
149
  # Hourly backup
116
150
  TIMESTAMP=$(date +%Y-%m-%d-%H%M)
@@ -148,7 +182,12 @@ if [ -f "$LOCAL_CONTEXT_DB" ]; then
148
182
  LOCAL_CONTEXT_BACKUP_FILE="$BACKUP_DIR/local-context-$TIMESTAMP.db"
149
183
  LOCAL_CONTEXT_TMP_BACKUP="$LOCAL_CONTEXT_BACKUP_FILE.tmp.$$"
150
184
  rm -f "$LOCAL_CONTEXT_TMP_BACKUP"
151
- if [ -f "$LOCK_FILE" ] && find "$LOCK_FILE" -mmin -30 -print -quit | grep -q . && has_recent_local_context_backup; then
185
+ LOCAL_CONTEXT_SIZE="$(file_size_bytes "$LOCAL_CONTEXT_DB")"
186
+ if [ -n "$LOCAL_CONTEXT_SIZE" ] && [ "$LOCAL_CONTEXT_SIZE" -gt "$LOCAL_CONTEXT_MAX_BACKUP_BYTES" ]; then
187
+ echo "NEXO local memory backup skipped: local-context.db exceeds automatic backup cap (${LOCAL_CONTEXT_SIZE}B > ${LOCAL_CONTEXT_MAX_BACKUP_BYTES}B)"
188
+ elif ! ensure_backup_space; then
189
+ echo "NEXO local memory backup skipped: free disk below safety floor"
190
+ elif [ -f "$LOCK_FILE" ] && find "$LOCK_FILE" -mmin -30 -print -quit | grep -q . && has_recent_local_context_backup; then
152
191
  echo "NEXO local memory backup skipped: index is active and a recent local backup exists"
153
192
  elif sqlite3 -cmd ".timeout $BUSY_TIMEOUT_MS" "$LOCAL_CONTEXT_DB" <<SQL
154
193
  PRAGMA busy_timeout=$BUSY_TIMEOUT_MS;
@@ -22,7 +22,7 @@ Class taxonomy (prefix-based) and retention policy:
22
22
  Prefixes:
23
23
  pre-update-*, pre-autoupdate-*, pre-backfill-owner-*,
24
24
  pre-runtime-sync-*, pre-sleep-wrapper-*, pre-obs-clean-*,
25
- pre-import-user-data-*, pre-backfill-*,
25
+ pre-import-user-data-*, pre-backfill-*, pre-heal-*, pre-recover-*,
26
26
  code-tree-*, runtime-tree-*,
27
27
  app-install-*, app-reinstall-*, desktop-local-install-*,
28
28
  packaged-code-f06-conflicts-*, legacy-shim-conflicts-*,
@@ -82,6 +82,8 @@ TECHNICAL_PREFIXES = (
82
82
  "pre-sleep-wrapper-",
83
83
  "pre-obs-clean-",
84
84
  "pre-import-user-data-",
85
+ "pre-heal-",
86
+ "pre-recover-",
85
87
  "pre-freshinstall-",
86
88
  "code-tree-",
87
89
  "runtime-tree-",
@@ -108,8 +110,11 @@ TECHNICAL_PREFIXES = (
108
110
  PROTECTED_NAMES = {"shopify-backups", "weekly"}
109
111
  # Hourly DB dumps at the root of runtime/backups — managed by nexo-backup.sh.
110
112
  HOURLY_DB_RE = re.compile(r"^nexo-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
113
+ LOCAL_CONTEXT_DB_RE = re.compile(r"^local-context-\d{4}-\d{2}-\d{2}-\d{4}(\d{2})?\.db$")
114
+ TEMPORARY_RE = re.compile(r"(^|.*[.])tmp([.-].*)?$|.*\.tmp\..*|.*-journal$|.*\.db-(wal|shm)$")
111
115
  # Big ad-hoc DB files at the root — rare, include for reporting but never auto-prune.
112
116
  ROOT_DB_RE = re.compile(r"^(pre-obs-clean|pre-sleep-wrapper-apply|pre-.*)-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
117
+ DEFAULT_MAX_BYTES = 50 * 1024 * 1024 * 1024
113
118
 
114
119
  # Timestamp patterns embedded in directory names.
115
120
  TS_PATTERNS = (
@@ -147,6 +152,10 @@ def classify(name: str) -> tuple[str, str] | None:
147
152
  """Return (class, family) or None if the entry should be ignored."""
148
153
  if name in PROTECTED_NAMES:
149
154
  return ("BUSINESS", name)
155
+ if TEMPORARY_RE.match(name):
156
+ return ("TEMPORARY", "temporary")
157
+ if LOCAL_CONTEXT_DB_RE.match(name):
158
+ return ("LOCAL_CONTEXT_DB", "local-context")
150
159
  if HOURLY_DB_RE.match(name):
151
160
  return ("HOURLY_DB", "nexo-db")
152
161
  if ROOT_DB_RE.match(name):
@@ -181,6 +190,30 @@ def human_size(n: int) -> str:
181
190
  return f"{n:.1f}P"
182
191
 
183
192
 
193
+ def parse_size_bytes(value: str | int | None, *, default: int = DEFAULT_MAX_BYTES) -> int:
194
+ if value is None or value == "":
195
+ return default
196
+ if isinstance(value, int):
197
+ return max(0, value)
198
+ raw = str(value).strip().lower()
199
+ if not raw:
200
+ return default
201
+ multiplier = 1
202
+ if raw[-1:] in {"k", "m", "g", "t"}:
203
+ unit = raw[-1]
204
+ raw = raw[:-1].strip()
205
+ multiplier = {
206
+ "k": 1024,
207
+ "m": 1024 ** 2,
208
+ "g": 1024 ** 3,
209
+ "t": 1024 ** 4,
210
+ }[unit]
211
+ try:
212
+ return max(0, int(float(raw) * multiplier))
213
+ except ValueError:
214
+ return default
215
+
216
+
184
217
  def gather_entries(backups_root: Path) -> list[dict]:
185
218
  items: list[dict] = []
186
219
  for entry in backups_root.iterdir():
@@ -195,7 +228,10 @@ def gather_entries(backups_root: Path) -> list[dict]:
195
228
  ts = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
196
229
  except OSError:
197
230
  ts = None
198
- size = dir_size_bytes(entry) if entry.is_dir() else entry.stat().st_size
231
+ try:
232
+ size = dir_size_bytes(entry) if entry.is_dir() else entry.stat().st_size
233
+ except OSError:
234
+ continue
199
235
  items.append({
200
236
  "name": name,
201
237
  "path": str(entry),
@@ -214,11 +250,16 @@ def plan_prunes(
214
250
  n_recent: int,
215
251
  window_days: int,
216
252
  only: str | None,
253
+ max_bytes: int,
254
+ tmp_ttl_seconds: int,
255
+ local_context_keep: int,
256
+ hourly_keep: int,
217
257
  ) -> tuple[list[dict], list[dict]]:
218
- """Return (to_delete, to_keep) among TECHNICAL items only."""
258
+ """Return (to_delete, to_keep) for product-generated backup artifacts."""
219
259
  now = datetime.now(tz=timezone.utc)
220
260
  to_delete: list[dict] = []
221
261
  to_keep: list[dict] = []
262
+ delete_ids: set[int] = set()
222
263
  by_family: dict[str, list[dict]] = {}
223
264
  for it in items:
224
265
  if it["class"] != "TECHNICAL":
@@ -245,8 +286,75 @@ def plan_prunes(
245
286
  seen_months.add(ym)
246
287
  to_keep.append(it)
247
288
  continue
248
- to_delete.append(it)
289
+ if id(it) not in delete_ids:
290
+ to_delete.append(it)
291
+ delete_ids.add(id(it))
249
292
  to_keep.extend(keep_recent)
293
+
294
+ for it in items:
295
+ if it["class"] != "TEMPORARY":
296
+ continue
297
+ ts = it["ts"]
298
+ age_seconds = (now - ts).total_seconds() if ts else 10_000_000
299
+ if age_seconds >= tmp_ttl_seconds:
300
+ if id(it) not in delete_ids:
301
+ to_delete.append(it)
302
+ delete_ids.add(id(it))
303
+ else:
304
+ to_keep.append(it)
305
+
306
+ for klass, keep_count in (("LOCAL_CONTEXT_DB", local_context_keep), ("HOURLY_DB", hourly_keep)):
307
+ group = [it for it in items if it["class"] == klass]
308
+ group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
309
+ for it in group[:max(0, keep_count)]:
310
+ to_keep.append(it)
311
+ for it in group[max(0, keep_count):]:
312
+ if id(it) not in delete_ids:
313
+ to_delete.append(it)
314
+ delete_ids.add(id(it))
315
+
316
+ if max_bytes > 0:
317
+ total_after_planned = sum(i["size"] for i in items if id(i) not in delete_ids)
318
+ if total_after_planned > max_bytes:
319
+ protected_keep_ids: set[int] = set()
320
+ by_budget_family: dict[tuple[str, str], list[dict]] = {}
321
+ for it in items:
322
+ by_budget_family.setdefault((it["class"], it["family"]), []).append(it)
323
+ for (klass, _family), group in by_budget_family.items():
324
+ if klass not in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}:
325
+ continue
326
+ min_keep = 0
327
+ if klass == "HOURLY_DB":
328
+ min_keep = max(1, hourly_keep)
329
+ elif klass == "LOCAL_CONTEXT_DB":
330
+ min_keep = max(0, local_context_keep)
331
+ group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
332
+ for it in group[:min_keep]:
333
+ protected_keep_ids.add(id(it))
334
+
335
+ budget_candidates = [
336
+ it for it in items
337
+ if id(it) not in delete_ids
338
+ and id(it) not in protected_keep_ids
339
+ and it["class"] in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}
340
+ ]
341
+ budget_candidates.sort(
342
+ key=lambda x: (
343
+ x["ts"] or datetime.min.replace(tzinfo=timezone.utc),
344
+ -int(x["size"] or 0),
345
+ )
346
+ )
347
+ for it in budget_candidates:
348
+ if total_after_planned <= max_bytes:
349
+ break
350
+ to_delete.append(it)
351
+ delete_ids.add(id(it))
352
+ total_after_planned -= int(it["size"] or 0)
353
+
354
+ keep_ids = {id(i) for i in to_keep}
355
+ for it in items:
356
+ if id(it) not in delete_ids and id(it) not in keep_ids:
357
+ to_keep.append(it)
250
358
  return to_delete, to_keep
251
359
 
252
360
 
@@ -260,13 +368,20 @@ def run(args: argparse.Namespace) -> int:
260
368
  biz_items = [i for i in items if i["class"] == "BUSINESS"]
261
369
  hourly_items = [i for i in items if i["class"] == "HOURLY_DB"]
262
370
  root_db_items = [i for i in items if i["class"] == "ROOT_DB"]
371
+ local_context_items = [i for i in items if i["class"] == "LOCAL_CONTEXT_DB"]
372
+ temporary_items = [i for i in items if i["class"] == "TEMPORARY"]
263
373
  unknown_items = [i for i in items if i["class"] == "UNKNOWN"]
374
+ max_bytes = parse_size_bytes(args.max_bytes)
264
375
 
265
376
  to_delete, to_keep = plan_prunes(
266
377
  items,
267
378
  n_recent=args.recent,
268
379
  window_days=args.window_days,
269
380
  only=args.only,
381
+ max_bytes=max_bytes,
382
+ tmp_ttl_seconds=max(0, args.tmp_ttl_minutes) * 60,
383
+ local_context_keep=max(0, args.local_context_keep),
384
+ hourly_keep=max(0, args.hourly_keep),
270
385
  )
271
386
 
272
387
  total_all = sum(i["size"] for i in items)
@@ -279,6 +394,11 @@ def run(args: argparse.Namespace) -> int:
279
394
  "n_recent": args.recent,
280
395
  "window_days": args.window_days,
281
396
  "only": args.only,
397
+ "max_bytes": max_bytes,
398
+ "max_human": human_size(max_bytes),
399
+ "tmp_ttl_minutes": args.tmp_ttl_minutes,
400
+ "local_context_keep": args.local_context_keep,
401
+ "hourly_keep": args.hourly_keep,
282
402
  },
283
403
  "totals": {
284
404
  "all_bytes": total_all,
@@ -291,6 +411,8 @@ def run(args: argparse.Namespace) -> int:
291
411
  "technical": len(tech_items),
292
412
  "business": len(biz_items),
293
413
  "hourly_db": len(hourly_items),
414
+ "local_context_db": len(local_context_items),
415
+ "temporary": len(temporary_items),
294
416
  "root_db": len(root_db_items),
295
417
  "unknown": len(unknown_items),
296
418
  },
@@ -315,9 +437,11 @@ def run(args: argparse.Namespace) -> int:
315
437
  print(f" technical: {len(tech_items)}")
316
438
  print(f" business: {len(biz_items)} (protected)")
317
439
  print(f" hourly_db: {len(hourly_items)} (managed by nexo-backup.sh)")
440
+ print(f" local_context: {len(local_context_items)}")
441
+ print(f" temporary: {len(temporary_items)}")
318
442
  print(f" root_db: {len(root_db_items)} (never auto-pruned)")
319
443
  print(f" unknown: {len(unknown_items)}")
320
- print(f" policy: keep {args.recent} most-recent + 1 per month within {args.window_days}d")
444
+ print(f" policy: keep {args.recent} most-recent + 1 per month within {args.window_days}d; hard-cap {human_size(max_bytes)}")
321
445
  if args.only:
322
446
  print(f" restricted to family: {args.only}")
323
447
  print()
@@ -364,6 +488,10 @@ def main() -> int:
364
488
  ap.add_argument("--recent", type=int, default=5, help="N most recent per family to always keep (default: 5)")
365
489
  ap.add_argument("--window-days", type=int, default=90, help="month-spaced retention window (default: 90)")
366
490
  ap.add_argument("--only", help="restrict to one technical family (e.g. 'pre-backfill-owner')")
491
+ ap.add_argument("--max-bytes", default=os.environ.get("NEXO_BACKUP_MAX_BYTES", str(DEFAULT_MAX_BYTES)), help="global product-generated backup hard cap, bytes or K/M/G/T (default: 50G)")
492
+ ap.add_argument("--tmp-ttl-minutes", type=int, default=int(os.environ.get("NEXO_BACKUP_TMP_TTL_MINUTES", "30")), help="delete orphan temporary backup files older than this (default: 30)")
493
+ ap.add_argument("--local-context-keep", type=int, default=int(os.environ.get("NEXO_LOCAL_CONTEXT_BACKUP_KEEP_LAST", "1")), help="local-context backup files to keep under the global cap (default: 1)")
494
+ ap.add_argument("--hourly-keep", type=int, default=int(os.environ.get("NEXO_BACKUP_KEEP_LAST", "3")), help="hourly nexo DB backups to keep under the global cap (default: 3)")
367
495
  args = ap.parse_args()
368
496
  try:
369
497
  return run(args)