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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.22",
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.22` is the current packaged-runtime line. Patch release over `7.9.21`: Desktop lifecycle shutdowns now have 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.
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.22",
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",
@@ -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
- message = handle_stop(str(sid or ""))
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": str(sid or ""),
224
- "message": message,
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({