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/src/db/_schema.py CHANGED
@@ -1393,6 +1393,37 @@ def _m52_lifecycle_canonical_plan(conn):
1393
1393
  _migrate_add_index(conn, "idx_lifecycle_events_plan_id", "lifecycle_events", "canonical_plan_id")
1394
1394
 
1395
1395
 
1396
+ def _m53_session_conversation_identity(conn):
1397
+ """Stable Desktop conversation identity independent from the runtime SID."""
1398
+ _migrate_add_column(conn, "sessions", "conversation_id", "TEXT DEFAULT ''")
1399
+ _migrate_add_index(conn, "idx_sessions_conversation_id", "sessions", "conversation_id")
1400
+
1401
+
1402
+ def _m54_continuity_snapshots(conn):
1403
+ """Durable continuity snapshots for Desktop/Brain handoff and audit."""
1404
+ conn.execute(
1405
+ """
1406
+ CREATE TABLE IF NOT EXISTS continuity_snapshots (
1407
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1408
+ conversation_id TEXT NOT NULL,
1409
+ session_id TEXT DEFAULT '',
1410
+ external_session_id TEXT DEFAULT '',
1411
+ client TEXT DEFAULT '',
1412
+ event_type TEXT NOT NULL DEFAULT 'turn_end',
1413
+ payload_json TEXT NOT NULL DEFAULT '{}',
1414
+ trace_id TEXT DEFAULT '',
1415
+ idempotency_key TEXT NOT NULL DEFAULT '',
1416
+ created_at TEXT DEFAULT (datetime('now')),
1417
+ updated_at TEXT DEFAULT (datetime('now')),
1418
+ UNIQUE(conversation_id, idempotency_key)
1419
+ )
1420
+ """
1421
+ )
1422
+ _migrate_add_index(conn, "idx_continuity_snapshots_conv", "continuity_snapshots", "conversation_id")
1423
+ _migrate_add_index(conn, "idx_continuity_snapshots_sid", "continuity_snapshots", "session_id")
1424
+ _migrate_add_index(conn, "idx_continuity_snapshots_created", "continuity_snapshots", "created_at")
1425
+
1426
+
1396
1427
  MIGRATIONS = [
1397
1428
  (1, "learnings_columns", _m1_learnings_columns),
1398
1429
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1446,6 +1477,8 @@ MIGRATIONS = [
1446
1477
  (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1447
1478
  (51, "lifecycle_events", _m51_lifecycle_events),
1448
1479
  (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1480
+ (53, "session_conversation_identity", _m53_session_conversation_identity),
1481
+ (54, "continuity_snapshots", _m54_continuity_snapshots),
1449
1482
  ]
1450
1483
 
1451
1484
 
@@ -41,6 +41,7 @@ def register_session(
41
41
  *,
42
42
  external_session_id: str = "",
43
43
  session_client: str = "",
44
+ conversation_id: str = "",
44
45
  ) -> dict:
45
46
  """Register or re-register a session."""
46
47
  sid = _validate_sid(sid)
@@ -48,12 +49,28 @@ def register_session(
48
49
  now = now_epoch()
49
50
  linked_session_id = (external_session_id or claude_session_id or "").strip()
50
51
  conn.execute(
51
- "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id, external_session_id, session_client) "
52
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
53
- (sid, task, now, now, local_time_str(), linked_session_id, linked_session_id, (session_client or "").strip())
52
+ "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id, external_session_id, session_client, conversation_id) "
53
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
54
+ (
55
+ sid,
56
+ task,
57
+ now,
58
+ now,
59
+ local_time_str(),
60
+ linked_session_id,
61
+ linked_session_id,
62
+ (session_client or "").strip(),
63
+ (conversation_id or "").strip(),
64
+ )
54
65
  )
55
66
  conn.commit()
56
- return {"sid": sid, "task": task, "external_session_id": linked_session_id, "session_client": (session_client or "").strip()}
67
+ return {
68
+ "sid": sid,
69
+ "task": task,
70
+ "external_session_id": linked_session_id,
71
+ "session_client": (session_client or "").strip(),
72
+ "conversation_id": (conversation_id or "").strip(),
73
+ }
57
74
 
58
75
 
59
76
  def update_session(sid: str, task: str | None) -> dict:
@@ -99,7 +116,7 @@ def get_active_sessions() -> list[dict]:
99
116
  conn = get_db()
100
117
  cutoff = now_epoch() - SESSION_STALE_SECONDS
101
118
  rows = conn.execute(
102
- "SELECT sid, task, started_epoch, last_update_epoch, local_time "
119
+ "SELECT sid, task, started_epoch, last_update_epoch, local_time, conversation_id "
103
120
  "FROM sessions WHERE last_update_epoch > ?",
104
121
  (cutoff,)
105
122
  ).fetchall()
@@ -232,7 +249,7 @@ def search_sessions(keyword: str) -> list[dict]:
232
249
  conn = get_db()
233
250
  cutoff = now_epoch() - SESSION_STALE_SECONDS
234
251
  rows = conn.execute(
235
- "SELECT sid, task, last_update_epoch, local_time FROM sessions "
252
+ "SELECT sid, task, last_update_epoch, local_time, conversation_id FROM sessions "
236
253
  "WHERE last_update_epoch > ? AND LOWER(task) LIKE ?",
237
254
  (cutoff, f"%{keyword.lower()}%")
238
255
  ).fetchall()
@@ -431,4 +448,3 @@ def _expire_old_questions(conn: sqlite3.Connection):
431
448
  "WHERE status = 'pending' AND created_epoch < ?",
432
449
  (cutoff,)
433
450
  )
434
-
@@ -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 any_failure:
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": "canonical-diary-not-confirmed" if diary_missing else None,
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
@@ -70,7 +70,30 @@ def home() -> Path:
70
70
  # Core (shipped with the package, replaced on every `nexo update`)
71
71
  # ---------------------------------------------------------------------------
72
72
  def core_dir() -> Path:
73
- return home() / "core"
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
87
+ current = container / "current"
88
+ if current.exists():
89
+ try:
90
+ resolved = current.resolve(strict=False)
91
+ if resolved.exists():
92
+ return resolved
93
+ except Exception:
94
+ return current
95
+ return current
96
+ return container
74
97
 
75
98
 
76
99
  def core_scripts_dir() -> Path:
@@ -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
- t = Tool.from_function(func, name=name, description=description)
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
 
@@ -12,6 +12,7 @@ import time
12
12
  from pathlib import Path
13
13
 
14
14
  from runtime_home import export_resolved_nexo_home
15
+ from runtime_versioning import activate_versioned_runtime_snapshot, write_restart_required_marker
15
16
 
16
17
  try:
17
18
  from tree_hygiene import is_duplicate_artifact_name
@@ -1228,6 +1229,25 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1228
1229
  except Exception as e:
1229
1230
  launchagent_reload_warning = f"launchagent reload error: {e}"
1230
1231
 
1232
+ versioned_runtime_summary = None
1233
+ restart_marker_summary = None
1234
+ if old_version != new_version:
1235
+ try:
1236
+ _emit_progress(progress_fn, "Activating versioned runtime snapshot...")
1237
+ versioned_runtime_summary = activate_versioned_runtime_snapshot(
1238
+ source_root=_runtime_code_root(),
1239
+ version=new_version,
1240
+ )
1241
+ except Exception as e:
1242
+ errors.append(f"versioned runtime activation: {e}")
1243
+ try:
1244
+ restart_marker_summary = write_restart_required_marker(
1245
+ from_version=old_version,
1246
+ to_version=new_version,
1247
+ )
1248
+ except Exception as e:
1249
+ errors.append(f"restart marker: {e}")
1250
+
1231
1251
  if errors:
1232
1252
  # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
1233
1253
  if code_backup_dir:
@@ -1289,6 +1309,10 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1289
1309
  )
1290
1310
  else:
1291
1311
  lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
1312
+ if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
1313
+ lines.append(f" Runtime activation: core/current -> versions/{new_version}")
1314
+ if restart_marker_summary:
1315
+ lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
1292
1316
  lines.append("")
1293
1317
  lines.append("MCP server restart needed to load new code.")
1294
1318
  return "\n".join(lines)
@@ -1499,6 +1523,27 @@ def handle_update(
1499
1523
  except Exception:
1500
1524
  pass # Non-critical, configs can be re-synced later
1501
1525
 
1526
+ versioned_runtime_summary = None
1527
+ restart_marker_summary = None
1528
+ if version_changed:
1529
+ try:
1530
+ _emit_progress(progress_fn, "Activating versioned runtime snapshot...")
1531
+ versioned_runtime_summary = activate_versioned_runtime_snapshot(
1532
+ source_root=SRC_DIR,
1533
+ version=new_version,
1534
+ )
1535
+ steps_done.append("versioned-runtime")
1536
+ except Exception as e:
1537
+ raise RuntimeError(f"Versioned runtime activation failed: {e}")
1538
+ try:
1539
+ restart_marker_summary = write_restart_required_marker(
1540
+ from_version=old_version,
1541
+ to_version=new_version,
1542
+ )
1543
+ steps_done.append("restart-marker")
1544
+ except Exception as e:
1545
+ raise RuntimeError(f"Restart marker write failed: {e}")
1546
+
1502
1547
  # Build result
1503
1548
  dep_summary_lines = _format_dep_results(dep_results)
1504
1549
  if pull_out == "Already up to date.":
@@ -1529,6 +1574,10 @@ def handle_update(
1529
1574
  lines.extend(external_cli_lines)
1530
1575
  if "client-sync" in steps_done:
1531
1576
  lines.append(" Clients: configured client targets synced")
1577
+ if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
1578
+ lines.append(f" Runtime activation: core/current -> versions/{new_version}")
1579
+ if restart_marker_summary:
1580
+ lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
1532
1581
  lines.append("")
1533
1582
  lines.append("MCP server restart needed to load new code.")
1534
1583
  return "\n".join(lines)