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
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
|
|
package/src/db/_sessions.py
CHANGED
|
@@ -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
|
-
(
|
|
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 {
|
|
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
|
-
|
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
|
@@ -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
|
-
|
|
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:
|
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
|
|
package/src/plugins/update.py
CHANGED
|
@@ -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)
|