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.0",
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.0` is the current packaged-runtime line. 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.
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.0",
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",
@@ -1019,7 +1019,7 @@ def _problem_rows(conn) -> list[dict]:
1019
1019
  LIMIT 20
1020
1020
  """
1021
1021
  ).fetchall()
1022
- return [
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
- age = time.time() - LOCK_FILE.stat().st_mtime
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 main() -> int:
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
- if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1":
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