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.
@@ -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.
@@ -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",