nexo-brain 7.9.5 → 7.9.7
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 +3 -1
- package/bin/nexo-brain.js +9 -0
- package/package.json +1 -1
- package/src/auto_update.py +41 -30
- package/src/bootstrap_docs.py +8 -0
- package/src/cli.py +138 -0
- package/src/client_sync.py +62 -3
- package/src/continuity.py +442 -0
- package/src/core_prompts.py +20 -8
- package/src/db/__init__.py +8 -0
- package/src/db/_continuity.py +171 -0
- package/src/db/_schema.py +33 -0
- package/src/db/_sessions.py +23 -7
- package/src/lifecycle_events.py +47 -2
- package/src/paths.py +24 -1
- package/src/plugin_loader.py +7 -1
- package/src/plugins/update.py +49 -0
- package/src/runtime_versioning.py +361 -0
- package/src/server.py +122 -1
- package/src/tools_sessions.py +30 -0
- package/tool-enforcement-map.json +75 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastmcp.server.middleware import Middleware
|
|
11
|
+
from fastmcp.tools import ToolResult
|
|
12
|
+
|
|
13
|
+
import paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CONTINUITY_API_LEVEL = 1
|
|
17
|
+
MCP_STATUS_SCHEMA_VERSION = 1
|
|
18
|
+
PROCESS_VERSION = ""
|
|
19
|
+
RESTART_ALLOWLIST = {
|
|
20
|
+
"nexo_status",
|
|
21
|
+
"nexo_system_catalog",
|
|
22
|
+
"nexo_tool_explain",
|
|
23
|
+
"nexo_heartbeat",
|
|
24
|
+
"nexo_stop",
|
|
25
|
+
"nexo_session_portable_context",
|
|
26
|
+
"nexo_session_export_bundle",
|
|
27
|
+
"nexo_lifecycle_event",
|
|
28
|
+
"nexo_lifecycle_status",
|
|
29
|
+
"nexo_lifecycle_complete_canonical",
|
|
30
|
+
"nexo_lifecycle_wait_for_diary",
|
|
31
|
+
"nexo_continuity_snapshot_read",
|
|
32
|
+
"nexo_continuity_resume_bundle",
|
|
33
|
+
"nexo_continuity_audit",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_json_file(path: Path) -> dict:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except Exception:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_json_atomic(path: Path, payload: dict) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
47
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
48
|
+
tmp.replace(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def core_container_dir() -> Path:
|
|
52
|
+
return paths.home() / "core"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def core_versions_dir() -> Path:
|
|
56
|
+
return core_container_dir() / "versions"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def core_current_link() -> Path:
|
|
60
|
+
return core_container_dir() / "current"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def active_runtime_root() -> Path:
|
|
64
|
+
current = core_current_link()
|
|
65
|
+
if current.exists():
|
|
66
|
+
try:
|
|
67
|
+
resolved = current.resolve(strict=False)
|
|
68
|
+
if resolved.exists():
|
|
69
|
+
return resolved
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
return current
|
|
73
|
+
core_dir = core_container_dir()
|
|
74
|
+
if (core_dir / "cli.py").is_file() or (core_dir / "server.py").is_file():
|
|
75
|
+
return core_dir
|
|
76
|
+
return paths.home()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def restart_required_marker_path() -> Path:
|
|
80
|
+
return paths.operations_dir() / "mcp-restart-required.json"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _candidate_version_files(base: Path) -> list[Path]:
|
|
84
|
+
return [
|
|
85
|
+
base / "version.json",
|
|
86
|
+
base / "package.json",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_version_for_path(base: Path) -> str:
|
|
91
|
+
for candidate in _candidate_version_files(base):
|
|
92
|
+
try:
|
|
93
|
+
if candidate.is_file():
|
|
94
|
+
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
|
95
|
+
version = str(payload.get("version", "")).strip()
|
|
96
|
+
if version:
|
|
97
|
+
return version
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def installed_runtime_version() -> str:
|
|
104
|
+
for candidate in [active_runtime_root(), paths.home()]:
|
|
105
|
+
version = read_version_for_path(candidate)
|
|
106
|
+
if version:
|
|
107
|
+
return version
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_restart_required_marker() -> dict:
|
|
112
|
+
path = restart_required_marker_path()
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return {"required": False, "path": str(path), "exists": False}
|
|
115
|
+
try:
|
|
116
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
raise ValueError("marker is not an object")
|
|
119
|
+
payload.setdefault("required", True)
|
|
120
|
+
payload["path"] = str(path)
|
|
121
|
+
payload["exists"] = True
|
|
122
|
+
return payload
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
return {
|
|
125
|
+
"required": True,
|
|
126
|
+
"exists": True,
|
|
127
|
+
"path": str(path),
|
|
128
|
+
"corrupt": True,
|
|
129
|
+
"error": str(exc),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_restart_required_marker(
|
|
134
|
+
*,
|
|
135
|
+
from_version: str,
|
|
136
|
+
to_version: str,
|
|
137
|
+
reason: str = "brain_update",
|
|
138
|
+
) -> dict:
|
|
139
|
+
path = restart_required_marker_path()
|
|
140
|
+
payload = {
|
|
141
|
+
"schema_version": MCP_STATUS_SCHEMA_VERSION,
|
|
142
|
+
"required": True,
|
|
143
|
+
"from_version": str(from_version or "").strip(),
|
|
144
|
+
"to_version": str(to_version or "").strip(),
|
|
145
|
+
"reason": str(reason or "brain_update"),
|
|
146
|
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
147
|
+
"clients": {
|
|
148
|
+
"claude_desktop": "restart_client_required",
|
|
149
|
+
"claude_code": "restart_session_required",
|
|
150
|
+
"codex": "restart_session_required",
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
_write_json_atomic(path, payload)
|
|
154
|
+
payload["path"] = str(path)
|
|
155
|
+
return payload
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, version: str = "") -> dict:
|
|
159
|
+
container = core_container_dir()
|
|
160
|
+
source = Path(source_root or container)
|
|
161
|
+
if source_root is None and source == container and core_current_link().exists():
|
|
162
|
+
try:
|
|
163
|
+
source = core_current_link().resolve(strict=False)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
resolved_version = str(version or read_version_for_path(source) or installed_runtime_version()).strip()
|
|
167
|
+
if not resolved_version:
|
|
168
|
+
return {"ok": False, "error": "missing_version", "source_root": str(source)}
|
|
169
|
+
|
|
170
|
+
versions_dir = core_versions_dir()
|
|
171
|
+
target = versions_dir / resolved_version
|
|
172
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
copied: list[str] = []
|
|
176
|
+
for item in source.iterdir():
|
|
177
|
+
if item.name in {"versions", "current", "__pycache__"}:
|
|
178
|
+
continue
|
|
179
|
+
dest = target / item.name
|
|
180
|
+
if dest.exists() or dest.is_symlink():
|
|
181
|
+
if dest.is_dir() and not dest.is_symlink():
|
|
182
|
+
shutil.rmtree(dest)
|
|
183
|
+
else:
|
|
184
|
+
dest.unlink()
|
|
185
|
+
if item.is_dir():
|
|
186
|
+
shutil.copytree(item, dest, symlinks=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
|
|
187
|
+
else:
|
|
188
|
+
shutil.copy2(item, dest)
|
|
189
|
+
copied.append(item.name)
|
|
190
|
+
|
|
191
|
+
current = core_current_link()
|
|
192
|
+
tmp_link = current.with_name(f".current.{os.getpid()}.tmp")
|
|
193
|
+
if tmp_link.exists() or tmp_link.is_symlink():
|
|
194
|
+
tmp_link.unlink()
|
|
195
|
+
target_rel = Path("versions") / resolved_version
|
|
196
|
+
os.symlink(str(target_rel), str(tmp_link))
|
|
197
|
+
os.replace(str(tmp_link), str(current))
|
|
198
|
+
return {
|
|
199
|
+
"ok": True,
|
|
200
|
+
"version": resolved_version,
|
|
201
|
+
"source_root": str(source),
|
|
202
|
+
"target_root": str(target),
|
|
203
|
+
"current_link": str(current),
|
|
204
|
+
"copied": copied,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def clear_restart_required_marker(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
|
|
209
|
+
path = restart_required_marker_path()
|
|
210
|
+
marker = read_restart_required_marker()
|
|
211
|
+
if not marker.get("required"):
|
|
212
|
+
return {"ok": True, "cleared": False, "path": str(path)}
|
|
213
|
+
if marker.get("corrupt"):
|
|
214
|
+
try:
|
|
215
|
+
path.unlink(missing_ok=True)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return {"ok": True, "cleared": True, "path": str(path), "corrupt": True}
|
|
219
|
+
|
|
220
|
+
payload = dict(marker)
|
|
221
|
+
clients = dict(payload.get("clients") or {})
|
|
222
|
+
if client:
|
|
223
|
+
clients[client] = "ok"
|
|
224
|
+
payload["clients"] = clients
|
|
225
|
+
pending_clients = {k: v for k, v in clients.items() if v != "ok"}
|
|
226
|
+
effective_installed = str(installed_version or payload.get("to_version") or "").strip()
|
|
227
|
+
effective_process = str(process_version or "").strip()
|
|
228
|
+
if pending_clients:
|
|
229
|
+
_write_json_atomic(path, payload)
|
|
230
|
+
return {"ok": True, "cleared": False, "path": str(path), "pending_clients": pending_clients}
|
|
231
|
+
if effective_installed and effective_process and effective_installed != effective_process:
|
|
232
|
+
_write_json_atomic(path, payload)
|
|
233
|
+
return {
|
|
234
|
+
"ok": True,
|
|
235
|
+
"cleared": False,
|
|
236
|
+
"path": str(path),
|
|
237
|
+
"pending_reason": "process_version_mismatch",
|
|
238
|
+
}
|
|
239
|
+
try:
|
|
240
|
+
path.unlink(missing_ok=True)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return {"ok": True, "cleared": True, "path": str(path)}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resolve_restart_required(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
|
|
247
|
+
marker = read_restart_required_marker()
|
|
248
|
+
installed = str(installed_version or installed_runtime_version() or "").strip()
|
|
249
|
+
process = str(process_version or PROCESS_VERSION or installed).strip()
|
|
250
|
+
restart_required = False
|
|
251
|
+
reason = ""
|
|
252
|
+
client_action = ""
|
|
253
|
+
marker_clients = dict(marker.get("clients") or {})
|
|
254
|
+
|
|
255
|
+
if marker.get("required"):
|
|
256
|
+
restart_required = True
|
|
257
|
+
reason = "marker_required"
|
|
258
|
+
client_action = str(marker_clients.get(client) or "")
|
|
259
|
+
if marker.get("corrupt"):
|
|
260
|
+
restart_required = True
|
|
261
|
+
reason = "marker_corrupt"
|
|
262
|
+
elif installed and process and installed != process:
|
|
263
|
+
restart_required = True
|
|
264
|
+
reason = reason or "version_mismatch"
|
|
265
|
+
elif client and client_action == "ok":
|
|
266
|
+
restart_required = False
|
|
267
|
+
reason = ""
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"restart_required": restart_required,
|
|
271
|
+
"reason": reason,
|
|
272
|
+
"client_action": client_action,
|
|
273
|
+
"marker": marker,
|
|
274
|
+
"installed_version": installed,
|
|
275
|
+
"process_version": process,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def build_mcp_status(*, client: str = "") -> dict:
|
|
280
|
+
state = resolve_restart_required(client=client)
|
|
281
|
+
marker = state["marker"]
|
|
282
|
+
return {
|
|
283
|
+
"ok": True,
|
|
284
|
+
"schema_version": MCP_STATUS_SCHEMA_VERSION,
|
|
285
|
+
"client": str(client or "").strip(),
|
|
286
|
+
"installed_version": state["installed_version"],
|
|
287
|
+
"process_version": state["process_version"],
|
|
288
|
+
"active_runtime_root": str(active_runtime_root()),
|
|
289
|
+
"active_runtime_version": read_version_for_path(active_runtime_root()),
|
|
290
|
+
"restart_required": bool(state["restart_required"]),
|
|
291
|
+
"reason": state["reason"],
|
|
292
|
+
"client_action": state["client_action"],
|
|
293
|
+
"marker_path": marker.get("path", str(restart_required_marker_path())),
|
|
294
|
+
"marker_exists": bool(marker.get("exists")),
|
|
295
|
+
"marker_corrupt": bool(marker.get("corrupt")),
|
|
296
|
+
"continuity_api_level": CONTINUITY_API_LEVEL,
|
|
297
|
+
"version_match": (
|
|
298
|
+
bool(state["installed_version"])
|
|
299
|
+
and bool(state["process_version"])
|
|
300
|
+
and state["installed_version"] == state["process_version"]
|
|
301
|
+
),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def prime_process_version() -> str:
|
|
306
|
+
global PROCESS_VERSION
|
|
307
|
+
if PROCESS_VERSION:
|
|
308
|
+
return PROCESS_VERSION
|
|
309
|
+
for candidate in [Path(__file__).resolve().parent, active_runtime_root(), paths.home()]:
|
|
310
|
+
version = read_version_for_path(candidate)
|
|
311
|
+
if version:
|
|
312
|
+
PROCESS_VERSION = version
|
|
313
|
+
return version
|
|
314
|
+
PROCESS_VERSION = "unknown"
|
|
315
|
+
return PROCESS_VERSION
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class RestartRequiredMiddleware(Middleware):
|
|
320
|
+
client: str = ""
|
|
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
|
+
|
|
344
|
+
async def on_call_tool(self, context, call_next):
|
|
345
|
+
tool_name = str(getattr(context.message, "name", "") or "").strip()
|
|
346
|
+
state = resolve_restart_required(client=self.client)
|
|
347
|
+
if not state["restart_required"] or tool_name in RESTART_ALLOWLIST:
|
|
348
|
+
return await call_next(context)
|
|
349
|
+
|
|
350
|
+
payload = {
|
|
351
|
+
"ok": False,
|
|
352
|
+
"error": "mcp_restart_required",
|
|
353
|
+
"message": "NEXO Brain was updated. Restart this MCP client/session.",
|
|
354
|
+
"restart_required": True,
|
|
355
|
+
"tool": tool_name,
|
|
356
|
+
"installed_version": state["installed_version"],
|
|
357
|
+
"process_version": state["process_version"],
|
|
358
|
+
"reason": state["reason"],
|
|
359
|
+
"client_action": state["client_action"],
|
|
360
|
+
}
|
|
361
|
+
return await self._tool_result_for_restart_required(context, payload)
|
package/src/server.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import os
|
|
5
5
|
import signal
|
|
6
6
|
import sys
|
|
7
|
+
import json
|
|
7
8
|
|
|
8
9
|
from fastmcp import FastMCP
|
|
9
10
|
from core_prompts import render_core_prompt
|
|
@@ -17,6 +18,15 @@ from tools_sessions import (
|
|
|
17
18
|
handle_session_portable_context,
|
|
18
19
|
handle_session_export_bundle,
|
|
19
20
|
)
|
|
21
|
+
from continuity import (
|
|
22
|
+
build_resume_bundle,
|
|
23
|
+
continuity_audit,
|
|
24
|
+
format_bundle_text,
|
|
25
|
+
json_dumps as _continuity_json_dumps,
|
|
26
|
+
read_snapshot,
|
|
27
|
+
record_compaction_event,
|
|
28
|
+
write_snapshot,
|
|
29
|
+
)
|
|
20
30
|
from tools_hot_context import (
|
|
21
31
|
handle_recent_context_capture,
|
|
22
32
|
handle_recent_context,
|
|
@@ -81,6 +91,11 @@ from plugins.workflow import (
|
|
|
81
91
|
)
|
|
82
92
|
from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
|
|
83
93
|
from tools_guardian import handle_guardian_rule_override
|
|
94
|
+
from runtime_versioning import (
|
|
95
|
+
RestartRequiredMiddleware,
|
|
96
|
+
build_mcp_status,
|
|
97
|
+
prime_process_version,
|
|
98
|
+
)
|
|
84
99
|
|
|
85
100
|
|
|
86
101
|
# ── Graceful shutdown: close DB on any termination signal ──────────
|
|
@@ -260,6 +275,29 @@ mcp = FastMCP(
|
|
|
260
275
|
assistant_name=_get_ctx().assistant_name,
|
|
261
276
|
),
|
|
262
277
|
)
|
|
278
|
+
prime_process_version()
|
|
279
|
+
mcp.add_middleware(
|
|
280
|
+
RestartRequiredMiddleware(client=str(os.environ.get("NEXO_MCP_CLIENT", "") or "").strip())
|
|
281
|
+
)
|
|
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]
|
|
263
301
|
|
|
264
302
|
|
|
265
303
|
def _run_kwargs_from_env() -> dict:
|
|
@@ -290,7 +328,7 @@ def _run_kwargs_from_env() -> dict:
|
|
|
290
328
|
# ── Session management (3 tools) ──────────────────────────────────
|
|
291
329
|
|
|
292
330
|
@mcp.tool
|
|
293
|
-
def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "") -> str:
|
|
331
|
+
def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "", conversation_id: str = "") -> str:
|
|
294
332
|
"""Register new session, clean stale ones, return active sessions + alerts.
|
|
295
333
|
|
|
296
334
|
Call this ONCE at the start of every conversation.
|
|
@@ -303,12 +341,14 @@ def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_tok
|
|
|
303
341
|
other clients may pass a synthetic durable token when useful.
|
|
304
342
|
Pass this to enable automatic inter-terminal inbox detection when available.
|
|
305
343
|
session_client: Optional client label such as `claude_code` or `codex`.
|
|
344
|
+
conversation_id: Stable client-side conversation identifier when available.
|
|
306
345
|
"""
|
|
307
346
|
return handle_startup(
|
|
308
347
|
task,
|
|
309
348
|
claude_session_id=claude_session_id,
|
|
310
349
|
session_token=session_token,
|
|
311
350
|
session_client=session_client,
|
|
351
|
+
conversation_id=conversation_id,
|
|
312
352
|
)
|
|
313
353
|
|
|
314
354
|
|
|
@@ -565,6 +605,87 @@ def nexo_status(keyword: str = "") -> str:
|
|
|
565
605
|
return handle_status(keyword if keyword else None)
|
|
566
606
|
|
|
567
607
|
|
|
608
|
+
@mcp.tool
|
|
609
|
+
def nexo_continuity_snapshot_write(
|
|
610
|
+
conversation_id: str,
|
|
611
|
+
session_id: str = "",
|
|
612
|
+
external_session_id: str = "",
|
|
613
|
+
client: str = "",
|
|
614
|
+
event_type: str = "turn_end",
|
|
615
|
+
payload: str = "",
|
|
616
|
+
trace_id: str = "",
|
|
617
|
+
idempotency_key: str = "",
|
|
618
|
+
) -> str:
|
|
619
|
+
"""Write a durable continuity snapshot for Desktop/Brain handoff."""
|
|
620
|
+
return _continuity_json_dumps(
|
|
621
|
+
write_snapshot(
|
|
622
|
+
conversation_id=conversation_id,
|
|
623
|
+
session_id=session_id,
|
|
624
|
+
external_session_id=external_session_id,
|
|
625
|
+
client=client,
|
|
626
|
+
event_type=event_type,
|
|
627
|
+
payload=payload,
|
|
628
|
+
trace_id=trace_id,
|
|
629
|
+
idempotency_key=idempotency_key,
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@mcp.tool
|
|
635
|
+
def nexo_continuity_snapshot_read(conversation_id: str = "", session_id: str = "", limit: int = 20) -> str:
|
|
636
|
+
"""Read recent continuity snapshots by conversation_id or session_id."""
|
|
637
|
+
return _continuity_json_dumps(
|
|
638
|
+
read_snapshot(conversation_id=conversation_id, session_id=session_id, limit=limit)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@mcp.tool
|
|
643
|
+
def nexo_continuity_resume_bundle(
|
|
644
|
+
conversation_id: str = "",
|
|
645
|
+
session_id: str = "",
|
|
646
|
+
external_session_id: str = "",
|
|
647
|
+
client: str = "",
|
|
648
|
+
token_budget: int = 2000,
|
|
649
|
+
) -> str:
|
|
650
|
+
"""Build the small continuity bundle Desktop injects after restore or stale resume loss."""
|
|
651
|
+
bundle = build_resume_bundle(
|
|
652
|
+
conversation_id=conversation_id,
|
|
653
|
+
session_id=session_id,
|
|
654
|
+
external_session_id=external_session_id,
|
|
655
|
+
client=client,
|
|
656
|
+
token_budget=token_budget,
|
|
657
|
+
)
|
|
658
|
+
if not bundle.get("unsafe_sid"):
|
|
659
|
+
bundle["message"] = format_bundle_text(bundle)
|
|
660
|
+
return _continuity_json_dumps(bundle)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@mcp.tool
|
|
664
|
+
def nexo_continuity_compaction_event(
|
|
665
|
+
conversation_id: str,
|
|
666
|
+
session_id: str = "",
|
|
667
|
+
payload: str = "",
|
|
668
|
+
trace_id: str = "",
|
|
669
|
+
event_type: str = "post_compact",
|
|
670
|
+
) -> str:
|
|
671
|
+
"""Persist a compaction-related continuity event into the canonical snapshot stream."""
|
|
672
|
+
return _continuity_json_dumps(
|
|
673
|
+
record_compaction_event(
|
|
674
|
+
conversation_id=conversation_id,
|
|
675
|
+
session_id=session_id,
|
|
676
|
+
payload=payload,
|
|
677
|
+
trace_id=trace_id,
|
|
678
|
+
event_type=event_type,
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@mcp.tool
|
|
684
|
+
def nexo_continuity_audit(conversation_id: str, limit: int = 50) -> str:
|
|
685
|
+
"""Return the forensic continuity timeline for a conversation."""
|
|
686
|
+
return _continuity_json_dumps(continuity_audit(conversation_id=conversation_id, limit=limit))
|
|
687
|
+
|
|
688
|
+
|
|
568
689
|
@mcp.tool
|
|
569
690
|
def nexo_context_packet(area: str, files: str = "") -> str:
|
|
570
691
|
"""Build a context packet for subagent injection. Returns learnings + changes + followups + preferences + cognitive memories for a specific area.
|
package/src/tools_sessions.py
CHANGED
|
@@ -198,6 +198,7 @@ def _session_portability_bundle(sid: str = "") -> dict:
|
|
|
198
198
|
"task": session_row["task"],
|
|
199
199
|
"client": session_row["session_client"],
|
|
200
200
|
"external_session_id": session_row["external_session_id"],
|
|
201
|
+
"conversation_id": session_row["conversation_id"],
|
|
201
202
|
"started_epoch": session_row["started_epoch"],
|
|
202
203
|
"last_update_epoch": session_row["last_update_epoch"],
|
|
203
204
|
"local_time": session_row["local_time"],
|
|
@@ -363,6 +364,7 @@ def handle_startup(
|
|
|
363
364
|
claude_session_id: str = "",
|
|
364
365
|
session_token: str = "",
|
|
365
366
|
session_client: str = "",
|
|
367
|
+
conversation_id: str = "",
|
|
366
368
|
) -> str:
|
|
367
369
|
"""Full startup sequence: register, clean, report.
|
|
368
370
|
|
|
@@ -393,12 +395,28 @@ def handle_startup(
|
|
|
393
395
|
# If we recovered the UUID from the coordination file, the only
|
|
394
396
|
# client that writes there is Claude Code.
|
|
395
397
|
inferred_client = "claude_code"
|
|
398
|
+
conversation = str(conversation_id or "").strip()
|
|
399
|
+
conflicts = []
|
|
400
|
+
if conversation:
|
|
401
|
+
cutoff = now_epoch() - SESSION_STALE_SECONDS
|
|
402
|
+
conn = get_db()
|
|
403
|
+
rows = conn.execute(
|
|
404
|
+
"""
|
|
405
|
+
SELECT sid, task, last_update_epoch, external_session_id, session_client
|
|
406
|
+
FROM sessions
|
|
407
|
+
WHERE conversation_id = ? AND last_update_epoch > ?
|
|
408
|
+
ORDER BY last_update_epoch DESC
|
|
409
|
+
""",
|
|
410
|
+
(conversation, cutoff),
|
|
411
|
+
).fetchall()
|
|
412
|
+
conflicts = [dict(row) for row in rows if row["sid"] != sid]
|
|
396
413
|
register_session(
|
|
397
414
|
sid,
|
|
398
415
|
task,
|
|
399
416
|
claude_session_id=linked_session_id,
|
|
400
417
|
external_session_id=linked_session_id,
|
|
401
418
|
session_client=inferred_client,
|
|
419
|
+
conversation_id=conversation,
|
|
402
420
|
)
|
|
403
421
|
# v43 hotfix: also register in session_claude_aliases so multi-
|
|
404
422
|
# conversation NEXO Desktop spawns (each with its own claude UUID)
|
|
@@ -420,6 +438,8 @@ def handle_startup(
|
|
|
420
438
|
inbox = get_inbox(sid)
|
|
421
439
|
|
|
422
440
|
lines = [f"SID: {sid}"]
|
|
441
|
+
if conversation:
|
|
442
|
+
lines.append(f"CONVERSATION_ID: {conversation}")
|
|
423
443
|
|
|
424
444
|
if cleaned > 0:
|
|
425
445
|
lines.append(f"Cleaned {cleaned} stale sessions.")
|
|
@@ -433,6 +453,16 @@ def handle_startup(
|
|
|
433
453
|
else:
|
|
434
454
|
lines.append("No other active sessions.")
|
|
435
455
|
|
|
456
|
+
if conflicts:
|
|
457
|
+
lines.append("")
|
|
458
|
+
lines.append("CONVERSATION CONFLICT:")
|
|
459
|
+
for row in conflicts[:3]:
|
|
460
|
+
age = _format_age(row["last_update_epoch"])
|
|
461
|
+
lines.append(
|
|
462
|
+
f" {row['sid']} ({age}) — {row['task']} "
|
|
463
|
+
f"[client={row.get('session_client') or '?'} external={row.get('external_session_id') or '?'}]"
|
|
464
|
+
)
|
|
465
|
+
|
|
436
466
|
if inbox:
|
|
437
467
|
lines.append("")
|
|
438
468
|
lines.append("PENDING MESSAGES:")
|
|
@@ -838,6 +838,81 @@
|
|
|
838
838
|
},
|
|
839
839
|
"triggers_after": []
|
|
840
840
|
},
|
|
841
|
+
"nexo_continuity_audit": {
|
|
842
|
+
"description": "Read the forensic continuity timeline for a conversation",
|
|
843
|
+
"category": "continuity",
|
|
844
|
+
"source": "server",
|
|
845
|
+
"requires": [],
|
|
846
|
+
"provides": [
|
|
847
|
+
"continuity_timeline"
|
|
848
|
+
],
|
|
849
|
+
"internal_calls": [],
|
|
850
|
+
"enforcement": {
|
|
851
|
+
"level": "none",
|
|
852
|
+
"rules": []
|
|
853
|
+
},
|
|
854
|
+
"triggers_after": []
|
|
855
|
+
},
|
|
856
|
+
"nexo_continuity_compaction_event": {
|
|
857
|
+
"description": "Persist a compaction-related continuity event",
|
|
858
|
+
"category": "continuity",
|
|
859
|
+
"source": "server",
|
|
860
|
+
"requires": [],
|
|
861
|
+
"provides": [
|
|
862
|
+
"continuity_snapshot"
|
|
863
|
+
],
|
|
864
|
+
"internal_calls": [],
|
|
865
|
+
"enforcement": {
|
|
866
|
+
"level": "none",
|
|
867
|
+
"rules": []
|
|
868
|
+
},
|
|
869
|
+
"triggers_after": []
|
|
870
|
+
},
|
|
871
|
+
"nexo_continuity_resume_bundle": {
|
|
872
|
+
"description": "Build the small continuity bundle used after restore/restart",
|
|
873
|
+
"category": "continuity",
|
|
874
|
+
"source": "server",
|
|
875
|
+
"requires": [],
|
|
876
|
+
"provides": [
|
|
877
|
+
"resume_bundle"
|
|
878
|
+
],
|
|
879
|
+
"internal_calls": [],
|
|
880
|
+
"enforcement": {
|
|
881
|
+
"level": "none",
|
|
882
|
+
"rules": []
|
|
883
|
+
},
|
|
884
|
+
"triggers_after": []
|
|
885
|
+
},
|
|
886
|
+
"nexo_continuity_snapshot_read": {
|
|
887
|
+
"description": "Read recent continuity snapshots by conversation or session",
|
|
888
|
+
"category": "continuity",
|
|
889
|
+
"source": "server",
|
|
890
|
+
"requires": [],
|
|
891
|
+
"provides": [
|
|
892
|
+
"continuity_snapshots"
|
|
893
|
+
],
|
|
894
|
+
"internal_calls": [],
|
|
895
|
+
"enforcement": {
|
|
896
|
+
"level": "none",
|
|
897
|
+
"rules": []
|
|
898
|
+
},
|
|
899
|
+
"triggers_after": []
|
|
900
|
+
},
|
|
901
|
+
"nexo_continuity_snapshot_write": {
|
|
902
|
+
"description": "Write a durable continuity snapshot",
|
|
903
|
+
"category": "continuity",
|
|
904
|
+
"source": "server",
|
|
905
|
+
"requires": [],
|
|
906
|
+
"provides": [
|
|
907
|
+
"continuity_snapshot"
|
|
908
|
+
],
|
|
909
|
+
"internal_calls": [],
|
|
910
|
+
"enforcement": {
|
|
911
|
+
"level": "none",
|
|
912
|
+
"rules": []
|
|
913
|
+
},
|
|
914
|
+
"triggers_after": []
|
|
915
|
+
},
|
|
841
916
|
"nexo_cortex_check": {
|
|
842
917
|
"description": "Cognitive pre-action check",
|
|
843
918
|
"category": "cortex",
|