nexo-brain 7.9.6 → 7.9.8
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 +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +46 -32
- package/src/bootstrap_docs.py +8 -0
- package/src/client_sync.py +62 -3
- package/src/core_prompts.py +20 -8
- package/src/lifecycle_events.py +47 -2
- package/src/paths.py +13 -0
- package/src/plugin_loader.py +7 -1
- package/src/runtime_versioning.py +24 -5
- package/src/server.py +19 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.8",
|
|
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,7 +18,7 @@
|
|
|
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.9.
|
|
21
|
+
Version `7.9.8` is the current packaged-runtime line. Hotfix release over `7.9.7`: packaged installs that already run from `~/.nexo/core/current -> versions/<version>` no longer mistake that managed snapshot for a mutable source checkout during `nexo update`. The updater now stays on the packaged path unless `version.json` points to a real external repo, so installed users stop “updating” from the old runtime back into itself. Coordinated Desktop v0.28.10 bundles the same fix.
|
|
22
22
|
|
|
23
23
|
Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.8",
|
|
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
|
@@ -2262,19 +2262,20 @@ def _maybe_backfill_task_owner() -> None:
|
|
|
2262
2262
|
|
|
2263
2263
|
|
|
2264
2264
|
def _f06_legacy_shim_map() -> list[tuple[str, Path]]:
|
|
2265
|
+
core_root = NEXO_HOME / "core"
|
|
2265
2266
|
return [
|
|
2266
|
-
("scripts",
|
|
2267
|
-
("skills-core",
|
|
2268
|
-
("db",
|
|
2269
|
-
("cognitive",
|
|
2270
|
-
("doctor",
|
|
2271
|
-
("dashboard",
|
|
2267
|
+
("scripts", core_root / "scripts"),
|
|
2268
|
+
("skills-core", core_root / "skills"),
|
|
2269
|
+
("db", core_root / "db"),
|
|
2270
|
+
("cognitive", core_root / "cognitive"),
|
|
2271
|
+
("doctor", core_root / "doctor"),
|
|
2272
|
+
("dashboard", core_root / "dashboard"),
|
|
2272
2273
|
("brain", NEXO_HOME / "personal" / "brain"),
|
|
2273
2274
|
("config", NEXO_HOME / "personal" / "config"),
|
|
2274
2275
|
("skills", NEXO_HOME / "personal" / "skills"),
|
|
2275
|
-
("plugins",
|
|
2276
|
-
("hooks",
|
|
2277
|
-
("rules",
|
|
2276
|
+
("plugins", core_root / "plugins"),
|
|
2277
|
+
("hooks", core_root / "hooks"),
|
|
2278
|
+
("rules", core_root / "rules"),
|
|
2278
2279
|
("data", NEXO_HOME / "runtime" / "data"),
|
|
2279
2280
|
("logs", NEXO_HOME / "runtime" / "logs"),
|
|
2280
2281
|
("operations", NEXO_HOME / "runtime" / "operations"),
|
|
@@ -2300,7 +2301,7 @@ def _f06_legacy_file_shim_map() -> list[tuple[str, Path]]:
|
|
|
2300
2301
|
def _f06_packaged_code_file_targets() -> list[tuple[str, Path]]:
|
|
2301
2302
|
names = list(_discover_runtime_root_python_modules(NEXO_HOME))
|
|
2302
2303
|
names.extend(_discover_runtime_root_python_modules(NEXO_HOME / "core"))
|
|
2303
|
-
for extra in ("requirements.txt", "model_defaults.json"):
|
|
2304
|
+
for extra in ("requirements.txt", "model_defaults.json", "package.json", "version.json"):
|
|
2304
2305
|
candidate = NEXO_HOME / extra
|
|
2305
2306
|
canonical_candidate = NEXO_HOME / "core" / extra
|
|
2306
2307
|
if candidate.exists() or canonical_candidate.exists():
|
|
@@ -2422,12 +2423,13 @@ def _promote_packaged_runtime_code_to_core() -> None:
|
|
|
2422
2423
|
return resolved
|
|
2423
2424
|
return path
|
|
2424
2425
|
|
|
2426
|
+
core_root = NEXO_HOME / "core"
|
|
2425
2427
|
dir_targets = [
|
|
2426
|
-
("db",
|
|
2427
|
-
("cognitive",
|
|
2428
|
-
("doctor",
|
|
2429
|
-
("dashboard",
|
|
2430
|
-
("skills-core",
|
|
2428
|
+
("db", core_root / "db"),
|
|
2429
|
+
("cognitive", core_root / "cognitive"),
|
|
2430
|
+
("doctor", core_root / "doctor"),
|
|
2431
|
+
("dashboard", core_root / "dashboard"),
|
|
2432
|
+
("skills-core", core_root / "skills"),
|
|
2431
2433
|
]
|
|
2432
2434
|
|
|
2433
2435
|
for legacy_name, canonical in dir_targets:
|
|
@@ -2904,11 +2906,11 @@ def _maybe_migrate_to_f06_layout() -> None:
|
|
|
2904
2906
|
("state", NEXO_HOME / "runtime" / "state"),
|
|
2905
2907
|
("backups", NEXO_HOME / "runtime" / "backups"),
|
|
2906
2908
|
("memory", NEXO_HOME / "runtime" / "memory"),
|
|
2907
|
-
("cognitive",
|
|
2909
|
+
("cognitive", NEXO_HOME / "core" / "cognitive"),
|
|
2908
2910
|
("coordination", NEXO_HOME / "runtime" / "coordination"),
|
|
2909
2911
|
("exports", NEXO_HOME / "runtime" / "exports"),
|
|
2910
2912
|
("nexo-email", NEXO_HOME / "runtime" / "nexo-email"),
|
|
2911
|
-
("doctor",
|
|
2913
|
+
("doctor", NEXO_HOME / "core" / "doctor"),
|
|
2912
2914
|
("snapshots", NEXO_HOME / "runtime" / "snapshots"),
|
|
2913
2915
|
("crons", NEXO_HOME / "runtime" / "crons"),
|
|
2914
2916
|
("skills", NEXO_HOME / "personal" / "skills"),
|
|
@@ -3954,34 +3956,46 @@ UPDATE_HISTORY_FILE = paths.logs_dir() / "update-history.jsonl"
|
|
|
3954
3956
|
def _resolve_sync_source() -> tuple[Path | None, Path | None]:
|
|
3955
3957
|
dest = NEXO_HOME
|
|
3956
3958
|
|
|
3957
|
-
def
|
|
3958
|
-
version_file = NEXO_HOME / "version.json"
|
|
3959
|
-
if not version_file.is_file():
|
|
3960
|
-
return None
|
|
3959
|
+
def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
3961
3960
|
try:
|
|
3962
|
-
|
|
3961
|
+
path.relative_to(parent)
|
|
3962
|
+
return True
|
|
3963
3963
|
except Exception:
|
|
3964
|
-
return
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3964
|
+
return False
|
|
3965
|
+
|
|
3966
|
+
def _runtime_version_source() -> Path | None:
|
|
3967
|
+
for version_file in (NEXO_HOME / "version.json", NEXO_HOME / "core" / "version.json"):
|
|
3968
|
+
if not version_file.is_file():
|
|
3969
|
+
continue
|
|
3970
|
+
try:
|
|
3971
|
+
data = json.loads(version_file.read_text())
|
|
3972
|
+
except Exception:
|
|
3973
|
+
continue
|
|
3974
|
+
source = str(data.get("source", "")).strip()
|
|
3975
|
+
if not source:
|
|
3976
|
+
continue
|
|
3977
|
+
candidate = Path(source).expanduser()
|
|
3978
|
+
if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
|
|
3979
|
+
return candidate
|
|
3971
3980
|
return None
|
|
3972
3981
|
|
|
3973
3982
|
try:
|
|
3974
3983
|
runtime_core = (dest / "core").resolve()
|
|
3984
|
+
runtime_versions = (runtime_core / "versions").resolve(strict=False)
|
|
3975
3985
|
code_resolved = NEXO_CODE.resolve()
|
|
3976
3986
|
except Exception:
|
|
3977
3987
|
runtime_core = dest / "core"
|
|
3988
|
+
runtime_versions = runtime_core / "versions"
|
|
3978
3989
|
code_resolved = NEXO_CODE
|
|
3979
3990
|
|
|
3980
3991
|
# Packaged/runtime-only installs resolve the launcher to ``~/.nexo/core``.
|
|
3981
3992
|
# Those must use the packaged updater path instead of treating the managed
|
|
3982
|
-
# runtime itself as a mutable source repository.
|
|
3983
|
-
#
|
|
3984
|
-
|
|
3993
|
+
# runtime itself as a mutable source repository. That also applies once the
|
|
3994
|
+
# launcher resolves through ``core/current`` into ``core/versions/X.Y.Z``:
|
|
3995
|
+
# the active snapshot is still managed runtime state, not a dev checkout.
|
|
3996
|
+
# Only a recorded external source repo in ``version.json`` should
|
|
3997
|
+
# reactivate source-sync mode.
|
|
3998
|
+
if code_resolved == runtime_core or _is_relative_to(code_resolved, runtime_versions):
|
|
3985
3999
|
version_source = _runtime_version_source()
|
|
3986
4000
|
if version_source:
|
|
3987
4001
|
return version_source / "src", version_source
|
package/src/bootstrap_docs.py
CHANGED
|
@@ -27,6 +27,14 @@ def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
|
|
|
27
27
|
parent = module_dir.parent / "templates"
|
|
28
28
|
if parent.is_dir():
|
|
29
29
|
return parent
|
|
30
|
+
try:
|
|
31
|
+
nexo_home_templates = resolve_nexo_home(
|
|
32
|
+
os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))
|
|
33
|
+
) / "templates"
|
|
34
|
+
except Exception:
|
|
35
|
+
nexo_home_templates = Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser() / "templates"
|
|
36
|
+
if nexo_home_templates.is_dir():
|
|
37
|
+
return nexo_home_templates
|
|
30
38
|
return direct
|
|
31
39
|
|
|
32
40
|
|
package/src/client_sync.py
CHANGED
|
@@ -143,6 +143,45 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
|
|
|
143
143
|
return DEFAULT_ASSISTANT_NAME
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def _is_relative_to(path: Path, base: Path) -> bool:
|
|
147
|
+
try:
|
|
148
|
+
path.relative_to(base)
|
|
149
|
+
return True
|
|
150
|
+
except Exception:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalize_managed_runtime_root(nexo_home: Path, candidate: Path) -> Path:
|
|
155
|
+
raw = candidate.expanduser()
|
|
156
|
+
if raw.name == "server.py":
|
|
157
|
+
raw = raw.parent
|
|
158
|
+
try:
|
|
159
|
+
resolved = raw.resolve()
|
|
160
|
+
except Exception:
|
|
161
|
+
resolved = raw.absolute()
|
|
162
|
+
core_root = (nexo_home / "core").expanduser()
|
|
163
|
+
current_root = core_root / "current"
|
|
164
|
+
preferred_targets = []
|
|
165
|
+
if (core_root / "server.py").is_file():
|
|
166
|
+
preferred_targets.append(core_root)
|
|
167
|
+
if (current_root / "server.py").is_file():
|
|
168
|
+
preferred_targets.append(current_root)
|
|
169
|
+
if not preferred_targets:
|
|
170
|
+
return resolved
|
|
171
|
+
try:
|
|
172
|
+
core_resolved = core_root.resolve()
|
|
173
|
+
except Exception:
|
|
174
|
+
core_resolved = core_root.absolute()
|
|
175
|
+
version_root = core_resolved / "versions"
|
|
176
|
+
try:
|
|
177
|
+
current_resolved = current_root.resolve()
|
|
178
|
+
except Exception:
|
|
179
|
+
current_resolved = current_root.absolute()
|
|
180
|
+
if resolved == current_resolved or _is_relative_to(resolved, version_root):
|
|
181
|
+
return preferred_targets[0]
|
|
182
|
+
return resolved
|
|
183
|
+
|
|
184
|
+
|
|
146
185
|
def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str] | None = None) -> Path:
|
|
147
186
|
candidates: list[Path] = []
|
|
148
187
|
if runtime_root:
|
|
@@ -155,12 +194,13 @@ def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str]
|
|
|
155
194
|
|
|
156
195
|
seen: set[Path] = set()
|
|
157
196
|
for candidate in candidates:
|
|
158
|
-
|
|
197
|
+
normalized = _normalize_managed_runtime_root(nexo_home, candidate)
|
|
198
|
+
resolved = normalized.resolve()
|
|
159
199
|
if resolved in seen:
|
|
160
200
|
continue
|
|
161
201
|
seen.add(resolved)
|
|
162
|
-
if (
|
|
163
|
-
return
|
|
202
|
+
if (normalized / "server.py").is_file():
|
|
203
|
+
return normalized
|
|
164
204
|
raise FileNotFoundError(f"Could not locate runtime root with server.py (tried {len(seen)} locations)")
|
|
165
205
|
|
|
166
206
|
|
|
@@ -409,6 +449,7 @@ def build_server_config(
|
|
|
409
449
|
runtime_root: str | os.PathLike[str] | None = None,
|
|
410
450
|
python_path: str = "",
|
|
411
451
|
operator_name: str = "",
|
|
452
|
+
client: str = "",
|
|
412
453
|
) -> dict:
|
|
413
454
|
nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
|
|
414
455
|
runtime_root_path = _resolve_runtime_root(nexo_home_path, runtime_root)
|
|
@@ -423,6 +464,9 @@ def build_server_config(
|
|
|
423
464
|
resolved_name = _resolve_operator_name(nexo_home_path, explicit=operator_name)
|
|
424
465
|
if resolved_name:
|
|
425
466
|
config["env"]["NEXO_NAME"] = resolved_name
|
|
467
|
+
resolved_client = normalize_client_key(client)
|
|
468
|
+
if resolved_client:
|
|
469
|
+
config["env"]["NEXO_MCP_CLIENT"] = resolved_client
|
|
426
470
|
return config
|
|
427
471
|
|
|
428
472
|
|
|
@@ -436,6 +480,11 @@ def _claude_code_mcp_path(home: Path | None = None) -> Path:
|
|
|
436
480
|
return base / ".claude.json"
|
|
437
481
|
|
|
438
482
|
|
|
483
|
+
def _claude_code_cortex_path(home: Path | None = None) -> Path:
|
|
484
|
+
base = home or _user_home()
|
|
485
|
+
return base / ".claude" / "mcp-cortex.json"
|
|
486
|
+
|
|
487
|
+
|
|
439
488
|
def _claude_desktop_config_path(home: Path | None = None) -> Path:
|
|
440
489
|
base = home or _user_home()
|
|
441
490
|
if sys.platform == "darwin":
|
|
@@ -1022,6 +1071,7 @@ def sync_claude_code(
|
|
|
1022
1071
|
runtime_root=runtime_root,
|
|
1023
1072
|
python_path=python_path,
|
|
1024
1073
|
operator_name=operator_name,
|
|
1074
|
+
client="claude_code",
|
|
1025
1075
|
)
|
|
1026
1076
|
home_path = Path(user_home).expanduser() if user_home else None
|
|
1027
1077
|
result = _sync_claude_code_settings(
|
|
@@ -1039,6 +1089,13 @@ def sync_claude_code(
|
|
|
1039
1089
|
)
|
|
1040
1090
|
result["mcp"] = mcp_result
|
|
1041
1091
|
result["mcp_path"] = mcp_result.get("path", "")
|
|
1092
|
+
cortex_result = _sync_json_client(
|
|
1093
|
+
_claude_code_cortex_path(home_path),
|
|
1094
|
+
server_config,
|
|
1095
|
+
"claude_code",
|
|
1096
|
+
)
|
|
1097
|
+
result["cortex_mcp"] = cortex_result
|
|
1098
|
+
result["cortex_mcp_path"] = cortex_result.get("path", "")
|
|
1042
1099
|
bootstrap_result = sync_client_bootstrap(
|
|
1043
1100
|
"claude_code",
|
|
1044
1101
|
nexo_home=nexo_home,
|
|
@@ -1066,6 +1123,7 @@ def sync_claude_desktop(
|
|
|
1066
1123
|
runtime_root=runtime_root,
|
|
1067
1124
|
python_path=python_path,
|
|
1068
1125
|
operator_name=operator_name,
|
|
1126
|
+
client="claude_desktop",
|
|
1069
1127
|
)
|
|
1070
1128
|
resolved_name = server_config.get("env", {}).get("NEXO_NAME", "") or _resolve_operator_name(
|
|
1071
1129
|
Path(nexo_home).expanduser() if nexo_home else _default_nexo_home(),
|
|
@@ -1097,6 +1155,7 @@ def sync_codex(
|
|
|
1097
1155
|
runtime_root=runtime_root,
|
|
1098
1156
|
python_path=python_path,
|
|
1099
1157
|
operator_name=operator_name,
|
|
1158
|
+
client="codex",
|
|
1100
1159
|
)
|
|
1101
1160
|
codex_bin = shutil.which("codex")
|
|
1102
1161
|
config_path = _codex_config_path(home_path)
|
package/src/core_prompts.py
CHANGED
|
@@ -10,15 +10,27 @@ from pathlib import Path
|
|
|
10
10
|
_TOKEN_RE = re.compile(r"\[\[([a-zA-Z0-9_]+)\]\]")
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def _find_templates_root(start: Path) -> Path | None:
|
|
14
|
+
current = start.expanduser()
|
|
15
|
+
try:
|
|
16
|
+
current = current.resolve()
|
|
17
|
+
except Exception:
|
|
18
|
+
current = current.absolute()
|
|
19
|
+
if current.is_file():
|
|
20
|
+
current = current.parent
|
|
21
|
+
for candidate in (current, *current.parents):
|
|
22
|
+
if (candidate / "templates" / "core-prompts").is_dir():
|
|
23
|
+
return candidate
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
13
27
|
def _resolve_repo_root() -> Path:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return candidate.parent
|
|
21
|
-
return Path(__file__).resolve().parents[1]
|
|
28
|
+
configured = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
29
|
+
resolved = _find_templates_root(configured)
|
|
30
|
+
if resolved is not None:
|
|
31
|
+
return resolved
|
|
32
|
+
fallback = Path(__file__).resolve().parents[1]
|
|
33
|
+
return _find_templates_root(fallback) or fallback
|
|
22
34
|
|
|
23
35
|
|
|
24
36
|
PROMPTS_DIR = _resolve_repo_root() / "templates" / "core-prompts"
|
package/src/lifecycle_events.py
CHANGED
|
@@ -75,6 +75,7 @@ TERMINAL_STATUSES = {
|
|
|
75
75
|
"rejected",
|
|
76
76
|
}
|
|
77
77
|
_DIARY_TRIGGERING = lifecycle_prompts.DIARY_TRIGGERING_ACTIONS
|
|
78
|
+
SESSION_NOT_LINKED_REASON = "session-not-linked-to-nexo"
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def _normalise_payload(obj: Any) -> str:
|
|
@@ -125,6 +126,34 @@ def _session_diary_session_ids(conn, session_id: str) -> List[str]:
|
|
|
125
126
|
return deduped
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def _session_is_linked_to_nexo(conn, session_id: str) -> bool:
|
|
130
|
+
"""True when the external Claude/Desktop session is linked to a NEXO SID."""
|
|
131
|
+
raw = str(session_id or "").strip()
|
|
132
|
+
if not raw:
|
|
133
|
+
return False
|
|
134
|
+
if raw.startswith("nexo-"):
|
|
135
|
+
return True
|
|
136
|
+
try:
|
|
137
|
+
row = conn.execute(
|
|
138
|
+
"SELECT 1 FROM session_claude_aliases WHERE claude_session_id = ? LIMIT 1",
|
|
139
|
+
(raw,),
|
|
140
|
+
).fetchone()
|
|
141
|
+
if row is not None:
|
|
142
|
+
return True
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
try:
|
|
146
|
+
row = conn.execute(
|
|
147
|
+
"SELECT 1 FROM sessions "
|
|
148
|
+
"WHERE external_session_id = ? OR claude_session_id = ? "
|
|
149
|
+
"LIMIT 1",
|
|
150
|
+
(raw, raw),
|
|
151
|
+
).fetchone()
|
|
152
|
+
return row is not None
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
128
157
|
def _max_session_diary_id(conn, session_id: str) -> int:
|
|
129
158
|
session_ids = _session_diary_session_ids(conn, session_id)
|
|
130
159
|
if not session_ids:
|
|
@@ -510,10 +539,14 @@ def record_complete_canonical(
|
|
|
510
539
|
)
|
|
511
540
|
diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
|
|
512
541
|
diary_required = action in _DIARY_TRIGGERING and bool(session_id)
|
|
542
|
+
session_registered = _session_is_linked_to_nexo(conn, session_id) if diary_required else bool(session_id)
|
|
543
|
+
session_unregistered = diary_required and not session_registered
|
|
513
544
|
diary_missing = diary_required and diary_evidence is None
|
|
514
545
|
effective = "retryable_error" if (any_failure or diary_missing) else "canonical_done"
|
|
515
546
|
last_error = None
|
|
516
|
-
if
|
|
547
|
+
if session_unregistered:
|
|
548
|
+
last_error = SESSION_NOT_LINKED_REASON
|
|
549
|
+
elif any_failure:
|
|
517
550
|
last_error = "one-or-more-actions-failed"
|
|
518
551
|
elif diary_missing:
|
|
519
552
|
last_error = "canonical-diary-not-confirmed"
|
|
@@ -540,8 +573,9 @@ def record_complete_canonical(
|
|
|
540
573
|
"failed_actions": any_failure,
|
|
541
574
|
"diary_confirmed": diary_evidence is not None,
|
|
542
575
|
"diary_required": diary_required,
|
|
576
|
+
"session_registered": session_registered,
|
|
543
577
|
"session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
|
|
544
|
-
"reason":
|
|
578
|
+
"reason": last_error if effective == "retryable_error" else None,
|
|
545
579
|
}
|
|
546
580
|
|
|
547
581
|
|
|
@@ -570,6 +604,15 @@ def wait_for_canonical_diary(
|
|
|
570
604
|
session_id = str(row[0] or "")
|
|
571
605
|
if not session_id:
|
|
572
606
|
return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
|
|
607
|
+
if not _session_is_linked_to_nexo(conn, session_id):
|
|
608
|
+
return {
|
|
609
|
+
"status": "retryable_error",
|
|
610
|
+
"event_id": event_id,
|
|
611
|
+
"session_id": session_id,
|
|
612
|
+
"diary_confirmed": False,
|
|
613
|
+
"session_registered": False,
|
|
614
|
+
"reason": SESSION_NOT_LINKED_REASON,
|
|
615
|
+
}
|
|
573
616
|
evidence = _session_diary_evidence(conn, session_id, row[1], row[2])
|
|
574
617
|
if evidence is not None:
|
|
575
618
|
return {
|
|
@@ -577,6 +620,7 @@ def wait_for_canonical_diary(
|
|
|
577
620
|
"event_id": event_id,
|
|
578
621
|
"session_id": session_id,
|
|
579
622
|
"diary_confirmed": True,
|
|
623
|
+
"session_registered": True,
|
|
580
624
|
**evidence,
|
|
581
625
|
}
|
|
582
626
|
if time.monotonic() >= deadline:
|
|
@@ -585,6 +629,7 @@ def wait_for_canonical_diary(
|
|
|
585
629
|
"event_id": event_id,
|
|
586
630
|
"session_id": session_id,
|
|
587
631
|
"diary_confirmed": False,
|
|
632
|
+
"session_registered": True,
|
|
588
633
|
"reason": last_error or "diary-confirm-timeout",
|
|
589
634
|
}
|
|
590
635
|
time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
|
package/src/paths.py
CHANGED
|
@@ -71,6 +71,19 @@ def home() -> Path:
|
|
|
71
71
|
# ---------------------------------------------------------------------------
|
|
72
72
|
def core_dir() -> Path:
|
|
73
73
|
container = home() / "core"
|
|
74
|
+
live_markers = (
|
|
75
|
+
"cli.py",
|
|
76
|
+
"server.py",
|
|
77
|
+
"db",
|
|
78
|
+
"hooks",
|
|
79
|
+
"plugins",
|
|
80
|
+
"rules",
|
|
81
|
+
"scripts",
|
|
82
|
+
"package.json",
|
|
83
|
+
"version.json",
|
|
84
|
+
)
|
|
85
|
+
if any((container / marker).exists() for marker in live_markers):
|
|
86
|
+
return container
|
|
74
87
|
current = container / "current"
|
|
75
88
|
if current.exists():
|
|
76
89
|
try:
|
package/src/plugin_loader.py
CHANGED
|
@@ -290,7 +290,13 @@ def load_plugin(mcp, filename: str, plugins_dir: str | None = None) -> int:
|
|
|
290
290
|
mcp.local_provider.remove_tool(name)
|
|
291
291
|
except Exception:
|
|
292
292
|
pass
|
|
293
|
-
|
|
293
|
+
# output_schema=None disables FastMCP's auto-generated
|
|
294
|
+
# `x-fastmcp-wrap-result` wrapper that otherwise makes str-returning
|
|
295
|
+
# plugin tools unexecutable in Claude Code. See server.py and
|
|
296
|
+
# followup NF-FASTMCP-OUTPUT-SCHEMA-1776969764.
|
|
297
|
+
t = Tool.from_function(
|
|
298
|
+
func, name=name, description=description, output_schema=None
|
|
299
|
+
)
|
|
294
300
|
mcp.add_tool(t)
|
|
295
301
|
tool_names.append(name)
|
|
296
302
|
|
|
@@ -158,7 +158,7 @@ def write_restart_required_marker(
|
|
|
158
158
|
def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, version: str = "") -> dict:
|
|
159
159
|
container = core_container_dir()
|
|
160
160
|
source = Path(source_root or container)
|
|
161
|
-
if source == container and core_current_link().exists():
|
|
161
|
+
if source_root is None and source == container and core_current_link().exists():
|
|
162
162
|
try:
|
|
163
163
|
source = core_current_link().resolve(strict=False)
|
|
164
164
|
except Exception:
|
|
@@ -319,6 +319,28 @@ def prime_process_version() -> str:
|
|
|
319
319
|
class RestartRequiredMiddleware(Middleware):
|
|
320
320
|
client: str = ""
|
|
321
321
|
|
|
322
|
+
async def _tool_result_for_restart_required(self, context, payload: dict) -> ToolResult:
|
|
323
|
+
payload_text = json.dumps(payload, ensure_ascii=False)
|
|
324
|
+
tool = None
|
|
325
|
+
try:
|
|
326
|
+
fastmcp_context = getattr(context, "fastmcp_context", None)
|
|
327
|
+
fastmcp_server = getattr(fastmcp_context, "fastmcp", None)
|
|
328
|
+
if fastmcp_server is not None:
|
|
329
|
+
tool = await fastmcp_server.get_tool(str(getattr(context.message, "name", "") or "").strip())
|
|
330
|
+
except Exception:
|
|
331
|
+
tool = None
|
|
332
|
+
|
|
333
|
+
output_schema = getattr(tool, "output_schema", None)
|
|
334
|
+
if isinstance(output_schema, dict) and output_schema.get("x-fastmcp-wrap-result"):
|
|
335
|
+
return ToolResult(
|
|
336
|
+
content=payload_text,
|
|
337
|
+
structured_content={"result": payload_text},
|
|
338
|
+
)
|
|
339
|
+
return ToolResult(
|
|
340
|
+
content=payload_text,
|
|
341
|
+
structured_content=payload,
|
|
342
|
+
)
|
|
343
|
+
|
|
322
344
|
async def on_call_tool(self, context, call_next):
|
|
323
345
|
tool_name = str(getattr(context.message, "name", "") or "").strip()
|
|
324
346
|
state = resolve_restart_required(client=self.client)
|
|
@@ -336,7 +358,4 @@ class RestartRequiredMiddleware(Middleware):
|
|
|
336
358
|
"reason": state["reason"],
|
|
337
359
|
"client_action": state["client_action"],
|
|
338
360
|
}
|
|
339
|
-
return
|
|
340
|
-
content=json.dumps(payload, ensure_ascii=False),
|
|
341
|
-
structured_content=payload,
|
|
342
|
-
)
|
|
361
|
+
return await self._tool_result_for_restart_required(context, payload)
|
package/src/server.py
CHANGED
|
@@ -280,6 +280,25 @@ mcp.add_middleware(
|
|
|
280
280
|
RestartRequiredMiddleware(client=str(os.environ.get("NEXO_MCP_CLIENT", "") or "").strip())
|
|
281
281
|
)
|
|
282
282
|
|
|
283
|
+
# FastMCP (both 2.14.7 and 3.2.4) auto-generates an outputSchema wrapper
|
|
284
|
+
# (`{required: [result], x-fastmcp-wrap-result: true}`) for every tool whose
|
|
285
|
+
# return annotation is a non-object type such as `str`. Claude Code validates
|
|
286
|
+
# MCP tool responses strictly against outputSchema and rejects our plain-text
|
|
287
|
+
# replies with `Output validation error: result is a required property`,
|
|
288
|
+
# which makes every `nexo_*` tool inexecutable from Claude Code. We opt out
|
|
289
|
+
# of output_schema globally by wrapping `mcp.tool` so every decorator here
|
|
290
|
+
# and in plugins defaults to `output_schema=None` unless the caller passes
|
|
291
|
+
# something explicit. See followup NF-FASTMCP-OUTPUT-SCHEMA-1776969764.
|
|
292
|
+
_mcp_tool_original = mcp.tool
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _mcp_tool_without_output_schema(name_or_fn=None, **kwargs):
|
|
296
|
+
kwargs.setdefault("output_schema", None)
|
|
297
|
+
return _mcp_tool_original(name_or_fn, **kwargs)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
mcp.tool = _mcp_tool_without_output_schema # type: ignore[method-assign]
|
|
301
|
+
|
|
283
302
|
|
|
284
303
|
def _run_kwargs_from_env() -> dict:
|
|
285
304
|
transport = str(os.environ.get("NEXO_MCP_TRANSPORT", "stdio") or "stdio").strip().lower()
|