nexo-brain 7.20.19 → 7.20.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.19",
3
+ "version": "7.20.21",
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/cli.py CHANGED
@@ -140,6 +140,166 @@ def _mcp_status(args) -> int:
140
140
  )
141
141
 
142
142
 
143
+ def _mcp_write_message(stdin, payload: dict) -> None:
144
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
145
+ stdin.write(raw + b"\n")
146
+ stdin.flush()
147
+
148
+
149
+ def _mcp_reader(stdout, queue) -> None:
150
+ while True:
151
+ try:
152
+ line = stdout.readline()
153
+ if not line:
154
+ return
155
+ text = line.decode("utf-8", errors="replace").strip()
156
+ if not text:
157
+ continue
158
+ queue.put(json.loads(text))
159
+ except Exception as exc:
160
+ queue.put({"_reader_error": str(exc)})
161
+ return
162
+
163
+
164
+ def _stderr_reader(stderr, lines: list[str]) -> None:
165
+ while True:
166
+ chunk = stderr.readline()
167
+ if not chunk:
168
+ return
169
+ try:
170
+ lines.append(chunk.decode("utf-8", errors="replace").rstrip())
171
+ except Exception:
172
+ lines.append(repr(chunk))
173
+ del lines[:-80]
174
+
175
+
176
+ def _mcp_wait_for_response(queue, request_id: int, timeout_seconds: float) -> dict:
177
+ import queue as queue_module
178
+
179
+ deadline = time.monotonic() + max(timeout_seconds, 0.1)
180
+ while time.monotonic() < deadline:
181
+ remaining = max(deadline - time.monotonic(), 0.05)
182
+ try:
183
+ message = queue.get(timeout=remaining)
184
+ except queue_module.Empty:
185
+ break
186
+ if message.get("_reader_error"):
187
+ raise RuntimeError(message["_reader_error"])
188
+ if message.get("id") == request_id:
189
+ return message
190
+ raise TimeoutError(f"MCP response {request_id} timed out")
191
+
192
+
193
+ def _mcp_probe(args) -> int:
194
+ import queue as queue_module
195
+ import threading
196
+
197
+ timeout_ms = int(getattr(args, "timeout_ms", 8000) or 8000)
198
+ timeout_seconds = max(timeout_ms / 1000.0, 1.0)
199
+ started_at = time.monotonic()
200
+ server_path = NEXO_CODE / "server.py"
201
+ env = os.environ.copy()
202
+ env["NEXO_MCP_PROBE"] = "1"
203
+ env.setdefault("NEXO_MCP_PLUGIN_MODE", getattr(args, "plugin_mode", None) or "none")
204
+ env.setdefault("NEXO_MCP_RUN_STARTUP_PREFLIGHT", "0")
205
+ client = str(getattr(args, "client", "") or "").strip()
206
+ if client:
207
+ env["NEXO_MCP_CLIENT"] = client
208
+
209
+ proc = None
210
+ stderr_lines: list[str] = []
211
+ try:
212
+ proc = subprocess.Popen(
213
+ [sys.executable, str(server_path)],
214
+ cwd=str(NEXO_CODE),
215
+ env=env,
216
+ stdin=subprocess.PIPE,
217
+ stdout=subprocess.PIPE,
218
+ stderr=subprocess.PIPE,
219
+ text=False,
220
+ )
221
+ responses = queue_module.Queue()
222
+ threading.Thread(target=_mcp_reader, args=(proc.stdout, responses), daemon=True).start()
223
+ threading.Thread(target=_stderr_reader, args=(proc.stderr, stderr_lines), daemon=True).start()
224
+
225
+ _mcp_write_message(proc.stdin, {
226
+ "jsonrpc": "2.0",
227
+ "id": 1,
228
+ "method": "initialize",
229
+ "params": {
230
+ "protocolVersion": "2024-11-05",
231
+ "capabilities": {},
232
+ "clientInfo": {"name": "nexo-mcp-probe", "version": _get_version()},
233
+ },
234
+ })
235
+ init_response = _mcp_wait_for_response(responses, 1, timeout_seconds)
236
+ if init_response.get("error"):
237
+ raise RuntimeError(f"MCP initialize failed: {init_response['error']}")
238
+
239
+ _mcp_write_message(proc.stdin, {
240
+ "jsonrpc": "2.0",
241
+ "method": "notifications/initialized",
242
+ "params": {},
243
+ })
244
+ _mcp_write_message(proc.stdin, {
245
+ "jsonrpc": "2.0",
246
+ "id": 2,
247
+ "method": "tools/list",
248
+ "params": {},
249
+ })
250
+ tools_response = _mcp_wait_for_response(responses, 2, timeout_seconds)
251
+ if tools_response.get("error"):
252
+ raise RuntimeError(f"MCP tools/list failed: {tools_response['error']}")
253
+ tools = ((tools_response.get("result") or {}).get("tools") or [])
254
+ tool_names = [
255
+ str(tool.get("name") or "")
256
+ for tool in tools
257
+ if isinstance(tool, dict) and tool.get("name")
258
+ ]
259
+ required = ["nexo_startup", "nexo_heartbeat", "nexo_task_open", "nexo_guard_check"]
260
+ missing = [name for name in required if name not in tool_names]
261
+ ok = not missing and len(tool_names) > 0
262
+ payload = {
263
+ "ok": ok,
264
+ "mcp_ready": ok,
265
+ "probe_ok": ok,
266
+ "tools_available": len(tool_names) > 0,
267
+ "tool_count": len(tool_names),
268
+ "required_tools_present": not missing,
269
+ "missing_required_tools": missing,
270
+ "client": client,
271
+ "plugin_mode": env.get("NEXO_MCP_PLUGIN_MODE"),
272
+ "elapsed_ms": int((time.monotonic() - started_at) * 1000),
273
+ "stderr_tail": "\n".join(stderr_lines[-12:]),
274
+ }
275
+ return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
276
+ except Exception as exc:
277
+ payload = {
278
+ "ok": False,
279
+ "mcp_ready": False,
280
+ "probe_ok": False,
281
+ "error": "mcp_probe_failed",
282
+ "message": str(exc),
283
+ "client": client,
284
+ "elapsed_ms": int((time.monotonic() - started_at) * 1000),
285
+ "stderr_tail": "\n".join(stderr_lines[-20:]),
286
+ }
287
+ return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
288
+ finally:
289
+ if proc is not None:
290
+ try:
291
+ proc.terminate()
292
+ except Exception:
293
+ pass
294
+ try:
295
+ proc.wait(timeout=2)
296
+ except Exception:
297
+ try:
298
+ proc.kill()
299
+ except Exception:
300
+ pass
301
+
302
+
143
303
  def _mcp_clear_restart(args) -> int:
144
304
  return _print_json_or_text(
145
305
  clear_restart_required_marker(
@@ -3562,6 +3722,11 @@ def main():
3562
3722
  mcp_status_p = mcp_sub.add_parser("status", help="Read the current runtime/MCP alignment state")
3563
3723
  mcp_status_p.add_argument("--client", default="", help="Optional client label such as claude_desktop or codex")
3564
3724
  mcp_status_p.add_argument("--json", action="store_true", help="JSON output")
3725
+ mcp_probe_p = mcp_sub.add_parser("probe", help="Launch the MCP server and verify initialize + tools/list")
3726
+ mcp_probe_p.add_argument("--client", default="", help="Optional client label such as claude_desktop or codex")
3727
+ mcp_probe_p.add_argument("--timeout-ms", type=int, default=8000)
3728
+ mcp_probe_p.add_argument("--plugin-mode", default="none", choices=["essential", "none", "full"])
3729
+ mcp_probe_p.add_argument("--json", action="store_true", help="JSON output")
3565
3730
  mcp_clear_p = mcp_sub.add_parser("clear-restart", help="Acknowledge that a client/session reloaded the new runtime")
3566
3731
  mcp_clear_p.add_argument("--client", default="", help="Client label such as claude_desktop or codex")
3567
3732
  mcp_clear_p.add_argument("--installed-version", default="")
@@ -3865,6 +4030,8 @@ def main():
3865
4030
  elif args.command == "mcp":
3866
4031
  if args.mcp_command == "status":
3867
4032
  return _mcp_status(args)
4033
+ if args.mcp_command == "probe":
4034
+ return _mcp_probe(args)
3868
4035
  if args.mcp_command == "clear-restart":
3869
4036
  return _mcp_clear_restart(args)
3870
4037
  mcp_parser.print_help()
package/src/server.py CHANGED
@@ -259,8 +259,56 @@ def _run_startup_preflight_sync() -> None:
259
259
  print(f"[NEXO auto-update] error: {e}", file=sys.stderr)
260
260
 
261
261
 
262
+ _ESSENTIAL_MCP_STARTUP_PLUGINS = (
263
+ "cards.py",
264
+ "doctor.py",
265
+ "episodic_memory.py",
266
+ "evolution.py",
267
+ "lifecycle_events.py",
268
+ "outcomes.py",
269
+ "preferences.py",
270
+ "protocol.py",
271
+ "recover.py",
272
+ "skills.py",
273
+ "user_state_tools.py",
274
+ "workflow.py",
275
+ )
276
+
277
+
278
+ def _env_flag(name: str, *, default: bool = False) -> bool:
279
+ value = os.environ.get(name)
280
+ if value is None:
281
+ return default
282
+ return str(value).strip().lower() in {"1", "true", "yes", "on", "y", "si"}
283
+
284
+
285
+ def _mcp_startup_plugin_mode() -> str:
286
+ return str(os.environ.get("NEXO_MCP_PLUGIN_MODE", "none") or "none").strip().lower()
287
+
288
+
289
+ def _load_startup_plugins() -> None:
290
+ mode = _mcp_startup_plugin_mode()
291
+ if mode in {"none", "off", "0", "false"}:
292
+ print("[NEXO] MCP dynamic plugin loading skipped.", file=sys.stderr)
293
+ return
294
+ if mode in {"full", "all", "legacy"}:
295
+ load_all_plugins(mcp)
296
+ return
297
+
298
+ if mode not in {"essential", "fast", "default"}:
299
+ print(f"[NEXO] Unknown NEXO_MCP_PLUGIN_MODE={mode!r}; using essential plugins.", file=sys.stderr)
300
+
301
+ loaded = 0
302
+ for filename in _ESSENTIAL_MCP_STARTUP_PLUGINS:
303
+ try:
304
+ loaded += int(load_plugin(mcp, filename) or 0)
305
+ except Exception as exc:
306
+ print(f"[PLUGIN ERROR] {filename}: {exc}", file=sys.stderr)
307
+ print(f"[NEXO] MCP essential plugins ready: {loaded} tools.", file=sys.stderr)
308
+
309
+
262
310
  def _server_init():
263
- """Run all side effects: signals, PID, DB, auto-update, plugins.
311
+ """Run side effects needed by the MCP server.
264
312
 
265
313
  Called only when the server is actually started (not on import).
266
314
  """
@@ -268,20 +316,26 @@ def _server_init():
268
316
  signal.signal(signal.SIGINT, _shutdown_handler)
269
317
 
270
318
  # ── Write PID file for stale process detection ─────────────────
271
- data_dir = _data_dir()
272
- os.makedirs(data_dir, exist_ok=True)
273
- _pid_file = os.path.join(data_dir, "nexo.pid")
274
- with open(_pid_file, "w") as f:
275
- f.write(str(os.getpid()))
319
+ if not _env_flag("NEXO_MCP_PROBE"):
320
+ data_dir = _data_dir()
321
+ os.makedirs(data_dir, exist_ok=True)
322
+ _pid_file = os.path.join(data_dir, "nexo.pid")
323
+ with open(_pid_file, "w") as f:
324
+ f.write(str(os.getpid()))
276
325
 
277
326
  # ── Database initialization with recovery ─────────────────────
278
327
  _init_db_or_exit()
279
328
 
280
- # ── Auto-update / startup preflight (synchronous) ─────────────
281
- _run_startup_preflight_sync()
329
+ # ── Auto-update / startup preflight ───────────────────────────
330
+ # The MCP client waits for an immediate JSON-RPC handshake. Running update
331
+ # checks here can block the transport and make clients start without NEXO.
332
+ if _env_flag("NEXO_MCP_RUN_STARTUP_PREFLIGHT"):
333
+ _run_startup_preflight_sync()
334
+ else:
335
+ print("[NEXO] MCP startup preflight deferred.", file=sys.stderr)
282
336
 
283
337
  # ── Load plugins ───────────────────────────────────────────────
284
- load_all_plugins(mcp)
338
+ _load_startup_plugins()
285
339
 
286
340
 
287
341
  mcp = FastMCP(
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  import os
6
6
  import paths
7
+ import sqlite3
7
8
  import time
8
9
  import secrets
9
10
  import threading
@@ -81,6 +82,45 @@ def _env_flag(name: str, default: bool = False) -> bool:
81
82
  return raw.strip().lower() not in {"", "0", "false", "no", "off"}
82
83
 
83
84
 
85
+ def _interactive_db_timeout_ms() -> int:
86
+ """Short DB wait for interactive MCP tools.
87
+
88
+ Long waits make Desktop look frozen when a background cron briefly owns
89
+ the SQLite writer lock. Interactive tools should degrade and let the chat
90
+ continue instead of waiting 30s per query.
91
+ """
92
+ try:
93
+ return max(50, min(int(os.environ.get("NEXO_MCP_DB_BUSY_TIMEOUT_MS", "250")), 10000))
94
+ except Exception:
95
+ return 250
96
+
97
+
98
+ def _set_interactive_db_timeout() -> None:
99
+ try:
100
+ conn = get_db()
101
+ conn.execute(f"PRAGMA busy_timeout={_interactive_db_timeout_ms()}")
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ def _is_db_busy(exc: BaseException) -> bool:
107
+ if isinstance(exc, sqlite3.OperationalError) and "database is locked" in str(exc).lower():
108
+ return True
109
+ return "database is locked" in str(exc).lower()
110
+
111
+
112
+ def _safe_interactive(label: str, fn, default=None, warnings: list[str] | None = None):
113
+ try:
114
+ return fn()
115
+ except Exception as exc:
116
+ if warnings is not None:
117
+ if _is_db_busy(exc):
118
+ warnings.append(f"{label}: skipped because the local brain database is busy")
119
+ else:
120
+ warnings.append(f"{label}: skipped ({type(exc).__name__})")
121
+ return default
122
+
123
+
84
124
  def _keepalive_loop(sid: str, stop_event: threading.Event) -> None:
85
125
  """Periodically touch the session's last_update_epoch until stopped."""
86
126
  while not stop_event.wait(KEEPALIVE_INTERVAL):
@@ -381,8 +421,21 @@ def handle_startup(
381
421
  Enables automatic inbox detection when hook-backed clients provide one.
382
422
  session_client: Optional client label such as `claude_code` or `codex`.
383
423
  """
424
+ _set_interactive_db_timeout()
384
425
  sid = _generate_sid()
385
- cleaned = clean_stale_sessions()
426
+ startup_warnings: list[str] = []
427
+ cleaned = _safe_interactive("stale-session cleanup", clean_stale_sessions, 0, startup_warnings)
428
+ if startup_warnings:
429
+ lines = [f"SID: {sid}"]
430
+ conversation = str(conversation_id or "").strip()
431
+ if conversation:
432
+ lines.append(f"CONVERSATION_ID: {conversation}")
433
+ lines.append("")
434
+ lines.append("STARTUP DEGRADED:")
435
+ for warning in startup_warnings[:4]:
436
+ lines.append(f" {warning}")
437
+ lines.append(" Continue responding; retry nexo_heartbeat shortly for full context.")
438
+ return "\n".join(lines)
386
439
  linked_session_id = (session_token or claude_session_id or "").strip()
387
440
  # v6.0.7 hotfix: when the caller did not pass an explicit UUID, fall back to
388
441
  # the Claude Code SessionStart UUID written by the SessionStart hook to
@@ -403,36 +456,47 @@ def handle_startup(
403
456
  conversation = str(conversation_id or "").strip()
404
457
  conflicts = []
405
458
  if conversation:
406
- cutoff = now_epoch() - SESSION_STALE_SECONDS
407
- conn = get_db()
408
- rows = conn.execute(
409
- """
410
- SELECT sid, task, last_update_epoch, external_session_id, session_client
411
- FROM sessions
412
- WHERE conversation_id = ? AND last_update_epoch > ?
413
- ORDER BY last_update_epoch DESC
414
- """,
415
- (conversation, cutoff),
416
- ).fetchall()
417
- conflicts = [dict(row) for row in rows if row["sid"] != sid]
418
- register_session(
419
- sid,
420
- task,
421
- claude_session_id=linked_session_id,
422
- external_session_id=linked_session_id,
423
- session_client=inferred_client,
424
- conversation_id=conversation,
459
+ def _load_conflicts():
460
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
461
+ conn = get_db()
462
+ rows = conn.execute(
463
+ """
464
+ SELECT sid, task, last_update_epoch, external_session_id, session_client
465
+ FROM sessions
466
+ WHERE conversation_id = ? AND last_update_epoch > ?
467
+ ORDER BY last_update_epoch DESC
468
+ """,
469
+ (conversation, cutoff),
470
+ ).fetchall()
471
+ return [dict(row) for row in rows if row["sid"] != sid]
472
+
473
+ conflicts = _safe_interactive("conversation conflict lookup", _load_conflicts, [], startup_warnings)
474
+ registered = _safe_interactive(
475
+ "session registration",
476
+ lambda: register_session(
477
+ sid,
478
+ task,
479
+ claude_session_id=linked_session_id,
480
+ external_session_id=linked_session_id,
481
+ session_client=inferred_client,
482
+ conversation_id=conversation,
483
+ ),
484
+ None,
485
+ startup_warnings,
425
486
  )
426
487
  memory_maintenance = None
427
- try:
428
- backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "250") or "250")
429
- memory_maintenance = maintain_memory_observations(
430
- process_limit=100,
431
- retry_failed=True,
432
- backfill_limit=backfill_limit,
433
- )
434
- except Exception as exc:
435
- memory_maintenance = {"ok": False, "error": str(exc)}
488
+ if _env_flag("NEXO_MEMORY_MAINTENANCE_IN_STARTUP", default=False):
489
+ try:
490
+ backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "0") or "0")
491
+ memory_maintenance = maintain_memory_observations(
492
+ process_limit=int(os.environ.get("NEXO_MEMORY_STARTUP_PROCESS_LIMIT", "20") or "20"),
493
+ retry_failed=True,
494
+ backfill_limit=backfill_limit,
495
+ )
496
+ except Exception as exc:
497
+ memory_maintenance = {"ok": False, "error": str(exc)}
498
+ else:
499
+ memory_maintenance = {"ok": True, "skipped": True}
436
500
  # v43 hotfix: also register in session_claude_aliases so multi-
437
501
  # conversation NEXO Desktop spawns (each with its own claude UUID)
438
502
  # resolve to the same NEXO sid on every PreToolUse hook lookup.
@@ -447,10 +511,11 @@ def handle_startup(
447
511
  except Exception:
448
512
  # Never let alias registration failures block startup.
449
513
  pass
450
- _start_keepalive(sid)
451
- active = get_active_sessions()
514
+ if registered:
515
+ _start_keepalive(sid)
516
+ active = _safe_interactive("active-session lookup", get_active_sessions, [], startup_warnings)
452
517
  other_sessions = [s for s in active if s["sid"] != sid]
453
- inbox = get_inbox(sid)
518
+ inbox = _safe_interactive("inbox lookup", lambda: get_inbox(sid), [], startup_warnings)
454
519
 
455
520
  lines = [f"SID: {sid}"]
456
521
  if conversation:
@@ -459,6 +524,13 @@ def handle_startup(
459
524
  if cleaned > 0:
460
525
  lines.append(f"Cleaned {cleaned} stale sessions.")
461
526
 
527
+ if startup_warnings:
528
+ lines.append("")
529
+ lines.append("STARTUP DEGRADED:")
530
+ for warning in startup_warnings[:4]:
531
+ lines.append(f" {warning}")
532
+ lines.append(" Continue responding; retry nexo_heartbeat shortly for full context.")
533
+
462
534
  if other_sessions:
463
535
  lines.append("")
464
536
  lines.append("ACTIVE SESSIONS:")
@@ -649,6 +721,8 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
649
721
  """Inner body of handle_heartbeat — wrapped by tool_span above."""
650
722
  from db import get_db, update_last_heartbeat_ts
651
723
 
724
+ _set_interactive_db_timeout()
725
+ heartbeat_warnings: list[str] = []
652
726
  mandate_state = None
653
727
  if context_hint:
654
728
  try:
@@ -658,6 +732,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
658
732
  context_hint,
659
733
  session_id=sid,
660
734
  source="heartbeat",
735
+ classifier=(lambda **_: False),
661
736
  )
662
737
  except Exception:
663
738
  mandate_state = None
@@ -669,7 +744,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
669
744
  except Exception:
670
745
  mandate_state = None
671
746
 
672
- update_session(sid, task)
747
+ _safe_interactive("session heartbeat update", lambda: update_session(sid, task), None, heartbeat_warnings)
673
748
  # v6.0.1 — stamp last_heartbeat_ts so the PostToolUse hook can
674
749
  # decide whether to surface a pending-inbox reminder on autopilot
675
750
  # sessions. Best-effort: never break the heartbeat on failure.
@@ -683,8 +758,15 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
683
758
  # no timezone assumption: clients format per operator preferences.
684
759
  _now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
685
760
  parts = [f"NOW_UTC: {_now_iso}", f"OK: {sid} — {task}"]
761
+ if heartbeat_warnings:
762
+ parts.append("")
763
+ parts.append("HEARTBEAT DEGRADED:")
764
+ for warning in heartbeat_warnings[:3]:
765
+ parts.append(f" {warning}")
766
+ parts.append(" Continue with the user request; context will catch up on a later heartbeat.")
767
+ return "\n".join(parts)
686
768
 
687
- inbox = get_inbox(sid)
769
+ inbox = _safe_interactive("inbox lookup", lambda: get_inbox(sid), [], heartbeat_warnings)
688
770
  if inbox:
689
771
  parts.append("")
690
772
  parts.append("MESSAGES:")
@@ -692,7 +774,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
692
774
  age = _format_age(m["created_epoch"])
693
775
  parts.append(f" [{m['from_sid']}] ({age}): {m['text']}")
694
776
 
695
- questions = get_pending_questions(sid)
777
+ questions = _safe_interactive("pending-question lookup", lambda: get_pending_questions(sid), [], heartbeat_warnings)
696
778
  if questions:
697
779
  parts.append("")
698
780
  parts.append("PENDING QUESTIONS (respond with nexo_answer):")
@@ -712,7 +794,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
712
794
  if bundle.get("has_matches"):
713
795
  parts.append("")
714
796
  parts.append(format_pre_action_context_bundle(bundle, compact=True))
715
- if append_local_context_evidence is not None:
797
+ if _env_flag("NEXO_HEARTBEAT_LOCAL_CONTEXT", default=False) and append_local_context_evidence is not None:
716
798
  local_rendered = append_local_context_evidence("", recent_query, limit=4).strip()
717
799
  if local_rendered:
718
800
  parts.append("")
@@ -813,7 +895,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
813
895
 
814
896
  # ── Drive/Curiosity: detect signals from context_hint (best-effort) ──
815
897
  try:
816
- if context_hint and len(context_hint.strip()) >= 15:
898
+ if _env_flag("NEXO_DRIVE_IN_HEARTBEAT", default=False) and context_hint and len(context_hint.strip()) >= 15:
817
899
  from tools_drive import detect_drive_signal as _detect_drive
818
900
  _drive_allow_llm = _env_flag("NEXO_DRIVE_LLM_IN_HEARTBEAT", default=False)
819
901
  _drive_result = _detect_drive(
@@ -835,11 +917,16 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
835
917
  pass # Drive detection is best-effort, never block heartbeat
836
918
 
837
919
  # ── Layer 3: DIARY_OVERDUE signal based on heartbeat count + time ──
838
- conn = get_db()
839
- row = conn.execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone()
920
+ conn = None
921
+ row = _safe_interactive(
922
+ "session age lookup",
923
+ lambda: get_db().execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone(),
924
+ None,
925
+ None,
926
+ )
840
927
  if row:
841
928
  age_seconds = now_epoch() - row["started_epoch"]
842
- has_diary = check_session_has_diary(sid)
929
+ has_diary = _safe_interactive("diary lookup", lambda: check_session_has_diary(sid), True, None)
843
930
 
844
931
  # DIARY_OVERDUE: >10 heartbeats OR >30 minutes, without a diary
845
932
  if not has_diary and (_hb_count > 10 or age_seconds >= 1800):
@@ -855,6 +942,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
855
942
  # Guard check reminder: if context_hint mentions code editing and no guard_check this session
856
943
  if context_hint and _hint_suggests_code_edit(context_hint):
857
944
  try:
945
+ conn = conn or get_db()
858
946
  guard_used = conn.execute(
859
947
  "SELECT COUNT(*) FROM guard_log WHERE session_id = ?", (sid,)
860
948
  ).fetchone()[0]
@@ -913,7 +1001,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
913
1001
  # adaptive_log row per heartbeat. Wrapped in best-effort try/except so
914
1002
  # a failure here cannot block the heartbeat itself.
915
1003
  try:
916
- if context_hint and len(context_hint.strip()) >= 5:
1004
+ if _env_flag("NEXO_HEARTBEAT_ADAPTIVE_MODE", default=False) and context_hint and len(context_hint.strip()) >= 5:
917
1005
  from plugins.adaptive_mode import compute_mode
918
1006
  from cognitive._trust import detect_sentiment
919
1007
  sentiment = detect_sentiment(context_hint)
@@ -1257,7 +1345,9 @@ def _hint_suggests_correction(hint: str, *, correction_detector=None) -> bool:
1257
1345
  text = (hint or "").strip()
1258
1346
  if not text:
1259
1347
  return False
1260
- detector = correction_detector if correction_detector is not None else _detect_correction_semantic
1348
+ detector = correction_detector if correction_detector is not None else (
1349
+ _detect_correction_semantic if _env_flag("NEXO_HEARTBEAT_SEMANTIC_DETECTORS", default=False) else None
1350
+ )
1261
1351
  if detector is not None:
1262
1352
  try:
1263
1353
  if bool(detector(text)):