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 +1 -1
- package/src/cli.py +167 -0
- package/src/server.py +63 -9
- 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.
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
281
|
-
|
|
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
|
-
|
|
338
|
+
_load_startup_plugins()
|
|
285
339
|
|
|
286
340
|
|
|
287
341
|
mcp = FastMCP(
|
package/src/tools_sessions.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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 =
|
|
839
|
-
row =
|
|
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
|
|
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)):
|