nexo-brain 7.20.0 → 7.20.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.20.
|
|
3
|
+
"version": "7.20.1",
|
|
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,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.20.
|
|
21
|
+
Version `7.20.1` is the current packaged-runtime line. Patch release over v7.20.0 — the Local Context service now recovers from orphaned locks and mixed-version cycle failures instead of leaving the background index stuck.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.0`: minor release over v7.19.0 — the Local Context index now reconciles known files and folders on every service cycle, so created, modified, deleted and newly excluded local files are reflected automatically between full scans.
|
|
22
24
|
|
|
23
25
|
Previously in `7.19.0`: minor release over v7.18.1 - bundle-managed installations (NEXO Desktop `brain-bundle/`) can now pin Brain to the host application release cycle via `NEXO_BRAIN_AUTO_UPDATE=false`, and the server auto-exits with code 75 on fingerprint mismatch so MCP clients respawn the server with the new code instead of leaving stale `server.py` processes alive.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.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/local_context/api.py
CHANGED
|
@@ -1019,7 +1019,7 @@ def _problem_rows(conn) -> list[dict]:
|
|
|
1019
1019
|
LIMIT 20
|
|
1020
1020
|
"""
|
|
1021
1021
|
).fetchall()
|
|
1022
|
-
|
|
1022
|
+
problems = [
|
|
1023
1023
|
{
|
|
1024
1024
|
"user_message": row["user_message"],
|
|
1025
1025
|
"recommended_action": "NEXO lo volvera a intentar mas tarde" if row["retryable"] else "Revisa permisos o archivo",
|
|
@@ -1033,6 +1033,35 @@ def _problem_rows(conn) -> list[dict]:
|
|
|
1033
1033
|
}
|
|
1034
1034
|
for row in rows
|
|
1035
1035
|
]
|
|
1036
|
+
last_success = conn.execute(
|
|
1037
|
+
"SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
|
|
1038
|
+
).fetchone()["created_at"] or 0
|
|
1039
|
+
service_rows = conn.execute(
|
|
1040
|
+
"""
|
|
1041
|
+
SELECT created_at, level, event, message, metadata_json
|
|
1042
|
+
FROM local_index_logs
|
|
1043
|
+
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
|
|
1044
|
+
AND created_at > ?
|
|
1045
|
+
ORDER BY id DESC
|
|
1046
|
+
LIMIT 5
|
|
1047
|
+
""",
|
|
1048
|
+
(last_success,),
|
|
1049
|
+
).fetchall()
|
|
1050
|
+
problems.extend(
|
|
1051
|
+
{
|
|
1052
|
+
"user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
|
|
1053
|
+
"recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
|
|
1054
|
+
"technical_detail": f"{row['event']}: {row['message']} {row['metadata_json']}",
|
|
1055
|
+
"support_code": row["event"],
|
|
1056
|
+
"severity": "warning" if row["level"] == "warn" else "error",
|
|
1057
|
+
"retryable": True,
|
|
1058
|
+
"path": "",
|
|
1059
|
+
"phase": "service",
|
|
1060
|
+
"created_at": row["created_at"],
|
|
1061
|
+
}
|
|
1062
|
+
for row in service_rows
|
|
1063
|
+
)
|
|
1064
|
+
return problems
|
|
1036
1065
|
|
|
1037
1066
|
|
|
1038
1067
|
def _command_output(args: list[str], *, timeout: int = 2) -> tuple[int, str, str]:
|
|
@@ -70,6 +70,57 @@ TRANSIENT_ERROR_KINDS = {
|
|
|
70
70
|
}
|
|
71
71
|
REQUIRED_PROTOCOL_SUMMARY_KEYS = ("guard_check", "heartbeat", "change_log")
|
|
72
72
|
|
|
73
|
+
# Compact few-shot rendered into the prompt on a `json_schema` retry. Keeps
|
|
74
|
+
# the placeholder structure intact so the model sees the exact contract that
|
|
75
|
+
# `_is_valid_extraction` enforces. Kept as a string to avoid pulling in a
|
|
76
|
+
# template engine for a one-shot block.
|
|
77
|
+
JSON_SCHEMA_FEWSHOT = (
|
|
78
|
+
"RETRY_HINT: the previous attempt produced JSON that did not match the "
|
|
79
|
+
"Deep Sleep extraction contract. The response must be a SINGLE JSON "
|
|
80
|
+
"object with the following minimum shape (extra keys allowed):\n"
|
|
81
|
+
"{\n"
|
|
82
|
+
' "session_id": "<exact session id, string>",\n'
|
|
83
|
+
' "findings": [ { "type": "...", "summary": "...", "evidence": "..." } ],\n'
|
|
84
|
+
' "protocol_summary": {\n'
|
|
85
|
+
' "guard_check": { "ran": true|false, "notes": "..." },\n'
|
|
86
|
+
' "heartbeat": { "count": 0, "notes": "..." },\n'
|
|
87
|
+
' "change_log": { "entries": 0, "notes": "..." }\n'
|
|
88
|
+
" }\n"
|
|
89
|
+
"}\n"
|
|
90
|
+
"Mandatory: session_id is a non-empty string equal to {{SESSION_ID}}; "
|
|
91
|
+
"findings is a list of objects; protocol_summary contains the three "
|
|
92
|
+
"object keys above. Return ONLY the JSON object, no prose, no fences."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _record_protocol_debt(
|
|
97
|
+
session_id: str,
|
|
98
|
+
*,
|
|
99
|
+
debt_type: str,
|
|
100
|
+
severity: str,
|
|
101
|
+
evidence: str,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Best-effort registration of an extraction failure as protocol debt.
|
|
104
|
+
|
|
105
|
+
Imported lazily so the extractor still runs in environments where the
|
|
106
|
+
DB layer is unavailable (e.g. partial installs, unit tests). Any error
|
|
107
|
+
inside the debt path is swallowed: we never want a debt-logging issue
|
|
108
|
+
to mask the real extraction failure already being reported.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
from db._protocol import create_protocol_debt
|
|
112
|
+
except Exception: # pragma: no cover - best effort
|
|
113
|
+
return
|
|
114
|
+
try:
|
|
115
|
+
create_protocol_debt(
|
|
116
|
+
session_id,
|
|
117
|
+
debt_type,
|
|
118
|
+
severity=severity,
|
|
119
|
+
evidence=evidence[:3500],
|
|
120
|
+
)
|
|
121
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
122
|
+
print(f" Warning: could not record protocol_debt: {exc}", file=sys.stderr)
|
|
123
|
+
|
|
73
124
|
|
|
74
125
|
def _classify_cli_result(result) -> tuple[str, str]:
|
|
75
126
|
"""Return (kind, short_message) describing a failed automation backend call.
|
|
@@ -211,11 +262,18 @@ def analyze_session(
|
|
|
211
262
|
date_dir: Path,
|
|
212
263
|
shared_context_file: Path | None,
|
|
213
264
|
session_txt_map: dict[str, str] | None = None,
|
|
265
|
+
*,
|
|
266
|
+
prior_error_kind: str = "",
|
|
214
267
|
) -> tuple[dict | None, str | None]:
|
|
215
268
|
"""Send a session to the automation backend for extraction analysis.
|
|
216
269
|
|
|
217
270
|
Returns (parsed_result, error_kind). `error_kind` is only set on failure.
|
|
218
271
|
See `_classify_cli_result` for possible values.
|
|
272
|
+
|
|
273
|
+
``prior_error_kind`` is consumed by the retry path: when the previous
|
|
274
|
+
attempt failed validation with ``json_schema`` we append a few-shot of
|
|
275
|
+
the contract so the model sees the exact shape it must produce instead
|
|
276
|
+
of repeating the same structurally wrong payload.
|
|
219
277
|
"""
|
|
220
278
|
session_file = find_session_file(session_id, date_dir, session_txt_map=session_txt_map)
|
|
221
279
|
if not session_file:
|
|
@@ -236,6 +294,15 @@ def analyze_session(
|
|
|
236
294
|
prompt = prompt_template.replace("{{CONTEXT_FILE}}", str(session_file))
|
|
237
295
|
prompt = prompt.replace("{{SESSION_ID}}", session_id)
|
|
238
296
|
prompt += shared_ctx_instruction
|
|
297
|
+
if prior_error_kind == "json_schema":
|
|
298
|
+
prompt += "\n\n" + JSON_SCHEMA_FEWSHOT.replace("{{SESSION_ID}}", session_id)
|
|
299
|
+
|
|
300
|
+
# Bootstrap the subagent with the day's deep-sleep dir as cwd so its
|
|
301
|
+
# default Read allowlist already covers the session transcript, the
|
|
302
|
+
# shared context, and the day's working files. Without this, the CLI
|
|
303
|
+
# subprocess inherits the parent's cwd (often "/") and fails with
|
|
304
|
+
# `cannot_comply` the first time it tries to Read the session file.
|
|
305
|
+
subagent_cwd = str(date_dir) if date_dir and Path(date_dir).exists() else None
|
|
239
306
|
|
|
240
307
|
try:
|
|
241
308
|
json_system_prompt = render_core_prompt(
|
|
@@ -246,6 +313,7 @@ def analyze_session(
|
|
|
246
313
|
result = run_automation_prompt(
|
|
247
314
|
prompt,
|
|
248
315
|
caller="deep-sleep/extract",
|
|
316
|
+
cwd=subagent_cwd,
|
|
249
317
|
timeout=CLAUDE_TIMEOUT,
|
|
250
318
|
output_format="text",
|
|
251
319
|
append_system_prompt=json_system_prompt,
|
|
@@ -276,6 +344,7 @@ def analyze_session(
|
|
|
276
344
|
convert_result = run_automation_prompt(
|
|
277
345
|
convert_prompt,
|
|
278
346
|
caller="deep-sleep/extract",
|
|
347
|
+
cwd=subagent_cwd,
|
|
279
348
|
timeout=120,
|
|
280
349
|
output_format="text",
|
|
281
350
|
append_system_prompt=json_system_prompt,
|
|
@@ -471,6 +540,7 @@ def main():
|
|
|
471
540
|
date_dir,
|
|
472
541
|
shared_context_file,
|
|
473
542
|
session_txt_map=session_txt_map,
|
|
543
|
+
prior_error_kind=last_error_kind,
|
|
474
544
|
)
|
|
475
545
|
if result:
|
|
476
546
|
break
|
|
@@ -522,6 +592,21 @@ def main():
|
|
|
522
592
|
}
|
|
523
593
|
all_extractions.append(failed_entry)
|
|
524
594
|
_save_checkpoint(checkpoint_file, failed_entry)
|
|
595
|
+
# Surface deterministic extractor failures as protocol debt so
|
|
596
|
+
# the aggregate self-audit cannot silently absorb the pattern.
|
|
597
|
+
# Severity escalates once the session is poisoned because by
|
|
598
|
+
# then it stops being a per-run hiccup and becomes a recurring
|
|
599
|
+
# runtime issue worth a louder signal.
|
|
600
|
+
_record_protocol_debt(
|
|
601
|
+
session_id,
|
|
602
|
+
debt_type=f"deep-sleep.extract.{last_error_kind}",
|
|
603
|
+
severity="error" if state == "poisoned" else "warn",
|
|
604
|
+
evidence=(
|
|
605
|
+
f"date={target_date} state={state} attempts={new_count}/"
|
|
606
|
+
f"{MAX_POISON_ATTEMPTS} kind={last_error_kind} "
|
|
607
|
+
f"checkpoint={checkpoint_file}"
|
|
608
|
+
),
|
|
609
|
+
)
|
|
525
610
|
if state == "poisoned":
|
|
526
611
|
poisoned += 1
|
|
527
612
|
|
|
@@ -51,6 +51,27 @@ def log(message: str) -> None:
|
|
|
51
51
|
handle.write(line + "\n")
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _read_lock() -> dict:
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(LOCK_FILE.read_text(encoding="utf-8"))
|
|
57
|
+
except Exception:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pid_running(pid: int) -> bool:
|
|
62
|
+
if pid <= 0:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
os.kill(pid, 0)
|
|
66
|
+
except ProcessLookupError:
|
|
67
|
+
return False
|
|
68
|
+
except PermissionError:
|
|
69
|
+
return True
|
|
70
|
+
except OSError:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
54
75
|
def acquire_lock() -> bool:
|
|
55
76
|
try:
|
|
56
77
|
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
@@ -59,9 +80,16 @@ def acquire_lock() -> bool:
|
|
|
59
80
|
return True
|
|
60
81
|
except FileExistsError:
|
|
61
82
|
try:
|
|
62
|
-
|
|
83
|
+
lock = _read_lock()
|
|
84
|
+
pid = int(lock.get("pid") or 0)
|
|
85
|
+
age = time.time() - float(lock.get("created_at") or LOCK_FILE.stat().st_mtime)
|
|
86
|
+
if pid and not _pid_running(pid):
|
|
87
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
88
|
+
log(f"Removed stale local-index lock for dead pid {pid}.")
|
|
89
|
+
return acquire_lock()
|
|
63
90
|
if age > LOCK_STALE_SECONDS:
|
|
64
91
|
LOCK_FILE.unlink(missing_ok=True)
|
|
92
|
+
log(f"Removed stale local-index lock older than {int(age)} seconds.")
|
|
65
93
|
return acquire_lock()
|
|
66
94
|
except Exception:
|
|
67
95
|
pass
|
|
@@ -70,25 +98,47 @@ def acquire_lock() -> bool:
|
|
|
70
98
|
|
|
71
99
|
def release_lock() -> None:
|
|
72
100
|
try:
|
|
101
|
+
lock = _read_lock()
|
|
102
|
+
pid = int(lock.get("pid") or 0)
|
|
103
|
+
if pid and pid != os.getpid():
|
|
104
|
+
return
|
|
73
105
|
LOCK_FILE.unlink(missing_ok=True)
|
|
74
106
|
except Exception:
|
|
75
107
|
pass
|
|
76
108
|
|
|
77
109
|
|
|
78
|
-
def
|
|
79
|
-
if not acquire_lock():
|
|
80
|
-
log("Skipped: previous local-index cycle is still running.")
|
|
81
|
-
return 0
|
|
110
|
+
def _run_index_cycle() -> dict:
|
|
82
111
|
try:
|
|
83
|
-
|
|
84
|
-
api.ensure_default_roots()
|
|
85
|
-
result = api.run_once(
|
|
112
|
+
return api.run_once(
|
|
86
113
|
limit=SCAN_LIMIT,
|
|
87
114
|
process_limit=PROCESS_LIMIT,
|
|
88
115
|
live_asset_limit=LIVE_ASSET_LIMIT,
|
|
89
116
|
live_dir_limit=LIVE_DIR_LIMIT,
|
|
90
117
|
live_file_limit=LIVE_FILE_LIMIT,
|
|
91
118
|
)
|
|
119
|
+
except TypeError as exc:
|
|
120
|
+
message = str(exc)
|
|
121
|
+
live_kwargs = ("live_asset_limit", "live_dir_limit", "live_file_limit")
|
|
122
|
+
if not any(name in message for name in live_kwargs):
|
|
123
|
+
raise
|
|
124
|
+
log_event(
|
|
125
|
+
"warn",
|
|
126
|
+
"service_cycle_compat_fallback",
|
|
127
|
+
"Local memory service used compatibility fallback",
|
|
128
|
+
error=message,
|
|
129
|
+
)
|
|
130
|
+
log(f"Compatibility fallback: api.run_once does not accept live reconcile limits ({message}).")
|
|
131
|
+
return api.run_once(limit=SCAN_LIMIT, process_limit=PROCESS_LIMIT)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> int:
|
|
135
|
+
if not acquire_lock():
|
|
136
|
+
log("Skipped: previous local-index cycle is still running.")
|
|
137
|
+
return 0
|
|
138
|
+
try:
|
|
139
|
+
if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1":
|
|
140
|
+
api.ensure_default_roots()
|
|
141
|
+
result = _run_index_cycle()
|
|
92
142
|
log_event("info", "service_cycle_finished", "Local memory service cycle finished", result=result)
|
|
93
143
|
log(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
94
144
|
return 0 if result.get("ok") else 2
|