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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent_runner.py +22 -2
- package/src/artifact_locator.py +89 -0
- package/src/auto_update.py +84 -2
- package/src/automation_supervisor.py +570 -0
- package/src/cli.py +165 -14
- package/src/continuity_sources.py +103 -0
- package/src/email_memory_bridge.py +86 -0
- package/src/enforcement_engine.py +131 -67
- package/src/evidence_ledger.py +1042 -0
- package/src/local_context/health.py +242 -0
- package/src/local_context/usage_events.py +448 -0
- package/src/mcp_live_audit.py +476 -0
- package/src/memory_observation_processor.py +277 -0
- package/src/plugins/update.py +66 -1
- package/src/pre_answer_router.py +1451 -0
- package/src/pre_answer_runtime.py +100 -0
- package/src/saved_not_used_audit.py +917 -0
- package/src/scripts/nexo-backup.sh +41 -2
- package/src/scripts/prune_runtime_backups.py +133 -5
- package/src/server.py +143 -3
- package/src/transcript_coverage.py +148 -0
- package/tool-enforcement-map.json +81 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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.
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
+
}
|
package/src/auto_update.py
CHANGED
|
@@ -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
|