nexo-brain 7.9.21 → 7.9.23

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.21",
3
+ "version": "7.9.23",
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` is the current packaged-runtime line. Patch release over `7.9.20`: 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.
21
+ Version `7.9.23` is the current packaged-runtime line. Patch release over `7.9.22`: 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.
22
+
23
+ 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.
24
+
25
+ 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.
22
26
 
23
27
  Previously in `7.9.20`: packaged update/doctor repair now finds `runtime/crons/sync.py`, LaunchAgent PATH includes the managed Claude runtime installed under `~/.nexo/runtime/bootstrap/npm-global/bin`, root runtime backfill includes `claude_cli.py`, and Immune no longer treats the legacy optional `~/.claude-mem/claude-mem.db` as a required database.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.21",
3
+ "version": "7.9.23",
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/cli.py CHANGED
@@ -3232,6 +3232,14 @@ def main():
3232
3232
  lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
3233
3233
  lwait_p.add_argument("--poll-ms", type=int, default=500)
3234
3234
 
3235
+ lfallback_diary_p = lifecycle_sub.add_parser(
3236
+ "write-fallback-diary",
3237
+ help="v7.9.22: write emergency diary evidence for a lifecycle event",
3238
+ )
3239
+ lfallback_diary_p.add_argument("--event-id", required=True)
3240
+ lfallback_diary_p.add_argument("--reason", default="")
3241
+ lfallback_diary_p.add_argument("--source", default="desktop-lifecycle-fallback")
3242
+
3235
3243
  lwait_stop_p = lifecycle_sub.add_parser(
3236
3244
  "wait-for-stop",
3237
3245
  help="v7.9.10: wait until the linked NEXO session is no longer active",
@@ -3547,6 +3555,23 @@ def main():
3547
3555
  if status == "retryable_error":
3548
3556
  return 2
3549
3557
  return 3
3558
+ if args.lifecycle_command == "write-fallback-diary":
3559
+ out = _lifecycle_plugin.handle_nexo_lifecycle_write_fallback_diary(
3560
+ event_id=args.event_id,
3561
+ reason=args.reason or "",
3562
+ source=args.source or "desktop-lifecycle-fallback",
3563
+ )
3564
+ print(out)
3565
+ try:
3566
+ parsed = _json.loads(out)
3567
+ status = str(parsed.get("status", ""))
3568
+ except Exception:
3569
+ status = ""
3570
+ if status in ("ok", "processed", "already_processed"):
3571
+ return 0
3572
+ if status == "retryable_error":
3573
+ return 2
3574
+ return 3
3550
3575
  if args.lifecycle_command == "wait-for-stop":
3551
3576
  out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_stop(
3552
3577
  event_id=args.event_id,
@@ -260,6 +260,259 @@ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], ac
260
260
  return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
261
261
 
262
262
 
263
+ def _preferred_diary_session_id(conn, session_id: str) -> str:
264
+ """Return the best session id to store fallback diary evidence under."""
265
+ raw = str(session_id or "").strip()
266
+ candidates = _session_diary_session_ids(conn, raw)
267
+ for sid in candidates:
268
+ if str(sid or "").startswith("nexo-"):
269
+ return str(sid)
270
+ return candidates[0] if candidates else raw
271
+
272
+
273
+ def _payload_lines(payload: Dict[str, Any]) -> List[str]:
274
+ lines: List[str] = []
275
+ if not isinstance(payload, dict):
276
+ return lines
277
+ for raw in payload.get("transcript_tail") or []:
278
+ text = str(raw or "").strip()
279
+ if text:
280
+ lines.append(text)
281
+ if not lines:
282
+ messages = payload.get("messages")
283
+ if isinstance(messages, list):
284
+ for item in messages[-12:]:
285
+ if isinstance(item, dict):
286
+ role = str(item.get("role") or item.get("type") or item.get("sender") or "message").strip()
287
+ text = str(item.get("content") or item.get("text") or "").strip()
288
+ else:
289
+ role = "message"
290
+ text = str(item or "").strip()
291
+ if text:
292
+ lines.append(f"{role}: {text}")
293
+ if not lines:
294
+ user = str(payload.get("last_user_message") or payload.get("latest_user_text") or "").strip()
295
+ assistant = str(payload.get("last_assistant_message") or payload.get("latest_assistant_text") or "").strip()
296
+ if user:
297
+ lines.append(f"user: {user}")
298
+ if assistant:
299
+ lines.append(f"assistant: {assistant}")
300
+ return lines[-12:]
301
+
302
+
303
+ def _parse_payload_json(raw: Any) -> Dict[str, Any]:
304
+ try:
305
+ parsed = json.loads(raw or "{}")
306
+ except Exception:
307
+ return {}
308
+ return parsed if isinstance(parsed, dict) else {}
309
+
310
+
311
+ def _payload_has_context(payload: Dict[str, Any]) -> bool:
312
+ if not isinstance(payload, dict):
313
+ return False
314
+ if _payload_lines(payload):
315
+ return True
316
+ for key in ("current_goal", "last_user_message", "latest_user_text", "last_assistant_message", "latest_assistant_text"):
317
+ if str(payload.get(key) or "").strip():
318
+ return True
319
+ return False
320
+
321
+
322
+ def _continuity_payloads_for_event(
323
+ conn,
324
+ conversation_id: str,
325
+ session_id: str,
326
+ limit: int = 24,
327
+ ) -> List[Dict[str, Any]]:
328
+ """Return recent continuity payloads for richer emergency diaries."""
329
+ conv = str(conversation_id or "").strip()
330
+ sid = str(session_id or "").strip()
331
+ if not conv and not sid:
332
+ return []
333
+
334
+ where_parts: List[str] = []
335
+ params: List[Any] = []
336
+ if conv:
337
+ where_parts.append("conversation_id = ?")
338
+ params.append(conv)
339
+ if sid:
340
+ where_parts.append("session_id = ?")
341
+ params.append(sid)
342
+ where = " OR ".join(where_parts)
343
+
344
+ try:
345
+ rows = conn.execute(
346
+ "SELECT event_type, payload_json FROM continuity_snapshots "
347
+ f"WHERE ({where}) "
348
+ "ORDER BY id DESC LIMIT ?",
349
+ (*params, int(limit)),
350
+ ).fetchall()
351
+ except Exception:
352
+ return []
353
+
354
+ payloads: List[Dict[str, Any]] = []
355
+ for row in reversed(rows):
356
+ event_type = str(row[0] or "").strip()
357
+ if event_type and event_type not in {"turn_end", "app_exit", "desktop_snapshot", "close", "archive"}:
358
+ continue
359
+ payload = _parse_payload_json(row[1] if len(row) > 1 else "")
360
+ if payload:
361
+ payloads.append(payload)
362
+ return payloads
363
+
364
+
365
+ def _dedupe_lines(lines: List[str]) -> List[str]:
366
+ seen = set()
367
+ result: List[str] = []
368
+ for raw in lines:
369
+ line = str(raw or "").strip()
370
+ if not line or line in seen:
371
+ continue
372
+ seen.add(line)
373
+ result.append(line)
374
+ return result
375
+
376
+
377
+ def _enrich_payload_from_continuity(
378
+ conn,
379
+ payload: Dict[str, Any],
380
+ conversation_id: str,
381
+ session_id: str,
382
+ ) -> Dict[str, Any]:
383
+ """Fill sparse lifecycle payloads from durable continuity snapshots."""
384
+ enriched = dict(payload or {})
385
+ continuity_payloads = _continuity_payloads_for_event(conn, conversation_id, session_id)
386
+ if not continuity_payloads:
387
+ return enriched
388
+
389
+ latest = continuity_payloads[-1]
390
+ for key in (
391
+ "title",
392
+ "current_goal",
393
+ "last_user_message",
394
+ "latest_user_text",
395
+ "last_assistant_message",
396
+ "latest_assistant_text",
397
+ ):
398
+ if not str(enriched.get(key) or "").strip() and str(latest.get(key) or "").strip():
399
+ enriched[key] = latest[key]
400
+
401
+ continuity_lines: List[str] = []
402
+ for item in continuity_payloads:
403
+ continuity_lines.extend(_payload_lines(item))
404
+ continuity_lines = _dedupe_lines(continuity_lines)
405
+ current_lines = _payload_lines(enriched)
406
+ if len(continuity_lines) > len(current_lines) or not _payload_has_context(enriched):
407
+ enriched["transcript_tail"] = continuity_lines[-12:]
408
+ return enriched
409
+
410
+
411
+ def write_fallback_diary_for_lifecycle_event(
412
+ event_id: str,
413
+ reason: str = "",
414
+ source: str = "desktop-lifecycle-fallback",
415
+ ) -> Dict[str, Any]:
416
+ """Write minimum durable diary evidence when live-agent injection fails.
417
+
418
+ This is the safety net for Desktop close/archive/app-exit. The preferred
419
+ path remains an agent-authored ``nexo_session_diary_write``. If the agent is
420
+ busy or stdin never produces a response, Desktop can call this command so
421
+ session continuity still has a concrete ``session_diary`` row instead of
422
+ silently losing the last context.
423
+ """
424
+ if not event_id:
425
+ return {"status": "rejected", "reason": "missing-event-id"}
426
+
427
+ conn = get_db()
428
+ row = conn.execute(
429
+ "SELECT action, conversation_id, session_id, reason, payload_snapshot, "
430
+ "canonical_dispatched_at, canonical_actions_json "
431
+ "FROM lifecycle_events WHERE event_id = ?",
432
+ (str(event_id),),
433
+ ).fetchone()
434
+ if row is None:
435
+ return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
436
+
437
+ action = str(row[0] or "")
438
+ conversation_id = str(row[1] or "")
439
+ session_id = str(row[2] or "")
440
+ lifecycle_reason = str(row[3] or "")
441
+ dispatched_at = row[5]
442
+ actions_json = row[6]
443
+ if action not in _DIARY_TRIGGERING:
444
+ return {"status": "processed", "event_id": event_id, "diary_required": False}
445
+ if not session_id:
446
+ return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
447
+
448
+ existing = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
449
+ if existing is not None:
450
+ return {
451
+ "status": "ok",
452
+ "event_id": event_id,
453
+ "fallback_written": False,
454
+ "diary_confirmed": True,
455
+ **existing,
456
+ }
457
+
458
+ try:
459
+ payload = json.loads(row[4] or "{}")
460
+ if not isinstance(payload, dict):
461
+ payload = {}
462
+ except Exception:
463
+ payload = {}
464
+ payload = _enrich_payload_from_continuity(conn, payload, conversation_id, session_id)
465
+
466
+ title = str(payload.get("title") or conversation_id or event_id).strip()
467
+ transcript_lines = _payload_lines(payload)
468
+ technical_reason = str(reason or lifecycle_reason or "fallback-diary").strip()
469
+ diary_session_id = _preferred_diary_session_id(conn, session_id)
470
+ summary = (
471
+ "Diario automatico de emergencia generado por NEXO Desktop al cerrar "
472
+ f"'{title}'. No se confirmo un diario escrito por el agente vivo, asi "
473
+ "que se preserva el snapshot disponible para continuidad."
474
+ )
475
+ decisions = (
476
+ f"Accion de ciclo de vida: {action}. Evento: {event_id}. "
477
+ f"Motivo tecnico: {technical_reason}."
478
+ )
479
+ pending = str(payload.get("current_goal") or payload.get("last_user_message") or "").strip()
480
+ if not pending:
481
+ pending = "Revisar la conversacion al reabrir y continuar desde el snapshot preservado."
482
+ context_next_parts = [
483
+ f"conversation_id={conversation_id}",
484
+ f"session_id={session_id}",
485
+ ]
486
+ if transcript_lines:
487
+ context_next_parts.append("Transcript tail:\n" + "\n".join(transcript_lines))
488
+ context_next = "\n".join(context_next_parts)[:8000]
489
+
490
+ from db import write_session_diary
491
+
492
+ diary = write_session_diary(
493
+ diary_session_id,
494
+ decisions=decisions,
495
+ summary=summary,
496
+ discarded="",
497
+ pending=pending,
498
+ context_next=context_next,
499
+ mental_state="Fallback automatico: el agente vivo no confirmo el cierre dentro del timeout.",
500
+ domain="nexo-desktop",
501
+ user_signals="Cierre/archivo de conversacion; preservar informacion antes de salir.",
502
+ self_critique="El cierre no debe depender exclusivamente de que el agente responda a tiempo.",
503
+ source=source or "desktop-lifecycle-fallback",
504
+ )
505
+ return {
506
+ "status": "ok",
507
+ "event_id": event_id,
508
+ "fallback_written": True,
509
+ "diary_confirmed": True,
510
+ "session_diary_id": diary.get("id"),
511
+ "diary_session_id": diary_session_id,
512
+ "source": source or "desktop-lifecycle-fallback",
513
+ }
514
+
515
+
263
516
  def _session_stop_state(conn, session_id: str) -> Dict[str, Any]:
264
517
  """Return whether the lifecycle session can be verified as fully stopped."""
265
518
  raw = str(session_id or "").strip()
@@ -168,6 +168,27 @@ def handle_nexo_lifecycle_wait_for_diary(
168
168
  return json.dumps(ack, ensure_ascii=False)
169
169
 
170
170
 
171
+ def handle_nexo_lifecycle_write_fallback_diary(
172
+ event_id: str,
173
+ reason: str = "",
174
+ source: str = "desktop-lifecycle-fallback",
175
+ ) -> str:
176
+ """Write emergency diary evidence for a lifecycle event."""
177
+ try:
178
+ ack = lifecycle_events.write_fallback_diary_for_lifecycle_event(
179
+ event_id=str(event_id or ""),
180
+ reason=str(reason or ""),
181
+ source=str(source or "desktop-lifecycle-fallback"),
182
+ )
183
+ except Exception as exc:
184
+ return json.dumps({
185
+ "status": "retryable_error",
186
+ "reason": f"{type(exc).__name__}: {exc}",
187
+ "handler_threw": True,
188
+ }, ensure_ascii=False)
189
+ return json.dumps(ack, ensure_ascii=False)
190
+
191
+
171
192
  def handle_nexo_lifecycle_wait_for_stop(
172
193
  event_id: str,
173
194
  timeout_ms: int = 10_000,
@@ -231,6 +252,11 @@ TOOLS = [
231
252
  "nexo_lifecycle_wait_for_diary",
232
253
  "Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
233
254
  ),
255
+ (
256
+ handle_nexo_lifecycle_write_fallback_diary,
257
+ "nexo_lifecycle_write_fallback_diary",
258
+ "Write emergency session_diary evidence when Desktop cannot get a live agent-authored close diary.",
259
+ ),
234
260
  (
235
261
  handle_nexo_lifecycle_wait_for_stop,
236
262
  "nexo_lifecycle_wait_for_stop",
@@ -34,6 +34,7 @@ RESTART_ALLOWLIST = {
34
34
  "nexo_lifecycle_status",
35
35
  "nexo_lifecycle_complete_canonical",
36
36
  "nexo_lifecycle_wait_for_diary",
37
+ "nexo_lifecycle_write_fallback_diary",
37
38
  "nexo_continuity_snapshot_read",
38
39
  "nexo_continuity_resume_bundle",
39
40
  "nexo_continuity_audit",
@@ -1983,6 +1983,19 @@
1983
1983
  },
1984
1984
  "triggers_after": []
1985
1985
  },
1986
+ "nexo_lifecycle_write_fallback_diary": {
1987
+ "description": "Write a Brain-side fallback session_diary for a Desktop lifecycle event when live diary injection cannot complete.",
1988
+ "category": "lifecycle",
1989
+ "source": "plugin:lifecycle_events",
1990
+ "requires": [],
1991
+ "provides": [],
1992
+ "internal_calls": [],
1993
+ "enforcement": {
1994
+ "level": "none",
1995
+ "rules": []
1996
+ },
1997
+ "triggers_after": []
1998
+ },
1986
1999
  "nexo_lifecycle_stop_nexo_session": {
1987
2000
  "description": "Best-effort explicit stop of a NEXO SID for Desktop lifecycle cleanup.",
1988
2001
  "category": "lifecycle",