nexo-brain 7.9.22 → 7.9.24
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 +5 -1
- package/package.json +1 -1
- package/src/lifecycle_events.py +227 -0
- package/src/plugins/lifecycle_events.py +6 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.24",
|
|
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,11 @@
|
|
|
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.24` is the current packaged-runtime line. Patch release over `7.9.23`: Desktop lifecycle shutdown now resolves alias-only diary SIDs back to the registered NEXO session before stopping, so app-exit can preserve the diary and confirm the real session is closed.
|
|
22
|
+
|
|
23
|
+
Previously in `7.9.23`: Desktop lifecycle fallback diaries now enrich sparse lifecycle events from continuity snapshots, so app-exit fallback evidence preserves recent turn context even when the live agent does not answer the injected diary prompt before shutdown.
|
|
24
|
+
|
|
25
|
+
Previously in `7.9.22`: Desktop lifecycle shutdowns gained an emergency Brain-side fallback diary path, so close/archive/app-exit can preserve title, goal, session ids, and transcript tail even when the live agent does not answer the injected diary prompt before shutdown.
|
|
22
26
|
|
|
23
27
|
Previously in `7.9.21`: LaunchAgent reload/repair now handles macOS already-loaded races by booting out jobs with modern launchctl forms, falling back to legacy load, and treating an already-loaded job as healthy only when it points at the expected plist.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.24",
|
|
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/lifecycle_events.py
CHANGED
|
@@ -127,6 +127,109 @@ def _session_diary_session_ids(conn, session_id: str) -> List[str]:
|
|
|
127
127
|
return deduped
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def _registered_session_ids(conn, session_id: str, candidates: Optional[List[str]] = None) -> List[str]:
|
|
131
|
+
"""Return real active-session table ids linked to a lifecycle session id."""
|
|
132
|
+
raw = str(session_id or "").strip()
|
|
133
|
+
ids = [str(s or "").strip() for s in list(candidates or []) if str(s or "").strip()]
|
|
134
|
+
if raw and raw not in ids:
|
|
135
|
+
ids.append(raw)
|
|
136
|
+
rows = []
|
|
137
|
+
try:
|
|
138
|
+
if ids:
|
|
139
|
+
placeholders = ",".join("?" for _ in ids)
|
|
140
|
+
rows = conn.execute(
|
|
141
|
+
"SELECT sid FROM sessions "
|
|
142
|
+
f"WHERE sid IN ({placeholders}) OR external_session_id = ? OR claude_session_id = ? "
|
|
143
|
+
"ORDER BY last_update_epoch DESC",
|
|
144
|
+
(*ids, raw, raw),
|
|
145
|
+
).fetchall()
|
|
146
|
+
elif raw:
|
|
147
|
+
rows = conn.execute(
|
|
148
|
+
"SELECT sid FROM sessions "
|
|
149
|
+
"WHERE external_session_id = ? OR claude_session_id = ? OR sid = ? "
|
|
150
|
+
"ORDER BY last_update_epoch DESC",
|
|
151
|
+
(raw, raw, raw),
|
|
152
|
+
).fetchall()
|
|
153
|
+
except Exception:
|
|
154
|
+
rows = []
|
|
155
|
+
|
|
156
|
+
deduped: List[str] = []
|
|
157
|
+
seen = set()
|
|
158
|
+
for row in rows:
|
|
159
|
+
sid = str(row[0] or "").strip() if row else ""
|
|
160
|
+
if sid and sid not in seen:
|
|
161
|
+
seen.add(sid)
|
|
162
|
+
deduped.append(sid)
|
|
163
|
+
return deduped
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _linked_external_session_ids(conn, session_id: str) -> List[str]:
|
|
167
|
+
"""Return external Claude/Desktop ids linked to a NEXO SID or raw session id."""
|
|
168
|
+
raw = str(session_id or "").strip()
|
|
169
|
+
if not raw:
|
|
170
|
+
return []
|
|
171
|
+
ids: List[str] = []
|
|
172
|
+
if not raw.startswith("nexo-"):
|
|
173
|
+
ids.append(raw)
|
|
174
|
+
try:
|
|
175
|
+
rows = conn.execute(
|
|
176
|
+
"SELECT claude_session_id FROM session_claude_aliases WHERE sid = ? "
|
|
177
|
+
"ORDER BY last_seen DESC",
|
|
178
|
+
(raw,),
|
|
179
|
+
).fetchall()
|
|
180
|
+
ids.extend(str(row[0] or "").strip() for row in rows if row and row[0])
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
try:
|
|
184
|
+
rows = conn.execute(
|
|
185
|
+
"SELECT external_session_id, claude_session_id FROM sessions WHERE sid = ?",
|
|
186
|
+
(raw,),
|
|
187
|
+
).fetchall()
|
|
188
|
+
for row in rows:
|
|
189
|
+
ids.extend(str(value or "").strip() for value in row if value)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
deduped: List[str] = []
|
|
194
|
+
seen = set()
|
|
195
|
+
for external_id in ids:
|
|
196
|
+
if external_id and external_id not in seen:
|
|
197
|
+
seen.add(external_id)
|
|
198
|
+
deduped.append(external_id)
|
|
199
|
+
return deduped
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _registered_stop_session_ids(conn, session_id: str) -> List[str]:
|
|
203
|
+
"""Resolve a lifecycle stop target to real registered NEXO SIDs."""
|
|
204
|
+
raw = str(session_id or "").strip()
|
|
205
|
+
if not raw:
|
|
206
|
+
return []
|
|
207
|
+
candidates = _session_diary_session_ids(conn, raw)
|
|
208
|
+
external_ids = _linked_external_session_ids(conn, raw)
|
|
209
|
+
for external_id in external_ids:
|
|
210
|
+
candidates.extend(_session_diary_session_ids(conn, external_id))
|
|
211
|
+
|
|
212
|
+
registered: List[str] = []
|
|
213
|
+
registered.extend(_registered_session_ids(conn, raw, candidates))
|
|
214
|
+
for external_id in external_ids:
|
|
215
|
+
registered.extend(_registered_session_ids(conn, external_id, candidates))
|
|
216
|
+
|
|
217
|
+
deduped: List[str] = []
|
|
218
|
+
seen = set()
|
|
219
|
+
for sid in registered:
|
|
220
|
+
if sid and sid not in seen:
|
|
221
|
+
seen.add(sid)
|
|
222
|
+
deduped.append(sid)
|
|
223
|
+
if deduped:
|
|
224
|
+
return deduped
|
|
225
|
+
return [raw] if raw.startswith("nexo-") else []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def registered_stop_session_ids(session_id: str) -> List[str]:
|
|
229
|
+
"""Public wrapper used by plugin handlers before calling nexo_stop."""
|
|
230
|
+
return _registered_stop_session_ids(get_db(), session_id)
|
|
231
|
+
|
|
232
|
+
|
|
130
233
|
def _session_is_linked_to_nexo(conn, session_id: str) -> bool:
|
|
131
234
|
"""True when the external Claude/Desktop session is linked to a NEXO SID."""
|
|
132
235
|
raw = str(session_id or "").strip()
|
|
@@ -264,6 +367,9 @@ def _preferred_diary_session_id(conn, session_id: str) -> str:
|
|
|
264
367
|
"""Return the best session id to store fallback diary evidence under."""
|
|
265
368
|
raw = str(session_id or "").strip()
|
|
266
369
|
candidates = _session_diary_session_ids(conn, raw)
|
|
370
|
+
for sid in _registered_session_ids(conn, raw, candidates):
|
|
371
|
+
if str(sid or "").startswith("nexo-"):
|
|
372
|
+
return str(sid)
|
|
267
373
|
for sid in candidates:
|
|
268
374
|
if str(sid or "").startswith("nexo-"):
|
|
269
375
|
return str(sid)
|
|
@@ -278,6 +384,18 @@ def _payload_lines(payload: Dict[str, Any]) -> List[str]:
|
|
|
278
384
|
text = str(raw or "").strip()
|
|
279
385
|
if text:
|
|
280
386
|
lines.append(text)
|
|
387
|
+
if not lines:
|
|
388
|
+
messages = payload.get("messages")
|
|
389
|
+
if isinstance(messages, list):
|
|
390
|
+
for item in messages[-12:]:
|
|
391
|
+
if isinstance(item, dict):
|
|
392
|
+
role = str(item.get("role") or item.get("type") or item.get("sender") or "message").strip()
|
|
393
|
+
text = str(item.get("content") or item.get("text") or "").strip()
|
|
394
|
+
else:
|
|
395
|
+
role = "message"
|
|
396
|
+
text = str(item or "").strip()
|
|
397
|
+
if text:
|
|
398
|
+
lines.append(f"{role}: {text}")
|
|
281
399
|
if not lines:
|
|
282
400
|
user = str(payload.get("last_user_message") or payload.get("latest_user_text") or "").strip()
|
|
283
401
|
assistant = str(payload.get("last_assistant_message") or payload.get("latest_assistant_text") or "").strip()
|
|
@@ -288,6 +406,114 @@ def _payload_lines(payload: Dict[str, Any]) -> List[str]:
|
|
|
288
406
|
return lines[-12:]
|
|
289
407
|
|
|
290
408
|
|
|
409
|
+
def _parse_payload_json(raw: Any) -> Dict[str, Any]:
|
|
410
|
+
try:
|
|
411
|
+
parsed = json.loads(raw or "{}")
|
|
412
|
+
except Exception:
|
|
413
|
+
return {}
|
|
414
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _payload_has_context(payload: Dict[str, Any]) -> bool:
|
|
418
|
+
if not isinstance(payload, dict):
|
|
419
|
+
return False
|
|
420
|
+
if _payload_lines(payload):
|
|
421
|
+
return True
|
|
422
|
+
for key in ("current_goal", "last_user_message", "latest_user_text", "last_assistant_message", "latest_assistant_text"):
|
|
423
|
+
if str(payload.get(key) or "").strip():
|
|
424
|
+
return True
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _continuity_payloads_for_event(
|
|
429
|
+
conn,
|
|
430
|
+
conversation_id: str,
|
|
431
|
+
session_id: str,
|
|
432
|
+
limit: int = 24,
|
|
433
|
+
) -> List[Dict[str, Any]]:
|
|
434
|
+
"""Return recent continuity payloads for richer emergency diaries."""
|
|
435
|
+
conv = str(conversation_id or "").strip()
|
|
436
|
+
sid = str(session_id or "").strip()
|
|
437
|
+
if not conv and not sid:
|
|
438
|
+
return []
|
|
439
|
+
|
|
440
|
+
where_parts: List[str] = []
|
|
441
|
+
params: List[Any] = []
|
|
442
|
+
if conv:
|
|
443
|
+
where_parts.append("conversation_id = ?")
|
|
444
|
+
params.append(conv)
|
|
445
|
+
if sid:
|
|
446
|
+
where_parts.append("session_id = ?")
|
|
447
|
+
params.append(sid)
|
|
448
|
+
where = " OR ".join(where_parts)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
rows = conn.execute(
|
|
452
|
+
"SELECT event_type, payload_json FROM continuity_snapshots "
|
|
453
|
+
f"WHERE ({where}) "
|
|
454
|
+
"ORDER BY id DESC LIMIT ?",
|
|
455
|
+
(*params, int(limit)),
|
|
456
|
+
).fetchall()
|
|
457
|
+
except Exception:
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
payloads: List[Dict[str, Any]] = []
|
|
461
|
+
for row in reversed(rows):
|
|
462
|
+
event_type = str(row[0] or "").strip()
|
|
463
|
+
if event_type and event_type not in {"turn_end", "app_exit", "desktop_snapshot", "close", "archive"}:
|
|
464
|
+
continue
|
|
465
|
+
payload = _parse_payload_json(row[1] if len(row) > 1 else "")
|
|
466
|
+
if payload:
|
|
467
|
+
payloads.append(payload)
|
|
468
|
+
return payloads
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _dedupe_lines(lines: List[str]) -> List[str]:
|
|
472
|
+
seen = set()
|
|
473
|
+
result: List[str] = []
|
|
474
|
+
for raw in lines:
|
|
475
|
+
line = str(raw or "").strip()
|
|
476
|
+
if not line or line in seen:
|
|
477
|
+
continue
|
|
478
|
+
seen.add(line)
|
|
479
|
+
result.append(line)
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _enrich_payload_from_continuity(
|
|
484
|
+
conn,
|
|
485
|
+
payload: Dict[str, Any],
|
|
486
|
+
conversation_id: str,
|
|
487
|
+
session_id: str,
|
|
488
|
+
) -> Dict[str, Any]:
|
|
489
|
+
"""Fill sparse lifecycle payloads from durable continuity snapshots."""
|
|
490
|
+
enriched = dict(payload or {})
|
|
491
|
+
continuity_payloads = _continuity_payloads_for_event(conn, conversation_id, session_id)
|
|
492
|
+
if not continuity_payloads:
|
|
493
|
+
return enriched
|
|
494
|
+
|
|
495
|
+
latest = continuity_payloads[-1]
|
|
496
|
+
for key in (
|
|
497
|
+
"title",
|
|
498
|
+
"current_goal",
|
|
499
|
+
"last_user_message",
|
|
500
|
+
"latest_user_text",
|
|
501
|
+
"last_assistant_message",
|
|
502
|
+
"latest_assistant_text",
|
|
503
|
+
):
|
|
504
|
+
if not str(enriched.get(key) or "").strip() and str(latest.get(key) or "").strip():
|
|
505
|
+
enriched[key] = latest[key]
|
|
506
|
+
|
|
507
|
+
continuity_lines: List[str] = []
|
|
508
|
+
for item in continuity_payloads:
|
|
509
|
+
continuity_lines.extend(_payload_lines(item))
|
|
510
|
+
continuity_lines = _dedupe_lines(continuity_lines)
|
|
511
|
+
current_lines = _payload_lines(enriched)
|
|
512
|
+
if len(continuity_lines) > len(current_lines) or not _payload_has_context(enriched):
|
|
513
|
+
enriched["transcript_tail"] = continuity_lines[-12:]
|
|
514
|
+
return enriched
|
|
515
|
+
|
|
516
|
+
|
|
291
517
|
def write_fallback_diary_for_lifecycle_event(
|
|
292
518
|
event_id: str,
|
|
293
519
|
reason: str = "",
|
|
@@ -341,6 +567,7 @@ def write_fallback_diary_for_lifecycle_event(
|
|
|
341
567
|
payload = {}
|
|
342
568
|
except Exception:
|
|
343
569
|
payload = {}
|
|
570
|
+
payload = _enrich_payload_from_continuity(conn, payload, conversation_id, session_id)
|
|
344
571
|
|
|
345
572
|
title = str(payload.get("title") or conversation_id or event_id).strip()
|
|
346
573
|
transcript_lines = _payload_lines(payload)
|
|
@@ -217,11 +217,14 @@ def handle_nexo_lifecycle_stop_nexo_session(
|
|
|
217
217
|
try:
|
|
218
218
|
from tools_sessions import handle_stop
|
|
219
219
|
|
|
220
|
-
|
|
220
|
+
raw_sid = str(sid or "").strip()
|
|
221
|
+
stop_sids = lifecycle_events.registered_stop_session_ids(raw_sid) or [raw_sid]
|
|
222
|
+
messages = [handle_stop(stop_sid) for stop_sid in stop_sids]
|
|
221
223
|
return json.dumps({
|
|
222
224
|
"status": "ok",
|
|
223
|
-
"sid":
|
|
224
|
-
"
|
|
225
|
+
"sid": raw_sid,
|
|
226
|
+
"stopped_session_ids": stop_sids,
|
|
227
|
+
"message": " ".join(messages),
|
|
225
228
|
}, ensure_ascii=False)
|
|
226
229
|
except Exception as exc:
|
|
227
230
|
return json.dumps({
|