nexo-brain 7.20.21 → 7.20.23

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.
@@ -8,24 +8,130 @@ if [ ! -d "$BACKUP_DIR" ] && [ -d "$NEXO_HOME/backups" ]; then
8
8
  fi
9
9
  WEEKLY_DIR="$BACKUP_DIR/weekly"
10
10
  DB="$NEXO_HOME/runtime/data/nexo.db"
11
- RETENTION_HOURS=48
11
+ LOCAL_CONTEXT_DB="$NEXO_HOME/runtime/memory/local-context.db"
12
+ LOCK_FILE="$NEXO_HOME/runtime/logs/local-index.lock"
13
+ RETENTION_HOURS="${NEXO_BACKUP_RETENTION_HOURS:-24}"
14
+ KEEP_LAST="${NEXO_BACKUP_KEEP_LAST:-3}"
15
+ FAMILY_KEEP_LAST="${NEXO_BACKUP_FAMILY_KEEP_LAST:-2}"
16
+ LOCAL_CONTEXT_RETENTION_HOURS="${NEXO_LOCAL_CONTEXT_BACKUP_RETENTION_HOURS:-24}"
17
+ LOCAL_CONTEXT_KEEP_LAST="${NEXO_LOCAL_CONTEXT_BACKUP_KEEP_LAST:-2}"
18
+ BUSY_TIMEOUT_MS="${NEXO_BACKUP_BUSY_TIMEOUT_MS:-5000}"
19
+ RECENT_BACKUP_HOURS="${NEXO_BACKUP_RECENT_BACKUP_HOURS:-6}"
12
20
 
13
21
  mkdir -p "$BACKUP_DIR" "$WEEKLY_DIR"
14
22
 
23
+ cleanup_backups() {
24
+ python3 - "$BACKUP_DIR" "$RETENTION_HOURS" "$KEEP_LAST" "$FAMILY_KEEP_LAST" "$LOCAL_CONTEXT_RETENTION_HOURS" "$LOCAL_CONTEXT_KEEP_LAST" <<'PY'
25
+ from __future__ import annotations
26
+
27
+ import shutil
28
+ import sys
29
+ import time
30
+ from pathlib import Path
31
+
32
+ base = Path(sys.argv[1])
33
+ retention_hours = max(1, int(sys.argv[2]))
34
+ keep_last = max(1, int(sys.argv[3]))
35
+ family_keep_last = max(1, int(sys.argv[4]))
36
+ local_context_retention_hours = max(1, int(sys.argv[5]))
37
+ local_context_keep_last = max(1, int(sys.argv[6]))
38
+ now = time.time()
39
+
40
+ def delete_path(path: Path) -> None:
41
+ try:
42
+ if path.is_dir():
43
+ shutil.rmtree(path)
44
+ else:
45
+ path.unlink()
46
+ except FileNotFoundError:
47
+ pass
48
+ except Exception as exc:
49
+ print(f"NEXO backup cleanup warning: {path}: {exc}", file=sys.stderr)
50
+
51
+ for tmp in base.glob("*.tmp.*"):
52
+ try:
53
+ if now - tmp.stat().st_mtime > 1800:
54
+ delete_path(tmp)
55
+ except FileNotFoundError:
56
+ pass
57
+
58
+ hourlies = sorted(base.glob("nexo-*.db"), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
59
+ for backup in hourlies[keep_last:]:
60
+ try:
61
+ age_hours = (now - backup.stat().st_mtime) / 3600
62
+ except FileNotFoundError:
63
+ continue
64
+ if age_hours > retention_hours:
65
+ delete_path(backup)
66
+
67
+ local_context_hourlies = sorted(
68
+ base.glob("local-context-*.db"),
69
+ key=lambda p: p.stat().st_mtime if p.exists() else 0,
70
+ reverse=True,
71
+ )
72
+ for backup in local_context_hourlies[local_context_keep_last:]:
73
+ try:
74
+ age_hours = (now - backup.stat().st_mtime) / 3600
75
+ except FileNotFoundError:
76
+ continue
77
+ if age_hours > local_context_retention_hours:
78
+ delete_path(backup)
79
+
80
+ for pattern in (
81
+ "pre-backfill-owner-*",
82
+ "pre-update-*",
83
+ "pre-autoupdate-*",
84
+ "pre-restore-*",
85
+ "app-reinstall-*",
86
+ "app-install-*",
87
+ "desktop-local-install-*",
88
+ "code-tree-*",
89
+ ):
90
+ entries = sorted(base.glob(pattern), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
91
+ for entry in entries[family_keep_last:]:
92
+ delete_path(entry)
93
+
94
+ weekly = base / "weekly"
95
+ if weekly.exists():
96
+ for backup in weekly.glob("weekly-*.db"):
97
+ try:
98
+ if now - backup.stat().st_mtime > 90 * 24 * 3600:
99
+ delete_path(backup)
100
+ except FileNotFoundError:
101
+ pass
102
+ PY
103
+ }
104
+
105
+ has_recent_backup() {
106
+ find "$BACKUP_DIR" -maxdepth 1 -name "nexo-*.db" -mmin "-$((RECENT_BACKUP_HOURS * 60))" -print -quit | grep -q .
107
+ }
108
+
109
+ has_recent_local_context_backup() {
110
+ find "$BACKUP_DIR" -maxdepth 1 -name "local-context-*.db" -mmin "-$((RECENT_BACKUP_HOURS * 60))" -print -quit | grep -q .
111
+ }
112
+
113
+ cleanup_backups
114
+
15
115
  # Hourly backup
16
116
  TIMESTAMP=$(date +%Y-%m-%d-%H%M)
17
117
  BACKUP_FILE="$BACKUP_DIR/nexo-$TIMESTAMP.db"
18
118
  TMP_BACKUP="$BACKUP_FILE.tmp.$$"
19
119
  rm -f "$TMP_BACKUP"
20
- if sqlite3 -cmd ".timeout 60000" "$DB" <<SQL
21
- PRAGMA busy_timeout=60000;
120
+ if sqlite3 -cmd ".timeout $BUSY_TIMEOUT_MS" "$DB" <<SQL
121
+ PRAGMA busy_timeout=$BUSY_TIMEOUT_MS;
22
122
  .backup '$TMP_BACKUP'
23
123
  SQL
24
124
  then
25
125
  mv "$TMP_BACKUP" "$BACKUP_FILE"
26
126
  else
27
127
  rm -f "$TMP_BACKUP"
28
- echo "NEXO backup failed: database busy or unavailable" >&2
128
+ if has_recent_backup; then
129
+ echo "NEXO backup skipped: database busy and a recent backup exists" >&2
130
+ cleanup_backups
131
+ exit 0
132
+ fi
133
+ echo "NEXO backup failed: database busy or unavailable and no recent backup exists" >&2
134
+ cleanup_backups
29
135
  exit 1
30
136
  fi
31
137
 
@@ -36,6 +142,24 @@ if [ ! -f "$WEEKLY_FILE" ] && [ "$(date +%u)" = "7" ] && [ -f "$BACKUP_FILE" ];
36
142
  cp "$BACKUP_FILE" "$WEEKLY_FILE"
37
143
  fi
38
144
 
39
- # Cleanup: hourly >48h, weekly >90 days
40
- find "$BACKUP_DIR" -maxdepth 1 -name "nexo-*.db" -mmin +$((RETENTION_HOURS * 60)) -delete
41
- find "$WEEKLY_DIR" -name "weekly-*.db" -mtime +90 -delete
145
+ # Local memory backup: separate and aggressively rotated so the index cannot
146
+ # block core DB backups or fill the disk with duplicate multi-GB snapshots.
147
+ if [ -f "$LOCAL_CONTEXT_DB" ]; then
148
+ LOCAL_CONTEXT_BACKUP_FILE="$BACKUP_DIR/local-context-$TIMESTAMP.db"
149
+ LOCAL_CONTEXT_TMP_BACKUP="$LOCAL_CONTEXT_BACKUP_FILE.tmp.$$"
150
+ rm -f "$LOCAL_CONTEXT_TMP_BACKUP"
151
+ if [ -f "$LOCK_FILE" ] && find "$LOCK_FILE" -mmin -30 -print -quit | grep -q . && has_recent_local_context_backup; then
152
+ echo "NEXO local memory backup skipped: index is active and a recent local backup exists"
153
+ elif sqlite3 -cmd ".timeout $BUSY_TIMEOUT_MS" "$LOCAL_CONTEXT_DB" <<SQL
154
+ PRAGMA busy_timeout=$BUSY_TIMEOUT_MS;
155
+ .backup '$LOCAL_CONTEXT_TMP_BACKUP'
156
+ SQL
157
+ then
158
+ mv "$LOCAL_CONTEXT_TMP_BACKUP" "$LOCAL_CONTEXT_BACKUP_FILE"
159
+ else
160
+ rm -f "$LOCAL_CONTEXT_TMP_BACKUP"
161
+ echo "NEXO local memory backup warning: local-context database busy or unavailable" >&2
162
+ fi
163
+ fi
164
+
165
+ cleanup_backups
package/src/server.py CHANGED
@@ -5,6 +5,7 @@ import os
5
5
  import signal
6
6
  import sys
7
7
  import json
8
+ import time
8
9
 
9
10
  from fastmcp import FastMCP
10
11
  from core_prompts import render_core_prompt
@@ -94,10 +95,15 @@ from tools_automation_sessions import (
94
95
  from plugins.cortex import handle_cortex_check
95
96
  from plugins.guard import handle_guard_check
96
97
  from plugins.protocol import (
98
+ handle_confidence_check,
99
+ handle_protocol_debt_resolve,
97
100
  handle_task_acknowledge_guard,
98
101
  handle_task_close,
99
102
  handle_task_open,
100
103
  )
104
+ from plugins.episodic_memory import handle_session_diary_read
105
+ from plugins.cards import handle_card_match
106
+ from plugins.skills import handle_skill_match
101
107
  from plugins.workflow import (
102
108
  handle_workflow_open,
103
109
  handle_workflow_update,
@@ -112,10 +118,12 @@ from runtime_versioning import (
112
118
  prime_process_version,
113
119
  )
114
120
  from local_context import api as local_context_api
121
+ from local_context.db import close_local_context_db
115
122
 
116
123
 
117
124
  # ── Graceful shutdown: close DB on any termination signal ──────────
118
125
  def _shutdown_handler(signum, frame):
126
+ close_local_context_db()
119
127
  close_db()
120
128
  sys.exit(0)
121
129
 
@@ -184,11 +192,23 @@ def _restore_valid_db_backup() -> bool:
184
192
  def _init_db_or_exit() -> None:
185
193
  import sqlite3
186
194
 
187
- try:
188
- init_db()
189
- return
190
- except sqlite3.DatabaseError as exc:
191
- print(f"[NEXO] DB init failed: {exc}", file=sys.stderr)
195
+ for attempt in range(3):
196
+ try:
197
+ init_db()
198
+ return
199
+ except sqlite3.OperationalError as exc:
200
+ message = str(exc).lower()
201
+ if "locked" in message or "busy" in message:
202
+ if attempt < 2:
203
+ time.sleep(0.25 * (attempt + 1))
204
+ continue
205
+ print(f"[NEXO] DB init temporarily busy: {exc}", file=sys.stderr)
206
+ raise SystemExit(75)
207
+ print(f"[NEXO] DB init failed: {exc}", file=sys.stderr)
208
+ break
209
+ except sqlite3.DatabaseError as exc:
210
+ print(f"[NEXO] DB init failed: {exc}", file=sys.stderr)
211
+ break
192
212
 
193
213
  restored = False
194
214
  try:
@@ -439,6 +459,76 @@ def nexo_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
439
459
  return handle_heartbeat(sid, task, context_hint)
440
460
 
441
461
 
462
+ @mcp.tool
463
+ def nexo_session_diary_read(
464
+ session_id: str = "",
465
+ last_n: int = 3,
466
+ last_day: bool = False,
467
+ domain: str = "",
468
+ brief: bool = False,
469
+ ) -> str:
470
+ """Read recent session diaries for context continuity."""
471
+ return handle_session_diary_read(session_id, last_n, last_day, domain, brief)
472
+
473
+
474
+ @mcp.tool
475
+ def nexo_confidence_check(
476
+ goal: str,
477
+ task_type: str = "answer",
478
+ area: str = "",
479
+ context_hint: str = "",
480
+ constraints: str = "[]",
481
+ evidence_refs: str = "[]",
482
+ unknowns: str = "[]",
483
+ verification_step: str = "",
484
+ stakes: str = "",
485
+ ) -> str:
486
+ """Decide whether an answer should proceed directly or be verified first."""
487
+ return handle_confidence_check(
488
+ goal,
489
+ task_type,
490
+ area,
491
+ context_hint,
492
+ constraints,
493
+ evidence_refs,
494
+ unknowns,
495
+ verification_step,
496
+ stakes,
497
+ )
498
+
499
+
500
+ @mcp.tool
501
+ def nexo_protocol_debt_resolve(
502
+ debt_ids: str = "",
503
+ task_id: str = "",
504
+ session_id: str = "",
505
+ debt_types: str = "",
506
+ resolution: str = "",
507
+ debt_id: str = "",
508
+ ) -> str:
509
+ """Resolve protocol debt records by id or filters."""
510
+ return handle_protocol_debt_resolve(debt_ids, task_id, session_id, debt_types, resolution, debt_id)
511
+
512
+
513
+ @mcp.tool
514
+ def nexo_card_match(
515
+ query: str,
516
+ limit: int = 5,
517
+ include_protocol: bool = True,
518
+ locale: str = "es",
519
+ category: str = "",
520
+ business_type: str = "",
521
+ ) -> str:
522
+ """Find official NEXO protocol cards for a user request."""
523
+ return handle_card_match(query, limit, include_protocol, locale, category, business_type)
524
+
525
+
526
+ @mcp.tool
527
+ def nexo_skill_match(task: str, level: str = "") -> str:
528
+ """Find reusable NEXO skills for the current task."""
529
+ return handle_skill_match(task, level)
530
+
531
+
442
532
  @mcp.tool
443
533
  def nexo_stop(sid: str) -> str:
444
534
  """Cleanly close a session. Removes it from active sessions immediately.
@@ -720,7 +810,7 @@ def nexo_local_index_roots(action: str = "list", path: str = "", mode: str = "no
720
810
  """List, add or remove local memory roots."""
721
811
  normalized = str(action or "list").strip().lower()
722
812
  if normalized == "list":
723
- result = {"ok": True, "roots": local_context_api.list_roots()}
813
+ result = {"ok": True, "roots": local_context_api.list_roots(readonly=True)}
724
814
  elif normalized == "add":
725
815
  result = local_context_api.add_root(path, mode=mode, depth=depth)
726
816
  elif normalized == "remove":
@@ -735,7 +825,7 @@ def nexo_local_index_exclusions(action: str = "list", path: str = "", reason: st
735
825
  """List, add or remove local memory exclusions."""
736
826
  normalized = str(action or "list").strip().lower()
737
827
  if normalized == "list":
738
- result = {"ok": True, "exclusions": local_context_api.list_exclusions()}
828
+ result = {"ok": True, "exclusions": local_context_api.list_exclusions(readonly=True)}
739
829
  elif normalized == "add":
740
830
  result = local_context_api.add_exclusion(path, reason=reason)
741
831
  elif normalized == "remove":
@@ -2,6 +2,7 @@
2
2
 
3
3
  from db import get_reminders, get_followups
4
4
  from datetime import date
5
+ from interactive_db import interactive_db_timeout, is_db_busy
5
6
 
6
7
 
7
8
  def _is_due(date_str: str) -> bool:
@@ -22,21 +23,49 @@ def handle_reminders(filter_type: str = "due") -> str:
22
23
  filter_type: 'due', 'all', 'followups', 'completed', 'deleted', 'history', 'any'
23
24
  """
24
25
  parts = []
26
+ warnings: list[str] = []
25
27
 
26
- if filter_type in ("due", "all", "completed", "deleted", "history", "any"):
27
- r = _format_reminders(filter_type)
28
- if r:
29
- parts.append(r)
28
+ with interactive_db_timeout():
29
+ if filter_type in ("due", "all", "completed", "deleted", "history", "any"):
30
+ r = _format_reminders_safe(filter_type, warnings)
31
+ if r:
32
+ parts.append(r)
30
33
 
31
- if filter_type in ("due", "all", "followups", "completed", "deleted", "history", "any"):
32
- f = _format_followups(filter_type)
33
- if f:
34
- parts.append(f)
34
+ if filter_type in ("due", "all", "followups", "completed", "deleted", "history", "any"):
35
+ f = _format_followups_safe(filter_type, warnings)
36
+ if f:
37
+ parts.append(f)
35
38
 
36
39
  result = "\n\n".join(parts)
40
+ if warnings:
41
+ prefix = "REMINDERS DEGRADED:\n " + "\n ".join(warnings)
42
+ prefix += "\n Continue with the user request; reminders will catch up shortly."
43
+ return f"{prefix}\n\n{result}" if result else prefix
37
44
  return result if result else "No pending reminders."
38
45
 
39
46
 
47
+ def _format_reminders_safe(filter_type: str, warnings: list[str]) -> str:
48
+ try:
49
+ return _format_reminders(filter_type)
50
+ except Exception as exc:
51
+ if is_db_busy(exc):
52
+ warnings.append("reminders skipped because the local brain database is busy")
53
+ else:
54
+ warnings.append(f"reminders skipped ({type(exc).__name__})")
55
+ return ""
56
+
57
+
58
+ def _format_followups_safe(filter_type: str, warnings: list[str]) -> str:
59
+ try:
60
+ return _format_followups(filter_type)
61
+ except Exception as exc:
62
+ if is_db_busy(exc):
63
+ warnings.append("followups skipped because the local brain database is busy")
64
+ else:
65
+ warnings.append(f"followups skipped ({type(exc).__name__})")
66
+ return ""
67
+
68
+
40
69
  def _format_reminders(filter_type: str) -> str:
41
70
  """Format reminders from database."""
42
71
  rows = get_reminders(filter_type)
@@ -425,17 +425,6 @@ def handle_startup(
425
425
  sid = _generate_sid()
426
426
  startup_warnings: list[str] = []
427
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)
439
428
  linked_session_id = (session_token or claude_session_id or "").strip()
440
429
  # v6.0.7 hotfix: when the caller did not pass an explicit UUID, fall back to
441
430
  # the Claude Code SessionStart UUID written by the SessionStart hook to
@@ -485,9 +474,12 @@ def handle_startup(
485
474
  startup_warnings,
486
475
  )
487
476
  memory_maintenance = None
488
- if _env_flag("NEXO_MEMORY_MAINTENANCE_IN_STARTUP", default=False):
477
+ try:
478
+ backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "0") or "0")
479
+ except Exception:
480
+ backfill_limit = 0
481
+ if _env_flag("NEXO_MEMORY_MAINTENANCE_IN_STARTUP", default=False) or backfill_limit > 0:
489
482
  try:
490
- backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "0") or "0")
491
483
  memory_maintenance = maintain_memory_observations(
492
484
  process_limit=int(os.environ.get("NEXO_MEMORY_STARTUP_PROCESS_LIMIT", "20") or "20"),
493
485
  retry_failed=True,
@@ -794,7 +786,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
794
786
  if bundle.get("has_matches"):
795
787
  parts.append("")
796
788
  parts.append(format_pre_action_context_bundle(bundle, compact=True))
797
- if _env_flag("NEXO_HEARTBEAT_LOCAL_CONTEXT", default=False) and append_local_context_evidence is not None:
789
+ if _env_flag("NEXO_HEARTBEAT_LOCAL_CONTEXT", default=True) and append_local_context_evidence is not None:
798
790
  local_rendered = append_local_context_evidence("", recent_query, limit=4).strip()
799
791
  if local_rendered:
800
792
  parts.append("")
@@ -895,7 +887,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
895
887
 
896
888
  # ── Drive/Curiosity: detect signals from context_hint (best-effort) ──
897
889
  try:
898
- if _env_flag("NEXO_DRIVE_IN_HEARTBEAT", default=False) and context_hint and len(context_hint.strip()) >= 15:
890
+ if _env_flag("NEXO_DRIVE_IN_HEARTBEAT", default=True) and context_hint and len(context_hint.strip()) >= 15:
899
891
  from tools_drive import detect_drive_signal as _detect_drive
900
892
  _drive_allow_llm = _env_flag("NEXO_DRIVE_LLM_IN_HEARTBEAT", default=False)
901
893
  _drive_result = _detect_drive(
@@ -1001,7 +993,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
1001
993
  # adaptive_log row per heartbeat. Wrapped in best-effort try/except so
1002
994
  # a failure here cannot block the heartbeat itself.
1003
995
  try:
1004
- if _env_flag("NEXO_HEARTBEAT_ADAPTIVE_MODE", default=False) and context_hint and len(context_hint.strip()) >= 5:
996
+ if _env_flag("NEXO_HEARTBEAT_ADAPTIVE_MODE", default=True) and context_hint and len(context_hint.strip()) >= 5:
1005
997
  from plugins.adaptive_mode import compute_mode
1006
998
  from cognitive._trust import detect_sentiment
1007
999
  sentiment = detect_sentiment(context_hint)
@@ -1345,9 +1337,7 @@ def _hint_suggests_correction(hint: str, *, correction_detector=None) -> bool:
1345
1337
  text = (hint or "").strip()
1346
1338
  if not text:
1347
1339
  return False
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
- )
1340
+ detector = correction_detector if correction_detector is not None else _detect_correction_semantic
1351
1341
  if detector is not None:
1352
1342
  try:
1353
1343
  if bool(detector(text)):
@@ -1386,6 +1376,8 @@ def _hint_suggests_correction(hint: str, *, correction_detector=None) -> bool:
1386
1376
 
1387
1377
  def _recent_learning_capture_exists(conn, sid: str, window_seconds: int = 300) -> bool:
1388
1378
  """Check whether a recent learning was captured manually or via protocol task close."""
1379
+ if conn is None:
1380
+ conn = get_db()
1389
1381
  cutoff_epoch = time.time() - window_seconds
1390
1382
 
1391
1383
  row = conn.execute(