nexo-brain 7.31.1 → 7.31.3
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/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/bin/nexo-brain.js +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +2 -2
- package/src/client_sync.py +1 -1
- package/src/db/_core.py +9 -1
- package/src/db/_sessions.py +46 -6
- package/src/model_defaults.json +12 -5
- package/src/model_defaults.py +2 -2
- package/src/resonance_tiers.json +4 -4
- package/src/runtime_service.py +147 -6
- package/src/server.py +10 -0
- package/src/hooks/heartbeat-enforcement.py +0 -103
- package/src/hooks/heartbeat-posttool.sh +0 -20
- package/src/hooks/heartbeat-user-msg.sh +0 -17
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.31.
|
|
3
|
+
"version": "7.31.3",
|
|
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,9 +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.31.
|
|
21
|
+
Version `7.31.3` is the current packaged-runtime line. Patch release over v7.31.2 - the recommended Claude Code model returns to Opus 4.8 with max reasoning (installs riding NEXO defaults migrate back automatically; customized models untouched), and the dead heartbeat-enforcement hook trio is removed from the source tree.
|
|
22
22
|
|
|
23
|
-
Previously in `7.31.
|
|
23
|
+
Previously in `7.31.2`: patch release over v7.31.1 - the session ID becomes a durable identity (sessions survive quiet work periods; physical cleanup at 24h), runtime residents are isolated per generation so two installs can never kill each other's resident, and obsolete residents retire themselves once clients disconnect.
|
|
24
24
|
|
|
25
25
|
Previously in `7.30.33`: patch release over v7.30.32 - personal agent/script status now keeps the newest real run between manual executions and cron history, so a successful manual agent run cannot be hidden behind an older scheduled failure.
|
|
26
26
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -115,7 +115,7 @@ const PUBLIC_CONTRIBUTION_UPSTREAM = "wazionapps/nexo";
|
|
|
115
115
|
const MODEL_DEFAULTS_PATH = path.join(__dirname, "..", "src", "model_defaults.json");
|
|
116
116
|
function _loadModelDefaults() {
|
|
117
117
|
const fallback = {
|
|
118
|
-
claude_code: { model: "claude-
|
|
118
|
+
claude_code: { model: "claude-opus-4-8", reasoning_effort: "max", display_name: "Opus 4.8 with max reasoning" },
|
|
119
119
|
codex: { model: "gpt-5.5", reasoning_effort: "xhigh", display_name: "GPT-5.5 with max reasoning" },
|
|
120
120
|
};
|
|
121
121
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.31.
|
|
3
|
+
"version": "7.31.3",
|
|
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/auto_update.py
CHANGED
|
@@ -2058,7 +2058,7 @@ def _refresh_resonance_tiers_model_defaults(dest: Path = NEXO_HOME) -> list[str]
|
|
|
2058
2058
|
dest / "personal" / "brain" / "resonance_tiers.json",
|
|
2059
2059
|
dest / "brain" / "resonance_tiers.json",
|
|
2060
2060
|
]
|
|
2061
|
-
old_prefixes = ("claude-opus-4-6", "claude-opus-4-7", "claude-
|
|
2061
|
+
old_prefixes = ("claude-opus-4-6", "claude-opus-4-7", "claude-fable-5")
|
|
2062
2062
|
|
|
2063
2063
|
for target_path in target_paths:
|
|
2064
2064
|
try:
|
|
@@ -2089,7 +2089,7 @@ def _refresh_resonance_tiers_model_defaults(dest: Path = NEXO_HOME) -> list[str]
|
|
|
2089
2089
|
model = str(claude.get("model") or "").strip()
|
|
2090
2090
|
tier_changed = False
|
|
2091
2091
|
if model and model.startswith(old_prefixes):
|
|
2092
|
-
claude["model"] = str(source_claude.get("model") or "claude-
|
|
2092
|
+
claude["model"] = str(source_claude.get("model") or "claude-opus-4-8")
|
|
2093
2093
|
tier_changed = True
|
|
2094
2094
|
if tier_changed and not str(claude.get("effort") or "").strip() and source_claude.get("effort"):
|
|
2095
2095
|
claude["effort"] = str(source_claude.get("effort"))
|
package/src/client_sync.py
CHANGED
|
@@ -83,7 +83,7 @@ except Exception:
|
|
|
83
83
|
|
|
84
84
|
def resolve_client_runtime_profile(client: str, preferences: dict | None = None) -> dict:
|
|
85
85
|
defaults = {
|
|
86
|
-
"claude_code": {"model": "claude-
|
|
86
|
+
"claude_code": {"model": "claude-opus-4-8", "reasoning_effort": "max"},
|
|
87
87
|
"codex": {"model": "gpt-5.5", "reasoning_effort": "xhigh"},
|
|
88
88
|
}
|
|
89
89
|
return dict(defaults.get(client, {}))
|
package/src/db/_core.py
CHANGED
|
@@ -36,10 +36,18 @@ _data_dir = os.path.dirname(DB_PATH)
|
|
|
36
36
|
os.makedirs(_data_dir, exist_ok=True)
|
|
37
37
|
|
|
38
38
|
# TTLs in seconds (match session-coord.sh behavior)
|
|
39
|
-
SESSION_STALE_SECONDS = 900 # 15 min (documented TTL)
|
|
39
|
+
SESSION_STALE_SECONDS = 900 # 15 min (documented TTL) — visibility horizon only
|
|
40
40
|
MESSAGE_TTL_SECONDS = 3600 # 1 hour
|
|
41
41
|
QUESTION_TTL_SECONDS = 600 # 10 min
|
|
42
42
|
|
|
43
|
+
# Phase 2.1 — purge horizon, deliberately FAR above the visibility TTL.
|
|
44
|
+
# SESSION_STALE_SECONDS only governs what counts as "active" in listings;
|
|
45
|
+
# physically DELETING rows at 15 min destroyed the continuity of any session
|
|
46
|
+
# that spent >15 min in code tools without touching a nexo_* tool (incident
|
|
47
|
+
# 10-jun: two working sessions lost mid-task, orphaning their open protocol
|
|
48
|
+
# tasks). Rows now survive 24h so revival/heartbeat can find them.
|
|
49
|
+
SESSION_PURGE_SECONDS = 24 * 3600
|
|
50
|
+
|
|
43
51
|
# Single shared connection per process with write serialization.
|
|
44
52
|
# SQLite allows only one writer at a time. Using a shared connection with
|
|
45
53
|
# check_same_thread=False and a write lock ensures:
|
package/src/db/_sessions.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
"""NEXO DB — Sessions module."""
|
|
3
3
|
import time, secrets, string, sqlite3
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from db._core import get_db, _gen_id, now_epoch, local_time_str, SESSION_STALE_SECONDS, MESSAGE_TTL_SECONDS, QUESTION_TTL_SECONDS
|
|
5
|
+
from db._core import get_db, _gen_id, now_epoch, local_time_str, SESSION_STALE_SECONDS, SESSION_PURGE_SECONDS, MESSAGE_TTL_SECONDS, QUESTION_TTL_SECONDS
|
|
6
6
|
|
|
7
7
|
# ── Session operations ──────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -166,9 +166,17 @@ def get_active_sessions() -> list[dict]:
|
|
|
166
166
|
|
|
167
167
|
|
|
168
168
|
def clean_stale_sessions() -> int:
|
|
169
|
-
"""
|
|
169
|
+
"""Purge sessions older than the PURGE horizon. Returns count removed.
|
|
170
|
+
|
|
171
|
+
Phase 2.1 — this used to delete at SESSION_STALE_SECONDS (15 min), which
|
|
172
|
+
destroyed any session that worked quietly in code tools for a while: the
|
|
173
|
+
next session/cron to start would erase it, its next nexo_track failed
|
|
174
|
+
with "Session not found" and its open protocol tasks were orphaned.
|
|
175
|
+
Deletion now happens at SESSION_PURGE_SECONDS (24h); the 15-min TTL keeps
|
|
176
|
+
governing VISIBILITY (get_active_sessions/search_sessions) unchanged.
|
|
177
|
+
"""
|
|
170
178
|
conn = get_db()
|
|
171
|
-
cutoff = now_epoch() -
|
|
179
|
+
cutoff = now_epoch() - SESSION_PURGE_SECONDS
|
|
172
180
|
stale = conn.execute(
|
|
173
181
|
"SELECT sid FROM sessions WHERE last_update_epoch <= ?", (cutoff,)
|
|
174
182
|
).fetchall()
|
|
@@ -309,13 +317,42 @@ def search_sessions(keyword: str) -> list[dict]:
|
|
|
309
317
|
|
|
310
318
|
# ── File tracking ───────────────────────────────────────────────────
|
|
311
319
|
|
|
320
|
+
def revive_session(sid: str, task_hint: str = "(revived session)") -> bool:
|
|
321
|
+
"""Phase 2.1 — re-create a session row for a valid SID that vanished.
|
|
322
|
+
|
|
323
|
+
A session can disappear legitimately (purge horizon, manual cleanup,
|
|
324
|
+
DB swap) while its client keeps working with the same SID. The durable
|
|
325
|
+
identity is the SID, not the row: revive it instead of erroring, so the
|
|
326
|
+
"Session not found. Register first." class of breakage cannot occur.
|
|
327
|
+
Returns True when a row was actually (re)created.
|
|
328
|
+
"""
|
|
329
|
+
sid = _validate_sid(sid)
|
|
330
|
+
conn = get_db()
|
|
331
|
+
now = now_epoch()
|
|
332
|
+
cursor = conn.execute(
|
|
333
|
+
"INSERT OR IGNORE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time) "
|
|
334
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
335
|
+
(sid, task_hint, now, now, local_time_str()),
|
|
336
|
+
)
|
|
337
|
+
conn.commit()
|
|
338
|
+
return cursor.rowcount > 0
|
|
339
|
+
|
|
340
|
+
|
|
312
341
|
def track_files(sid: str, paths: list[str]) -> dict:
|
|
313
|
-
"""Track files for a session. Returns conflicts if any.
|
|
342
|
+
"""Track files for a session. Returns conflicts if any.
|
|
343
|
+
|
|
344
|
+
Phase 2.1 — a valid SID whose row vanished is REVIVED instead of being
|
|
345
|
+
told "Session not found. Register first." (the heartbeat already revived
|
|
346
|
+
missing sessions; this layer was internally inconsistent). The result
|
|
347
|
+
carries ``revived: True`` so callers can log the recovery.
|
|
348
|
+
"""
|
|
349
|
+
sid = _validate_sid(sid)
|
|
314
350
|
conn = get_db()
|
|
315
351
|
now = now_epoch()
|
|
352
|
+
revived = False
|
|
316
353
|
session = conn.execute("SELECT sid FROM sessions WHERE sid = ?", (sid,)).fetchone()
|
|
317
354
|
if not session:
|
|
318
|
-
|
|
355
|
+
revived = revive_session(sid, task_hint="(revived by nexo_track)")
|
|
319
356
|
|
|
320
357
|
for path in paths:
|
|
321
358
|
conn.execute(
|
|
@@ -324,7 +361,10 @@ def track_files(sid: str, paths: list[str]) -> dict:
|
|
|
324
361
|
)
|
|
325
362
|
conn.commit()
|
|
326
363
|
conflicts = _check_conflicts(conn, sid)
|
|
327
|
-
|
|
364
|
+
result = {"tracked": paths, "conflicts": conflicts}
|
|
365
|
+
if revived:
|
|
366
|
+
result["revived"] = True
|
|
367
|
+
return result
|
|
328
368
|
|
|
329
369
|
|
|
330
370
|
def untrack_files(sid: str, paths: list[str] | None = None):
|
package/src/model_defaults.json
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"claude_code": {
|
|
4
|
-
"model": "claude-
|
|
4
|
+
"model": "claude-opus-4-8",
|
|
5
5
|
"reasoning_effort": "max",
|
|
6
|
-
"display_name": "
|
|
7
|
-
"recommendation_version":
|
|
8
|
-
"previous_defaults": [
|
|
6
|
+
"display_name": "Opus 4.8 with max reasoning",
|
|
7
|
+
"recommendation_version": 5,
|
|
8
|
+
"previous_defaults": [
|
|
9
|
+
"claude-fable-5",
|
|
10
|
+
"claude-opus-4-7[1m]",
|
|
11
|
+
"claude-opus-4-7",
|
|
12
|
+
"claude-opus-4-6[1m]"
|
|
13
|
+
]
|
|
9
14
|
},
|
|
10
15
|
"codex": {
|
|
11
16
|
"model": "gpt-5.5",
|
|
12
17
|
"reasoning_effort": "xhigh",
|
|
13
18
|
"display_name": "GPT-5.5 with max reasoning",
|
|
14
19
|
"recommendation_version": 2,
|
|
15
|
-
"previous_defaults": [
|
|
20
|
+
"previous_defaults": [
|
|
21
|
+
"gpt-5.4"
|
|
22
|
+
]
|
|
16
23
|
}
|
|
17
24
|
}
|
package/src/model_defaults.py
CHANGED
|
@@ -20,7 +20,7 @@ from typing import Any
|
|
|
20
20
|
_FALLBACK: dict[str, Any] = {
|
|
21
21
|
"schema_version": 1,
|
|
22
22
|
"claude_code": {
|
|
23
|
-
"model": "claude-
|
|
23
|
+
"model": "claude-opus-4-8",
|
|
24
24
|
"reasoning_effort": "max",
|
|
25
25
|
"display_name": "Fable 5 with max reasoning",
|
|
26
26
|
"recommendation_version": 4,
|
|
@@ -99,7 +99,7 @@ def looks_like_claude_model(model: str) -> bool:
|
|
|
99
99
|
return str(model or "").strip().lower().startswith(_CLAUDE_MODEL_PREFIXES)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
_CLAUDE_DEFAULT_PREFIXES = ("claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8")
|
|
102
|
+
_CLAUDE_DEFAULT_PREFIXES = ("claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-fable-5")
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
|
package/src/resonance_tiers.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"tiers": {
|
|
3
3
|
"maximo": {
|
|
4
|
-
"claude_code": { "model": "claude-
|
|
4
|
+
"claude_code": { "model": "claude-opus-4-8", "effort": "max" },
|
|
5
5
|
"codex": { "model": "gpt-5.5", "effort": "xhigh" }
|
|
6
6
|
},
|
|
7
7
|
"alto": {
|
|
8
|
-
"claude_code": { "model": "claude-
|
|
8
|
+
"claude_code": { "model": "claude-opus-4-8", "effort": "xhigh" },
|
|
9
9
|
"codex": { "model": "gpt-5.5", "effort": "high" }
|
|
10
10
|
},
|
|
11
11
|
"medio": {
|
|
12
|
-
"claude_code": { "model": "claude-
|
|
12
|
+
"claude_code": { "model": "claude-opus-4-8", "effort": "high" },
|
|
13
13
|
"codex": { "model": "gpt-5.5", "effort": "medium" }
|
|
14
14
|
},
|
|
15
15
|
"bajo": {
|
|
16
|
-
"claude_code": { "model": "claude-
|
|
16
|
+
"claude_code": { "model": "claude-opus-4-8", "effort": "medium" },
|
|
17
17
|
"codex": { "model": "gpt-5.5", "effort": "low" }
|
|
18
18
|
},
|
|
19
19
|
"muy_bajo": {
|
package/src/runtime_service.py
CHANGED
|
@@ -34,6 +34,16 @@ STATE_FILE = "runtime-service.json"
|
|
|
34
34
|
LOCK_FILE = "runtime-service.lock"
|
|
35
35
|
LOG_FILE = "runtime-service.log"
|
|
36
36
|
|
|
37
|
+
# Phase 2.1/2.2 — state isolation per runtime generation. Two different Brain
|
|
38
|
+
# installs (e.g. the managed ~/.nexo/core runtime and an npm-global one)
|
|
39
|
+
# used to share ONE state file: each side saw a "stale_runtime" resident and
|
|
40
|
+
# KILLED the other's in an endless ping-pong (1,314 resident restarts logged
|
|
41
|
+
# on the operator machine; every restart forced the next conversation to pay
|
|
42
|
+
# a 10-48s cold Brain boot and expired client sessions). With the state file
|
|
43
|
+
# keyed by runtime generation, a runtime can only ever see — and manage —
|
|
44
|
+
# its OWN resident. Foreign residents become invisible instead of killable.
|
|
45
|
+
|
|
46
|
+
|
|
37
47
|
|
|
38
48
|
def env_flag(name: str, *, default: bool = False) -> bool:
|
|
39
49
|
value = os.environ.get(name)
|
|
@@ -55,12 +65,29 @@ def service_url(host: str | None = None, port: int | None = None, path: str | No
|
|
|
55
65
|
return f"http://{host or service_host()}:{int(port or service_port())}{path or service_path()}"
|
|
56
66
|
|
|
57
67
|
|
|
58
|
-
def
|
|
68
|
+
def _generation_state_token(generation: str) -> str:
|
|
69
|
+
"""Stable filesystem-safe token for a runtime generation."""
|
|
70
|
+
text = str(generation or "unknown").strip() or "unknown"
|
|
71
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _current_generation_token() -> str:
|
|
75
|
+
return _generation_state_token(current_runtime_identity().get("runtime_generation", "unknown"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _legacy_service_state_path() -> Path:
|
|
59
79
|
root = paths.runtime_state_dir()
|
|
60
80
|
root.mkdir(parents=True, exist_ok=True)
|
|
61
81
|
return root / STATE_FILE
|
|
62
82
|
|
|
63
83
|
|
|
84
|
+
def service_state_path() -> Path:
|
|
85
|
+
root = paths.runtime_state_dir()
|
|
86
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
token = _current_generation_token()
|
|
88
|
+
return root / f"runtime-service-{token}.json"
|
|
89
|
+
|
|
90
|
+
|
|
64
91
|
def service_log_path() -> Path:
|
|
65
92
|
root = paths.logs_dir()
|
|
66
93
|
root.mkdir(parents=True, exist_ok=True)
|
|
@@ -70,7 +97,8 @@ def service_log_path() -> Path:
|
|
|
70
97
|
def service_lock_path() -> Path:
|
|
71
98
|
root = paths.runtime_state_dir()
|
|
72
99
|
root.mkdir(parents=True, exist_ok=True)
|
|
73
|
-
|
|
100
|
+
token = _current_generation_token()
|
|
101
|
+
return root / f"runtime-service-{token}.lock"
|
|
74
102
|
|
|
75
103
|
|
|
76
104
|
@contextmanager
|
|
@@ -128,10 +156,22 @@ def service_start_lock(*, timeout: float = 10.0):
|
|
|
128
156
|
def read_service_state() -> dict[str, Any]:
|
|
129
157
|
try:
|
|
130
158
|
path = service_state_path()
|
|
131
|
-
if
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
159
|
+
if path.is_file():
|
|
160
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
161
|
+
return data if isinstance(data, dict) else {}
|
|
162
|
+
# Phase 2.1 — one-time soft migration: adopt a pre-generation legacy
|
|
163
|
+
# state file ONLY if it belongs to this same runtime. A foreign
|
|
164
|
+
# install's legacy state stays invisible (never "stale to kill").
|
|
165
|
+
legacy = _legacy_service_state_path()
|
|
166
|
+
if legacy.is_file():
|
|
167
|
+
data = json.loads(legacy.read_text(encoding="utf-8"))
|
|
168
|
+
if isinstance(data, dict) and state_matches_current_runtime(data):
|
|
169
|
+
try:
|
|
170
|
+
legacy.replace(path)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
return data
|
|
174
|
+
return {}
|
|
135
175
|
except Exception:
|
|
136
176
|
return {}
|
|
137
177
|
|
|
@@ -445,6 +485,107 @@ def runtime_service_status() -> dict[str, Any]:
|
|
|
445
485
|
}
|
|
446
486
|
|
|
447
487
|
|
|
488
|
+
# Phase 2.1/2.2 — resident obsolescence watch.
|
|
489
|
+
#
|
|
490
|
+
# With per-generation state files, residents no longer kill each other; the
|
|
491
|
+
# missing piece is cleanup: after a runtime update, the OLD resident must
|
|
492
|
+
# eventually exit, while the CURRENT one must stay warm forever (a hot Brain
|
|
493
|
+
# is what turns 10-48s conversation starts into fast ones). Rules:
|
|
494
|
+
# - a resident whose on-disk runtime generation still matches its own NEVER
|
|
495
|
+
# self-terminates, idle or not;
|
|
496
|
+
# - an OBSOLETE resident (disk generation changed under it) exits cleanly
|
|
497
|
+
# once it has had no established client connections for two consecutive
|
|
498
|
+
# checks (anti-flap), removing its state file on the way out;
|
|
499
|
+
# - if connections cannot be counted (no lsof/netstat), it stays alive —
|
|
500
|
+
# fail-safe towards living.
|
|
501
|
+
|
|
502
|
+
OBSOLESCENCE_CHECK_SECONDS = 300
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _count_established_connections(port: int) -> int | None:
|
|
506
|
+
"""Best-effort count of ESTABLISHED TCP connections to ``port``.
|
|
507
|
+
|
|
508
|
+
Returns None when it cannot tell (missing tooling) so callers can fail
|
|
509
|
+
safe. Uses lsof on POSIX and netstat on Windows; both ship with the OS.
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
if os.name == "nt":
|
|
513
|
+
out = subprocess.run(
|
|
514
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
515
|
+
capture_output=True, text=True, timeout=10,
|
|
516
|
+
).stdout
|
|
517
|
+
needle = f":{port} "
|
|
518
|
+
return sum(
|
|
519
|
+
1 for line in out.splitlines()
|
|
520
|
+
if "ESTABLISHED" in line and needle in line.split("ESTABLISHED")[0]
|
|
521
|
+
)
|
|
522
|
+
out = subprocess.run(
|
|
523
|
+
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:ESTABLISHED"],
|
|
524
|
+
capture_output=True, text=True, timeout=10,
|
|
525
|
+
).stdout
|
|
526
|
+
rows = [line for line in out.splitlines() if "ESTABLISHED" in line]
|
|
527
|
+
# lsof lists both directions of loopback pairs; the resident's own
|
|
528
|
+
# accept side is one row per client connection.
|
|
529
|
+
return len(rows)
|
|
530
|
+
except Exception:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _resident_is_obsolete(boot_generation: str) -> bool:
|
|
535
|
+
try:
|
|
536
|
+
from runtime_versioning import compute_mcp_runtime_fingerprint, read_version_for_path, runtime_generation
|
|
537
|
+
|
|
538
|
+
root = current_server_path().parent
|
|
539
|
+
version = read_version_for_path(root) or read_version_for_path(root.parent)
|
|
540
|
+
fingerprint = compute_mcp_runtime_fingerprint(root, use_cache=False)
|
|
541
|
+
current = runtime_generation(version, fingerprint, str(root))
|
|
542
|
+
return bool(boot_generation) and bool(current) and current != boot_generation
|
|
543
|
+
except Exception:
|
|
544
|
+
return False # cannot tell -> assume still current (fail-safe)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def start_resident_obsolescence_watch(*, port: int, on_exit=None) -> None:
|
|
548
|
+
"""Spawn the daemon thread that retires obsolete residents gracefully."""
|
|
549
|
+
import threading
|
|
550
|
+
|
|
551
|
+
boot_generation = str(current_runtime_identity().get("runtime_generation") or "")
|
|
552
|
+
|
|
553
|
+
def _watch() -> None:
|
|
554
|
+
strikes = 0
|
|
555
|
+
while True:
|
|
556
|
+
time.sleep(OBSOLESCENCE_CHECK_SECONDS)
|
|
557
|
+
try:
|
|
558
|
+
if not _resident_is_obsolete(boot_generation):
|
|
559
|
+
strikes = 0
|
|
560
|
+
continue
|
|
561
|
+
connections = _count_established_connections(port)
|
|
562
|
+
if connections is None or connections > 0:
|
|
563
|
+
strikes = 0
|
|
564
|
+
continue
|
|
565
|
+
strikes += 1
|
|
566
|
+
if strikes < 2:
|
|
567
|
+
continue
|
|
568
|
+
print(
|
|
569
|
+
f"[runtime-service] obsolete resident (gen {boot_generation[:24]}…) idle for two checks — exiting cleanly",
|
|
570
|
+
file=sys.stderr,
|
|
571
|
+
)
|
|
572
|
+
try:
|
|
573
|
+
service_state_path().unlink(missing_ok=True)
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
if callable(on_exit):
|
|
577
|
+
try:
|
|
578
|
+
on_exit()
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
os._exit(0)
|
|
582
|
+
except Exception:
|
|
583
|
+
strikes = 0 # the watch must never kill a healthy resident
|
|
584
|
+
|
|
585
|
+
thread = threading.Thread(target=_watch, name="resident-obsolescence-watch", daemon=True)
|
|
586
|
+
thread.start()
|
|
587
|
+
|
|
588
|
+
|
|
448
589
|
def run_mcp_proxy_adapter(*, name: str, instructions: str, run_kwargs: dict[str, Any]) -> None:
|
|
449
590
|
from fastmcp.server import create_proxy
|
|
450
591
|
|
package/src/server.py
CHANGED
|
@@ -3093,4 +3093,14 @@ if __name__ == "__main__":
|
|
|
3093
3093
|
"mode": "runtime-service",
|
|
3094
3094
|
}
|
|
3095
3095
|
)
|
|
3096
|
+
# Phase 2.1/2.2 — retire this resident gracefully if the on-disk
|
|
3097
|
+
# runtime is updated under it AND no clients remain connected.
|
|
3098
|
+
# The current-generation resident never self-terminates: a warm
|
|
3099
|
+
# Brain is what keeps conversation starts fast.
|
|
3100
|
+
from runtime_service import start_resident_obsolescence_watch
|
|
3101
|
+
|
|
3102
|
+
start_resident_obsolescence_watch(
|
|
3103
|
+
port=port,
|
|
3104
|
+
on_exit=lambda: (close_local_context_db(), close_db()),
|
|
3105
|
+
)
|
|
3096
3106
|
mcp.run(**run_kwargs)
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Heartbeat enforcement for NEXO sessions.
|
|
3
|
-
|
|
4
|
-
Tracks user messages vs heartbeat calls. Emits a warning when more than two
|
|
5
|
-
user messages pass without a heartbeat call.
|
|
6
|
-
|
|
7
|
-
Modes:
|
|
8
|
-
- HEARTBEAT_MODE=user_msg: increment counter on UserPromptSubmit
|
|
9
|
-
- HEARTBEAT_MODE=post_tool: inspect PostToolUse payload, reset on heartbeat,
|
|
10
|
-
warn when other tools keep running without one
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import json
|
|
16
|
-
import os
|
|
17
|
-
import sys
|
|
18
|
-
import time
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
import paths
|
|
23
|
-
except ModuleNotFoundError as exc:
|
|
24
|
-
if getattr(exc, "name", "") != "paths":
|
|
25
|
-
raise
|
|
26
|
-
|
|
27
|
-
class _PathsFallback:
|
|
28
|
-
@staticmethod
|
|
29
|
-
def operations_dir():
|
|
30
|
-
return Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations"
|
|
31
|
-
|
|
32
|
-
paths = _PathsFallback()
|
|
33
|
-
|
|
34
|
-
STATE_FILE = paths.operations_dir() / ".heartbeat-state.json"
|
|
35
|
-
THRESHOLD = 2
|
|
36
|
-
HEARTBEAT_TOOL = "nexo_heartbeat"
|
|
37
|
-
SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _read_state() -> dict:
|
|
41
|
-
try:
|
|
42
|
-
return json.loads(STATE_FILE.read_text())
|
|
43
|
-
except Exception:
|
|
44
|
-
return {"user_msgs": 0, "last_heartbeat_ts": 0.0, "last_user_msg_ts": 0.0}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _write_state(state: dict) -> None:
|
|
48
|
-
try:
|
|
49
|
-
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
STATE_FILE.write_text(json.dumps(state))
|
|
51
|
-
except Exception:
|
|
52
|
-
pass
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def handle_user_message() -> int:
|
|
56
|
-
state = _read_state()
|
|
57
|
-
state["user_msgs"] = state.get("user_msgs", 0) + 1
|
|
58
|
-
state["last_user_msg_ts"] = time.time()
|
|
59
|
-
_write_state(state)
|
|
60
|
-
return 0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def handle_post_tool(payload: dict) -> int:
|
|
64
|
-
tool_name = str(payload.get("tool_name", "")).strip()
|
|
65
|
-
short_name = tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name
|
|
66
|
-
state = _read_state()
|
|
67
|
-
|
|
68
|
-
if short_name == HEARTBEAT_TOOL:
|
|
69
|
-
state["user_msgs"] = 0
|
|
70
|
-
state["last_heartbeat_ts"] = time.time()
|
|
71
|
-
_write_state(state)
|
|
72
|
-
return 0
|
|
73
|
-
|
|
74
|
-
if short_name in SKIP_TOOLS:
|
|
75
|
-
return 0
|
|
76
|
-
|
|
77
|
-
user_msgs = state.get("user_msgs", 0)
|
|
78
|
-
if user_msgs > THRESHOLD:
|
|
79
|
-
print(
|
|
80
|
-
f"\nWARNING: HEARTBEAT OVERDUE ({user_msgs} user messages without nexo_heartbeat). "
|
|
81
|
-
"Call nexo_heartbeat(sid=SID, task='...') before continuing."
|
|
82
|
-
)
|
|
83
|
-
return 0
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def main() -> int:
|
|
87
|
-
mode = os.environ.get("HEARTBEAT_MODE", "").strip()
|
|
88
|
-
if mode == "user_msg":
|
|
89
|
-
return handle_user_message()
|
|
90
|
-
if mode == "post_tool":
|
|
91
|
-
raw = sys.stdin.read()
|
|
92
|
-
if not raw.strip():
|
|
93
|
-
return 0
|
|
94
|
-
try:
|
|
95
|
-
payload = json.loads(raw)
|
|
96
|
-
except Exception:
|
|
97
|
-
return 0
|
|
98
|
-
return handle_post_tool(payload)
|
|
99
|
-
return 0
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if __name__ == "__main__":
|
|
103
|
-
raise SystemExit(main())
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# NEXO PostToolUse hook — heartbeat enforcement checker
|
|
3
|
-
set -uo pipefail
|
|
4
|
-
|
|
5
|
-
INPUT=$(cat || true)
|
|
6
|
-
[ -z "$INPUT" ] && exit 0
|
|
7
|
-
|
|
8
|
-
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
9
|
-
HELPER=""
|
|
10
|
-
if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
|
|
11
|
-
HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
|
|
12
|
-
elif [ -f "$NEXO_HOME/core/hooks/heartbeat-enforcement.py" ]; then
|
|
13
|
-
HELPER="$NEXO_HOME/core/hooks/heartbeat-enforcement.py"
|
|
14
|
-
elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
|
|
15
|
-
HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
[ -z "$HELPER" ] && exit 0
|
|
19
|
-
HEARTBEAT_MODE=post_tool python3 "$HELPER" <<< "$INPUT" 2>/dev/null || true
|
|
20
|
-
exit 0
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# NEXO UserPromptSubmit hook — track user messages for heartbeat enforcement
|
|
3
|
-
set -uo pipefail
|
|
4
|
-
|
|
5
|
-
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
6
|
-
HELPER=""
|
|
7
|
-
if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
|
|
8
|
-
HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
|
|
9
|
-
elif [ -f "$NEXO_HOME/core/hooks/heartbeat-enforcement.py" ]; then
|
|
10
|
-
HELPER="$NEXO_HOME/core/hooks/heartbeat-enforcement.py"
|
|
11
|
-
elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
|
|
12
|
-
HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
|
|
13
|
-
fi
|
|
14
|
-
|
|
15
|
-
[ -z "$HELPER" ] && exit 0
|
|
16
|
-
HEARTBEAT_MODE=user_msg python3 "$HELPER" 2>/dev/null || true
|
|
17
|
-
exit 0
|