nexo-brain 7.3.0 → 7.4.0

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.3.0",
3
+ "version": "7.4.0",
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,7 @@
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.3.0` is the current packaged-runtime line. Hotfix minor release correcting three critical bugs that surfaced right after v7.2.0. B11 Guardian wire: new `src/hooks/pre_tool_use.py` entrypoint is now registered in `src/hooks/manifest.json` and `hooks/hooks.json` so the Claude Code PreToolUse event actually reaches Guardian — before this, `guardian-runtime-overrides.json` in hard mode was silently inert for G3-destructive, G3-SSH, and G4-guard_check gates. A parallel SSH prescreen in `hook_guardrails.process_pre_tool_event` forces `op=write` on remote-write patterns the local shell classifier could not see. B10 post-sync: `_run_post_install_hooks_fresh(dest, env)` invokes each whitelisted hook (`_persist_guardian_hard_defaults`, `_maybe_promote_adaptive_weights_empirically`) in a clean subprocess against the newly copied tree, so the first `nexo update` that introduces a new post-install hook actually runs it — previously the hook dispatch used the pre-upgrade module held in memory and silently no-op'd. B12 map distribution: `tool-enforcement-map.json` is now part of the npm `files` whitelist so fresh `npm install -g nexo-brain` ships it, closing the three-way gap (Brain npm + Desktop bundle + runtime sync) that prevented Desktop from ever discovering the map on new installs. Also ships the PE1 rapid items promised for this milestone: 5 additional `destructive_command` entries in `src/presets/entities_universal.json` (`curl_pipe_shell`, `dd_to_device`, `chmod_recursive_wide_open`, `ssh_remote_overwrite`, `scp_rsync_upload`; coverage floor raised from 7 to 12) and the `guardian-metrics` daily cron at 02:15 that feeds Fase C gate and the Guardian Proposals panel.
21
+ Version `7.4.0` is the current packaged-runtime line. Hotfix minor release correcting three critical bugs that surfaced right after v7.2.0. B11 Guardian wire: new `src/hooks/pre_tool_use.py` entrypoint is now registered in `src/hooks/manifest.json` and `hooks/hooks.json` so the Claude Code PreToolUse event actually reaches Guardian — before this, `guardian-runtime-overrides.json` in hard mode was silently inert for G3-destructive, G3-SSH, and G4-guard_check gates. A parallel SSH prescreen in `hook_guardrails.process_pre_tool_event` forces `op=write` on remote-write patterns the local shell classifier could not see. B10 post-sync: `_run_post_install_hooks_fresh(dest, env)` invokes each whitelisted hook (`_persist_guardian_hard_defaults`, `_maybe_promote_adaptive_weights_empirically`) in a clean subprocess against the newly copied tree, so the first `nexo update` that introduces a new post-install hook actually runs it — previously the hook dispatch used the pre-upgrade module held in memory and silently no-op'd. B12 map distribution: `tool-enforcement-map.json` is now part of the npm `files` whitelist so fresh `npm install -g nexo-brain` ships it, closing the three-way gap (Brain npm + Desktop bundle + runtime sync) that prevented Desktop from ever discovering the map on new installs. Also ships the PE1 rapid items promised for this milestone: 5 additional `destructive_command` entries in `src/presets/entities_universal.json` (`curl_pipe_shell`, `dd_to_device`, `chmod_recursive_wide_open`, `ssh_remote_overwrite`, `scp_rsync_upload`; coverage floor raised from 7 to 12) and the `guardian-metrics` daily cron at 02:15 that feeds Fase C gate and the Guardian Proposals panel.
22
22
 
23
23
  Previously in `7.2.0`: minor release consolidating three parallel workstreams into a single Guardian-active-by-default train. Block K roadmap closure (G1 enforcer active, G3 SSH remote-write detector, `src/guardian_runtime_config.py` resolver, `_persist_guardian_hard_defaults` during `nexo update`). F0.6 hardening wave (`nexo rollback f06` CLI, `src/scripts/prune_runtime_backups.py` promoted to core, `docs/f06-layout-contract.md`, three new doctor boot-tier checks, `scripts/nexo-migrate-nora.sh` + `scripts/f0-safe-apply-remote.sh` idempotent migration). Adaptive weights flipped from "14-day calendar wait" to "14 days OR (≥200 samples AND ≥2 days)" with auto-promotion during `nexo update`. Small-fixes batch: R34 `bool("unknown")==True` fix, `classify_scripts_dir` dedup, B10 module-level path constants lazy-evaluated, schedule override audit log, `scripts/pre-release-verify.sh` + `docs/release-discipline.md`, pre-commit hook that blocks commits when `tool-enforcement-map.json` drifts from `src/plugins/`.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.3.0",
3
+ "version": "7.4.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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
@@ -3076,6 +3076,22 @@ def main():
3076
3076
  dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
3077
3077
 
3078
3078
  # -- desktop bridge (read-only, for NEXO Desktop and any external UI) --
3079
+ # v7.4.0 — lifecycle event bridge (guardian-claude-desktop-plan).
3080
+ lifecycle_parser = sub.add_parser("lifecycle", help="Conversation lifecycle event handler (v7.4 Desktop bridge)")
3081
+ lifecycle_sub = lifecycle_parser.add_subparsers(dest="lifecycle_command")
3082
+
3083
+ lrec_p = lifecycle_sub.add_parser("record", help="Record a lifecycle event")
3084
+ lrec_p.add_argument("--event-id", required=True, help="UUID minted by the client (idempotency key)")
3085
+ lrec_p.add_argument("--action", required=True, help="close|delete|archive|switch|app-exit|window-close")
3086
+ lrec_p.add_argument("--conversation-id", required=True)
3087
+ lrec_p.add_argument("--session-id", default="")
3088
+ lrec_p.add_argument("--reason", default="user_action")
3089
+ lrec_p.add_argument("--payload", default="", help="JSON-encoded payload_snapshot")
3090
+ lrec_p.add_argument("--source", default="desktop")
3091
+
3092
+ lstat_p = lifecycle_sub.add_parser("status", help="Read the current delivery_status of an event")
3093
+ lstat_p.add_argument("--event-id", required=True)
3094
+
3079
3095
  # Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
3080
3096
  quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
3081
3097
  quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
@@ -3288,6 +3304,43 @@ def main():
3288
3304
  # No subcommand — show help.
3289
3305
  quarantine_parser.print_help()
3290
3306
  return 1
3307
+ elif args.command == "lifecycle":
3308
+ # v7.4.0 — bridge for NEXO Desktop's ConversationLifecycleService.
3309
+ # Both subcommands emit a single JSON line to stdout so the
3310
+ # caller (main.js runNexoCommand) can parse the ack directly.
3311
+ import json as _json
3312
+ import plugins.lifecycle_events as _lifecycle_plugin
3313
+ if args.lifecycle_command == "record":
3314
+ out = _lifecycle_plugin.handle_nexo_lifecycle_event(
3315
+ event_id=args.event_id,
3316
+ action=args.action,
3317
+ conversation_id=args.conversation_id,
3318
+ session_id=args.session_id or "",
3319
+ reason=args.reason or "user_action",
3320
+ payload_snapshot=args.payload or "",
3321
+ source=args.source or "desktop",
3322
+ schema_version=1,
3323
+ )
3324
+ print(out)
3325
+ try:
3326
+ parsed = _json.loads(out)
3327
+ status = str(parsed.get("status", ""))
3328
+ except Exception:
3329
+ status = ""
3330
+ # Exit code 0 for terminal ok states, 2 for retryable_error so
3331
+ # Desktop can distinguish "persisted + processed" from "try
3332
+ # again on boot reconciliation". Rejected is exit 3 (bad input).
3333
+ if status in ("processed", "already_processed", "accepted"):
3334
+ return 0
3335
+ if status == "retryable_error":
3336
+ return 2
3337
+ return 3
3338
+ if args.lifecycle_command == "status":
3339
+ out = _lifecycle_plugin.handle_nexo_lifecycle_status(args.event_id)
3340
+ print(out)
3341
+ return 0
3342
+ lifecycle_parser.print_help()
3343
+ return 1
3291
3344
  elif args.command in ("schema", "identity", "onboard", "scan-profile"):
3292
3345
  from desktop_bridge import cmd_schema, cmd_identity, cmd_onboard, cmd_scan_profile
3293
3346
  return {
package/src/db/_schema.py CHANGED
@@ -1311,6 +1311,46 @@ def _m44_entities_extended_schema(conn):
1311
1311
  _migrate_add_column(conn, "entities", "access_mode", "TEXT DEFAULT 'unknown'")
1312
1312
 
1313
1313
 
1314
+ def _m51_lifecycle_events(conn):
1315
+ """v7.4.0 — durable lifecycle event store for the Desktop pipeline.
1316
+
1317
+ Matches the Desktop-side NDJSON queue contract:
1318
+ event_id (PRIMARY KEY, uuid from Desktop), source (desktop|cron|...),
1319
+ action (close|delete|archive|switch|app-exit|window-close),
1320
+ conversation_id, session_id (claude session), reason,
1321
+ payload_snapshot (JSON), delivery_status
1322
+ (pending|accepted|processed|already_processed|rejected|retryable_error),
1323
+ retry_count, created_at, processed_at, last_error.
1324
+
1325
+ Idempotency key is event_id. Re-delivery of the same event_id returns
1326
+ status=already_processed without re-running any canonical side effect
1327
+ (diary, stop, archive bookkeeping). This is the backbone of
1328
+ guardian-claude-desktop-plan.md → "5. Idempotencia real".
1329
+ """
1330
+ conn.execute(
1331
+ """
1332
+ CREATE TABLE IF NOT EXISTS lifecycle_events (
1333
+ event_id TEXT PRIMARY KEY,
1334
+ schema_version INTEGER NOT NULL DEFAULT 1,
1335
+ source TEXT NOT NULL DEFAULT 'desktop',
1336
+ action TEXT NOT NULL,
1337
+ conversation_id TEXT NOT NULL,
1338
+ session_id TEXT DEFAULT NULL,
1339
+ reason TEXT DEFAULT 'user_action',
1340
+ payload_snapshot TEXT DEFAULT '{}',
1341
+ delivery_status TEXT NOT NULL DEFAULT 'accepted',
1342
+ retry_count INTEGER NOT NULL DEFAULT 0,
1343
+ created_at TEXT DEFAULT (datetime('now')),
1344
+ processed_at TEXT DEFAULT NULL,
1345
+ last_error TEXT DEFAULT NULL
1346
+ )
1347
+ """
1348
+ )
1349
+ _migrate_add_index(conn, "idx_lifecycle_events_status", "lifecycle_events", "delivery_status")
1350
+ _migrate_add_index(conn, "idx_lifecycle_events_conv", "lifecycle_events", "conversation_id")
1351
+ _migrate_add_index(conn, "idx_lifecycle_events_action", "lifecycle_events", "action")
1352
+
1353
+
1314
1354
  MIGRATIONS = [
1315
1355
  (1, "learnings_columns", _m1_learnings_columns),
1316
1356
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1362,6 +1402,7 @@ MIGRATIONS = [
1362
1402
  (48, "email_agent_contract_backfill", _m48_email_agent_contract_backfill),
1363
1403
  (49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
1364
1404
  (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1405
+ (51, "lifecycle_events", _m51_lifecycle_events),
1365
1406
  ]
1366
1407
 
1367
1408
 
@@ -0,0 +1,186 @@
1
+ """NEXO Brain — canonical lifecycle event handler (v7.4.0).
2
+
3
+ Companion to nexo-desktop's ConversationLifecycleService. Desktop
4
+ persists every conversation/app transition (close / delete / archive /
5
+ switch / window-close / app-exit) to an append-only NDJSON queue BEFORE
6
+ any UI mutation becomes visible, then calls this handler via the
7
+ ``nexo_lifecycle_event`` MCP tool. The handler is strictly idempotent:
8
+ re-delivery of the same ``event_id`` returns ``already_processed``
9
+ without replaying any canonical side effect.
10
+
11
+ Canonical side effects are intentionally minimal in this first slice:
12
+
13
+ - ``close`` / ``delete`` / ``archive`` / ``app-exit`` / ``window-close``
14
+ → mark processed. Diary / stop inside the conversation are driven by
15
+ Desktop's graceful-close flow (``conv-close`` IPC → nexo CLI) and
16
+ remain the authority for that per-conversation payload. This table
17
+ is the durable ledger so the next boot can reconcile.
18
+ - ``switch`` → mark processed. No canonical side effect beyond the
19
+ audit trail; the ledger still matters for telemetry and guard
20
+ invariants ("operator switched away from a conversation that still
21
+ had uncommitted claims").
22
+
23
+ Return shape matches the plan (lines 94-100):
24
+
25
+ - ``processed`` first delivery, side effects (if any) done
26
+ - ``already_processed`` duplicate delivery, no re-run
27
+ - ``accepted`` persisted, side effect deferred (not used yet)
28
+ - ``rejected`` malformed input, no persistence
29
+ - ``retryable_error`` transient failure, Desktop should retry
30
+
31
+ Any row keeps ``delivery_status`` as the latest terminal or retryable
32
+ status so ``nexo_lifecycle_status`` / future reconciliation queries
33
+ can read it directly.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from typing import Any, Dict, Optional
39
+
40
+ from db import get_db
41
+
42
+
43
+ VALID_ACTIONS = {
44
+ "close",
45
+ "delete",
46
+ "archive",
47
+ "switch",
48
+ "app-exit",
49
+ "window-close",
50
+ }
51
+
52
+ TERMINAL_STATUSES = {"processed", "already_processed", "rejected"}
53
+ _DIARY_TRIGGERING = {"close", "delete", "archive", "app-exit"}
54
+
55
+
56
+ def _normalise_payload(obj: Any) -> str:
57
+ try:
58
+ return json.dumps(obj or {}, ensure_ascii=False, sort_keys=True)
59
+ except Exception:
60
+ return "{}"
61
+
62
+
63
+ def record_lifecycle_event(
64
+ event_id: str,
65
+ action: str,
66
+ conversation_id: str,
67
+ session_id: Optional[str] = None,
68
+ reason: str = "user_action",
69
+ payload_snapshot: Optional[Dict[str, Any]] = None,
70
+ source: str = "desktop",
71
+ schema_version: int = 1,
72
+ ) -> Dict[str, Any]:
73
+ """Idempotent upsert + process.
74
+
75
+ Returns ``{status, event_id, diary_triggered, duplicate}``.
76
+ """
77
+ if not event_id or not str(event_id).strip():
78
+ return {"status": "rejected", "reason": "missing-event-id"}
79
+ if action not in VALID_ACTIONS:
80
+ return {"status": "rejected", "reason": f"unknown-action:{action}"}
81
+ if not conversation_id or not str(conversation_id).strip():
82
+ return {"status": "rejected", "reason": "missing-conversation-id"}
83
+
84
+ conn = get_db()
85
+ existing = conn.execute(
86
+ "SELECT delivery_status FROM lifecycle_events WHERE event_id = ?",
87
+ (str(event_id),),
88
+ ).fetchone()
89
+
90
+ if existing is not None:
91
+ status = str(existing[0] or "")
92
+ if status in TERMINAL_STATUSES:
93
+ return {
94
+ "status": "already_processed",
95
+ "event_id": event_id,
96
+ "duplicate": True,
97
+ "prior_status": status,
98
+ }
99
+ # Non-terminal row (accepted / retryable_error) — flip to processed
100
+ # now and record the transition.
101
+ conn.execute(
102
+ "UPDATE lifecycle_events SET delivery_status = 'processed', "
103
+ "processed_at = datetime('now'), last_error = NULL "
104
+ "WHERE event_id = ?",
105
+ (str(event_id),),
106
+ )
107
+ conn.commit()
108
+ return {
109
+ "status": "processed",
110
+ "event_id": event_id,
111
+ "diary_triggered": action in _DIARY_TRIGGERING,
112
+ "duplicate": False,
113
+ "reopened": True,
114
+ }
115
+
116
+ conn.execute(
117
+ """
118
+ INSERT INTO lifecycle_events (
119
+ event_id, schema_version, source, action, conversation_id,
120
+ session_id, reason, payload_snapshot, delivery_status,
121
+ retry_count, processed_at
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'processed', 0, datetime('now'))
123
+ """,
124
+ (
125
+ str(event_id),
126
+ int(schema_version or 1),
127
+ str(source or "desktop"),
128
+ str(action),
129
+ str(conversation_id),
130
+ str(session_id) if session_id else None,
131
+ str(reason or "user_action"),
132
+ _normalise_payload(payload_snapshot),
133
+ ),
134
+ )
135
+ conn.commit()
136
+
137
+ return {
138
+ "status": "processed",
139
+ "event_id": event_id,
140
+ "diary_triggered": action in _DIARY_TRIGGERING,
141
+ "duplicate": False,
142
+ }
143
+
144
+
145
+ def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
146
+ if not event_id:
147
+ return None
148
+ row = get_db().execute(
149
+ "SELECT event_id, schema_version, source, action, conversation_id, "
150
+ "session_id, reason, payload_snapshot, delivery_status, retry_count, "
151
+ "created_at, processed_at, last_error "
152
+ "FROM lifecycle_events WHERE event_id = ?",
153
+ (str(event_id),),
154
+ ).fetchone()
155
+ if row is None:
156
+ return None
157
+ try:
158
+ payload = json.loads(row[7] or "{}")
159
+ except Exception:
160
+ payload = {}
161
+ return {
162
+ "event_id": row[0],
163
+ "schema_version": row[1],
164
+ "source": row[2],
165
+ "action": row[3],
166
+ "conversation_id": row[4],
167
+ "session_id": row[5],
168
+ "reason": row[6],
169
+ "payload_snapshot": payload,
170
+ "delivery_status": row[8],
171
+ "retry_count": row[9],
172
+ "created_at": row[10],
173
+ "processed_at": row[11],
174
+ "last_error": row[12],
175
+ }
176
+
177
+
178
+ def list_lifecycle_events_by_status(status: str, limit: int = 100) -> list[Dict[str, Any]]:
179
+ if not status:
180
+ return []
181
+ rows = get_db().execute(
182
+ "SELECT event_id FROM lifecycle_events "
183
+ "WHERE delivery_status = ? ORDER BY created_at ASC LIMIT ?",
184
+ (str(status), int(limit or 100)),
185
+ ).fetchall()
186
+ return [e for e in (get_lifecycle_event(r[0]) for r in rows) if e]
@@ -0,0 +1,113 @@
1
+ """Lifecycle Events MCP plugin — nexo_lifecycle_event tool (v7.4.0).
2
+
3
+ Exposes the canonical handler as an MCP tool so nexo-desktop's
4
+ ConversationLifecycleService can relay close/delete/archive/app-exit/
5
+ window-close intents to Brain and get a formal acknowledgement.
6
+
7
+ See src/lifecycle_events.py for the handler contract and
8
+ guardian-claude-desktop-plan.md for the overall architecture.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+ import lifecycle_events
16
+
17
+
18
+ def handle_nexo_lifecycle_event(
19
+ event_id: str,
20
+ action: str,
21
+ conversation_id: str,
22
+ session_id: str = "",
23
+ reason: str = "user_action",
24
+ payload_snapshot: str = "",
25
+ source: str = "desktop",
26
+ schema_version: int = 1,
27
+ ) -> str:
28
+ """Record a durable lifecycle event and return the canonical ack.
29
+
30
+ Desktop (or any future client) MUST persist the event locally first
31
+ (NDJSON queue) before calling this tool. The handler is idempotent:
32
+ re-delivery of the same event_id returns ``already_processed``.
33
+
34
+ Args:
35
+ event_id: UUID minted by the client. Primary idempotency key.
36
+ action: One of ``close`` / ``delete`` / ``archive`` / ``switch``
37
+ / ``app-exit`` / ``window-close``.
38
+ conversation_id: Client-side conversation identifier.
39
+ session_id: Claude session id that backs the conversation, when
40
+ known. Optional.
41
+ reason: Free-form origin tag. Default ``user_action``.
42
+ payload_snapshot: JSON-encoded snapshot of the conversation at
43
+ the moment of the click (title, last_message_at, is_active,
44
+ etc). Accepts an empty string if nothing was snapped.
45
+ source: Client identifier. Default ``desktop``.
46
+ schema_version: Event schema version the client emitted. Default 1.
47
+
48
+ Returns:
49
+ JSON string ``{status, event_id, ...}``. ``status`` is one of
50
+ ``processed`` / ``already_processed`` / ``accepted`` /
51
+ ``rejected`` / ``retryable_error``.
52
+ """
53
+ payload_obj: Any = {}
54
+ if payload_snapshot:
55
+ try:
56
+ parsed = json.loads(payload_snapshot)
57
+ if isinstance(parsed, dict):
58
+ payload_obj = parsed
59
+ except Exception:
60
+ payload_obj = {"_raw": str(payload_snapshot)[:4096]}
61
+
62
+ try:
63
+ result = lifecycle_events.record_lifecycle_event(
64
+ event_id=event_id,
65
+ action=action,
66
+ conversation_id=conversation_id,
67
+ session_id=session_id or None,
68
+ reason=reason or "user_action",
69
+ payload_snapshot=payload_obj,
70
+ source=source or "desktop",
71
+ schema_version=int(schema_version or 1),
72
+ )
73
+ except Exception as exc:
74
+ return json.dumps({
75
+ "status": "retryable_error",
76
+ "reason": f"{type(exc).__name__}: {exc}",
77
+ "handler_threw": True,
78
+ }, ensure_ascii=False)
79
+
80
+ return json.dumps(result, ensure_ascii=False)
81
+
82
+
83
+ def handle_nexo_lifecycle_status(event_id: str) -> str:
84
+ """Read the current delivery_status of a lifecycle event.
85
+
86
+ Primarily used by reconciliation at Desktop boot: for each
87
+ still-pending or retryable event in the local NDJSON queue, ask
88
+ Brain whether it already processed it (Desktop crashed between the
89
+ append and the ack) or whether we need to re-submit.
90
+ """
91
+ if not event_id:
92
+ return json.dumps({"status": "rejected", "reason": "missing-event-id"})
93
+ try:
94
+ row = lifecycle_events.get_lifecycle_event(event_id)
95
+ except Exception as exc:
96
+ return json.dumps({"status": "retryable_error", "reason": f"{type(exc).__name__}: {exc}"})
97
+ if row is None:
98
+ return json.dumps({"status": "not_found", "event_id": event_id})
99
+ return json.dumps(row, ensure_ascii=False)
100
+
101
+
102
+ TOOLS = [
103
+ (
104
+ handle_nexo_lifecycle_event,
105
+ "nexo_lifecycle_event",
106
+ "Record a durable lifecycle event (close/delete/archive/switch/app-exit/window-close) and return a canonical ack.",
107
+ ),
108
+ (
109
+ handle_nexo_lifecycle_status,
110
+ "nexo_lifecycle_status",
111
+ "Read the current delivery_status of a lifecycle event. Used by Desktop boot reconciliation.",
112
+ ),
113
+ ]
@@ -1843,6 +1843,32 @@
1843
1843
  },
1844
1844
  "triggers_after": []
1845
1845
  },
1846
+ "nexo_lifecycle_event": {
1847
+ "description": "Record a durable conversation lifecycle event (close/delete/archive/switch/app-exit/window-close) with idempotency by event_id.",
1848
+ "category": "lifecycle",
1849
+ "source": "plugin:lifecycle_events",
1850
+ "requires": [],
1851
+ "provides": [],
1852
+ "internal_calls": [],
1853
+ "enforcement": {
1854
+ "level": "none",
1855
+ "rules": []
1856
+ },
1857
+ "triggers_after": []
1858
+ },
1859
+ "nexo_lifecycle_status": {
1860
+ "description": "Read the current delivery_status of a lifecycle event (used by Desktop boot reconciliation).",
1861
+ "category": "lifecycle",
1862
+ "source": "plugin:lifecycle_events",
1863
+ "requires": [],
1864
+ "provides": [],
1865
+ "internal_calls": [],
1866
+ "enforcement": {
1867
+ "level": "none",
1868
+ "rules": []
1869
+ },
1870
+ "triggers_after": []
1871
+ },
1846
1872
  "nexo_media_memory_add": {
1847
1873
  "description": "Store non-text artifact metadata",
1848
1874
  "category": "media",