nexo-brain 7.22.0 → 7.23.1

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.22.0",
3
+ "version": "7.23.0",
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.22.0` is the current packaged-runtime line. 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.
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.
24
+
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.
22
26
 
23
27
  Previously in `7.21.0`: minor release over v7.20.25 - MCP now starts through a thin compatibility adapter backed by one resident local Runtime Service, reducing duplicate Brain processes and SQLite contention across Claude Code, Codex, Claude Desktop, and NEXO Desktop.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.22.0",
3
+ "version": "7.23.1",
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
 
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ """Project/artifact locator helpers with Project Atlas as authority."""
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Callable, Iterable
8
+
9
+
10
+ FallbackSearch = Callable[[str, int], Iterable[dict]]
11
+
12
+
13
+ def load_project_atlas(path: str | Path) -> dict:
14
+ try:
15
+ payload = json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
16
+ except Exception:
17
+ return {}
18
+ return payload if isinstance(payload, dict) else {}
19
+
20
+
21
+ def _projects(atlas: dict) -> dict:
22
+ if not isinstance(atlas, dict):
23
+ return {}
24
+ if isinstance(atlas.get("projects"), dict):
25
+ return atlas["projects"]
26
+ return atlas
27
+
28
+
29
+ def resolve_project(atlas: dict, query: str) -> dict | None:
30
+ clean_query = str(query or "").strip().lower()
31
+ if not clean_query:
32
+ return None
33
+ for key, entry in _projects(atlas).items():
34
+ if not isinstance(entry, dict):
35
+ continue
36
+ aliases = [str(key), *(entry.get("aliases") or [])]
37
+ haystack = " ".join([*aliases, str(entry.get("description") or "")]).lower()
38
+ if clean_query == str(key).lower() or clean_query in haystack:
39
+ return {"key": str(key), **entry}
40
+ return None
41
+
42
+
43
+ def project_locations(project: dict | None) -> dict:
44
+ if not project:
45
+ return {}
46
+ locations = project.get("locations")
47
+ return locations if isinstance(locations, dict) else {}
48
+
49
+
50
+ def locate_artifact(
51
+ *,
52
+ atlas: dict,
53
+ query: str,
54
+ artifact_kind: str = "",
55
+ fallback_search: FallbackSearch | None = None,
56
+ limit: int = 5,
57
+ ) -> dict:
58
+ project = resolve_project(atlas, query)
59
+ locations = project_locations(project)
60
+ matches: list[dict] = []
61
+ if project:
62
+ for name, value in locations.items():
63
+ if artifact_kind and artifact_kind not in str(name):
64
+ continue
65
+ matches.append({
66
+ "source": "project_atlas",
67
+ "project_key": project["key"],
68
+ "kind": str(name),
69
+ "path": str(value),
70
+ "confidence": 1.0,
71
+ })
72
+ if not matches and fallback_search:
73
+ for row in list(fallback_search(query, limit))[:limit]:
74
+ if not isinstance(row, dict):
75
+ continue
76
+ matches.append({
77
+ "source": str(row.get("source") or "fallback"),
78
+ "project_key": str(row.get("project_key") or ""),
79
+ "kind": str(row.get("kind") or artifact_kind or "artifact"),
80
+ "path": str(row.get("path") or row.get("file") or ""),
81
+ "confidence": float(row.get("confidence") or row.get("score") or 0.4),
82
+ })
83
+ return {
84
+ "query": query,
85
+ "artifact_kind": artifact_kind,
86
+ "project_key": project["key"] if project else "",
87
+ "matches": matches,
88
+ "used_fallback": not bool(project) and bool(fallback_search),
89
+ }
@@ -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