nexo-brain 7.20.20 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools_sessions.py +132 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.20",
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",
@@ -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)):