nexo-brain 7.4.0 → 7.5.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.4.0",
3
+ "version": "7.5.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,9 @@
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.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.
21
+ Version `7.5.0` is the current packaged-runtime line. Minor release that promotes `nexo_lifecycle_event` from ledger + reconciliation authority to **canonical authority of session-end**. Brain now owns the prompt, the sequence, and the timing of diary+stop; Desktop v0.25.0 (closed-source companion) is the conduit that executes Brain's plan against the live Claude process. The new 2-call contract — `nexo_lifecycle_event` returns a versioned `canonical_plan` (resume_session inject_prompt stop_session, with stable ids and per-action timeouts) and `nexo_lifecycle_complete_canonical` confirms execution with a per-action results array replaces polling with explicit acknowledgement. `canonical_plan_id` is deterministic: `sha256(event_id + "|v" + plan_version)[:24]`, so retries reuse the same id. Migration m52 extends `lifecycle_events` with six `canonical_*` columns plus an index; pre-v7.5 rows simply carry NULL. `session_diary` is the dedupe key on re-delivery: if Desktop crashes between executing the inject and sending the complete call, the next `nexo_lifecycle_event` for the same `event_id` checks for a diary written after `canonical_dispatched_at`; if one exists, Brain short-circuits to `already_processed` and refuses to re-dispatch. The seven explicit `delivery_status` values (`accepted`, `processed`, `canonical_pending`, `canonical_done`, `already_processed`, `retryable_error`, `rejected`) give the pipeline a diffable state machine. `switch` and `window-close` stay observational (no plan ever issued, even with a live `session_id`). `nexo lifecycle record` now returns exit code 0 for `canonical_pending`; older wrappers that treated it as an error are incompatible with v7.5. MCP tool count: 262 263.
22
+
23
+ Previously in `7.4.1`: patch release correcting the over-promise in v7.4.0's release notes and locking in the exact role of `nexo_lifecycle_event` as a ledger + reconciliation authority — NOT the canonical executor of diary+stop, which lived in Desktop. That responsibility moved to Brain in v7.5.
22
24
 
23
25
  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
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.4.0",
3
+ "version": "7.5.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
@@ -3092,6 +3092,18 @@ def main():
3092
3092
  lstat_p = lifecycle_sub.add_parser("status", help="Read the current delivery_status of an event")
3093
3093
  lstat_p.add_argument("--event-id", required=True)
3094
3094
 
3095
+ lcomp_p = lifecycle_sub.add_parser(
3096
+ "complete-canonical",
3097
+ help="v7.5: confirm Desktop finished executing the canonical_actions a prior record returned",
3098
+ )
3099
+ lcomp_p.add_argument("--event-id", required=True)
3100
+ lcomp_p.add_argument("--plan-id", required=True, help="canonical_plan_id returned by the original record call")
3101
+ lcomp_p.add_argument(
3102
+ "--results",
3103
+ default="",
3104
+ help="JSON array of per-action outcomes, e.g. '[{\"action_id\":\"a1\",\"status\":\"ok\"}]'",
3105
+ )
3106
+
3095
3107
  # Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
3096
3108
  quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
3097
3109
  quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
@@ -3327,10 +3339,12 @@ def main():
3327
3339
  status = str(parsed.get("status", ""))
3328
3340
  except Exception:
3329
3341
  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"):
3342
+ # Exit code 0 for terminal ok states AND for canonical_pending
3343
+ # (v7.5: plan handed to Desktop, awaiting complete_canonical).
3344
+ # 2 for retryable_error so Desktop can distinguish "persisted +
3345
+ # processed" from "try again on boot reconciliation".
3346
+ # Rejected is exit 3 (bad input).
3347
+ if status in ("processed", "already_processed", "accepted", "canonical_pending"):
3334
3348
  return 0
3335
3349
  if status == "retryable_error":
3336
3350
  return 2
@@ -3339,6 +3353,23 @@ def main():
3339
3353
  out = _lifecycle_plugin.handle_nexo_lifecycle_status(args.event_id)
3340
3354
  print(out)
3341
3355
  return 0
3356
+ if args.lifecycle_command == "complete-canonical":
3357
+ out = _lifecycle_plugin.handle_nexo_lifecycle_complete_canonical(
3358
+ event_id=args.event_id,
3359
+ canonical_plan_id=args.plan_id,
3360
+ results=args.results or "",
3361
+ )
3362
+ print(out)
3363
+ try:
3364
+ parsed = _json.loads(out)
3365
+ status = str(parsed.get("status", ""))
3366
+ except Exception:
3367
+ status = ""
3368
+ if status in ("canonical_done", "already_processed"):
3369
+ return 0
3370
+ if status == "retryable_error":
3371
+ return 2
3372
+ return 3
3342
3373
  lifecycle_parser.print_help()
3343
3374
  return 1
3344
3375
  elif args.command in ("schema", "identity", "onboard", "scan-profile"):
package/src/db/_schema.py CHANGED
@@ -1351,6 +1351,48 @@ def _m51_lifecycle_events(conn):
1351
1351
  _migrate_add_index(conn, "idx_lifecycle_events_action", "lifecycle_events", "action")
1352
1352
 
1353
1353
 
1354
+ def _m52_lifecycle_canonical_plan(conn):
1355
+ """v7.5.0 — Brain promoted to canonical authority for session-end.
1356
+
1357
+ When the Desktop pipeline posts a close / delete / archive / app-exit
1358
+ lifecycle event with a live session_id, Brain now decides the exact
1359
+ prompt + sequence to run against the live Claude process and hands
1360
+ that plan back to Desktop in the same MCP call. Desktop executes
1361
+ the plan inline and confirms via a second call
1362
+ (``nexo_lifecycle_complete_canonical``).
1363
+
1364
+ New columns on ``lifecycle_events``:
1365
+
1366
+ - ``canonical_plan_id`` — deterministic id hash(event_id+plan_version).
1367
+ Used for idempotent retries: Desktop can ask "did you already
1368
+ finish this plan_id" and Brain can dedupe without re-running any
1369
+ diary write.
1370
+ - ``canonical_plan_version`` — schema version of the plan payload
1371
+ (INTEGER, default 1). Lets us evolve the action shape without
1372
+ breaking older Desktop builds.
1373
+ - ``canonical_actions_json`` — the actions array Brain returned,
1374
+ verbatim. Persisted so boot reconciliation can re-send the exact
1375
+ same plan on a crash between dispatch and confirm.
1376
+ - ``canonical_dispatched_at`` — first time Brain returned the plan.
1377
+ Used as the "since" cursor for the session_diary dedup query.
1378
+ - ``canonical_done_at`` — set only when Desktop calls
1379
+ ``nexo_lifecycle_complete_canonical``. Absence + presence of
1380
+ ``canonical_dispatched_at`` == "dispatched but not confirmed".
1381
+ - ``canonical_done_results`` — JSON array of per-action results
1382
+ reported by Desktop, used for telemetry and retry classification.
1383
+
1384
+ Idempotent. Fresh installs already created the table in m51; this
1385
+ migration only ADDs the new columns.
1386
+ """
1387
+ _migrate_add_column(conn, "lifecycle_events", "canonical_plan_id", "TEXT DEFAULT NULL")
1388
+ _migrate_add_column(conn, "lifecycle_events", "canonical_plan_version", "INTEGER DEFAULT NULL")
1389
+ _migrate_add_column(conn, "lifecycle_events", "canonical_actions_json", "TEXT DEFAULT NULL")
1390
+ _migrate_add_column(conn, "lifecycle_events", "canonical_dispatched_at", "TEXT DEFAULT NULL")
1391
+ _migrate_add_column(conn, "lifecycle_events", "canonical_done_at", "TEXT DEFAULT NULL")
1392
+ _migrate_add_column(conn, "lifecycle_events", "canonical_done_results", "TEXT DEFAULT NULL")
1393
+ _migrate_add_index(conn, "idx_lifecycle_events_plan_id", "lifecycle_events", "canonical_plan_id")
1394
+
1395
+
1354
1396
  MIGRATIONS = [
1355
1397
  (1, "learnings_columns", _m1_learnings_columns),
1356
1398
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1403,6 +1445,7 @@ MIGRATIONS = [
1403
1445
  (49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
1404
1446
  (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1405
1447
  (51, "lifecycle_events", _m51_lifecycle_events),
1448
+ (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1406
1449
  ]
1407
1450
 
1408
1451
 
@@ -1,36 +1,52 @@
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.
1
+ """NEXO Brain — canonical lifecycle event handler (v7.5).
2
+
3
+ v7.4.x shipped this tool as a pure ledger + reconciliation surface:
4
+ Desktop persisted every conversation lifecycle transition locally,
5
+ called ``nexo_lifecycle_event`` for book-keeping, and ran its own
6
+ hardcoded ``diary + stop`` prompts against the live Claude process
7
+ during ``closeConversationGraceful``.
8
+
9
+ v7.5 promotes this handler to the **canonical authority** for
10
+ session-end. For every ``close`` / ``delete`` / ``archive`` /
11
+ ``app-exit`` event with a live ``session_id``, Brain now generates a
12
+ deterministic **canonical plan** (``canonical_plan_id``, versioned
13
+ action list) and hands it back in the same MCP call. Desktop executes
14
+ the plan inline (Desktop is still the only process that can reach the
15
+ Claude proc's stdin) and then calls
16
+ ``nexo_lifecycle_complete_canonical`` with per-action results. Brain
17
+ records ``canonical_done_at`` only on that second call no polling.
18
+
19
+ Idempotency is real, not cosmetic:
20
+
21
+ 1. ``canonical_plan_id`` is deterministic: ``sha256(event_id + version)``.
22
+ A retry of the same event returns the same plan id, which Desktop
23
+ can use to skip actions it already completed locally.
24
+ 2. Before regenerating a plan for a previously dispatched event, Brain
25
+ checks whether the session already wrote a ``session_diary`` row
26
+ after the original ``canonical_dispatched_at``. If it did → the
27
+ answer is ``already_processed``; no re-dispatch, no duplicate diary.
28
+
29
+ Status values:
30
+
31
+ - ``processed`` first delivery, no canonical plan applicable
32
+ (e.g. switch / window-close / missing
33
+ session_id).
34
+ - ``canonical_pending`` plan generated and returned. Desktop is
35
+ expected to execute + confirm.
36
+ - ``canonical_dispatched`` alias for ``canonical_pending`` on a row
37
+ that already has ``canonical_dispatched_at``
38
+ set (re-delivery case).
39
+ - ``canonical_done`` Desktop confirmed via complete_canonical.
40
+ - ``already_processed`` idempotent duplicate, no re-run.
41
+ - ``accepted`` persisted, no canonical side effect required.
42
+ - ``rejected`` malformed input.
43
+ - ``retryable_error`` a canonical action failed (inject timeout,
44
+ stdin closed, etc). Reconciler can retry
45
+ with the same plan_id.
46
+
47
+ Actions that carry a canonical plan: ``close``, ``delete``, ``archive``,
48
+ ``app-exit``. ``switch`` and ``window-close`` still return
49
+ ``accepted`` (no live-session work to do).
34
50
  """
35
51
  from __future__ import annotations
36
52
 
@@ -38,6 +54,7 @@ import json
38
54
  from typing import Any, Dict, Optional
39
55
 
40
56
  from db import get_db
57
+ import lifecycle_prompts
41
58
 
42
59
 
43
60
  VALID_ACTIONS = {
@@ -49,8 +66,14 @@ VALID_ACTIONS = {
49
66
  "window-close",
50
67
  }
51
68
 
52
- TERMINAL_STATUSES = {"processed", "already_processed", "rejected"}
53
- _DIARY_TRIGGERING = {"close", "delete", "archive", "app-exit"}
69
+ # Terminal for the user of the ledger (no further action expected).
70
+ TERMINAL_STATUSES = {
71
+ "processed",
72
+ "canonical_done",
73
+ "already_processed",
74
+ "rejected",
75
+ }
76
+ _DIARY_TRIGGERING = lifecycle_prompts.DIARY_TRIGGERING_ACTIONS
54
77
 
55
78
 
56
79
  def _normalise_payload(obj: Any) -> str:
@@ -60,6 +83,26 @@ def _normalise_payload(obj: Any) -> str:
60
83
  return "{}"
61
84
 
62
85
 
86
+ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str]) -> bool:
87
+ """True if session_diary has a row for ``session_id`` created after
88
+ ``dispatched_at``. Used by the canonical-authority idempotency
89
+ guard: if the live session already produced a diary since the
90
+ plan was handed out, we must NOT re-dispatch it.
91
+ """
92
+ if not session_id or not dispatched_at:
93
+ return False
94
+ try:
95
+ row = conn.execute(
96
+ "SELECT 1 FROM session_diary "
97
+ "WHERE session_id = ? AND created_at > ? LIMIT 1",
98
+ (str(session_id), str(dispatched_at)),
99
+ ).fetchone()
100
+ except Exception:
101
+ # Missing table on a minimal test harness — treat as "no diary".
102
+ return False
103
+ return row is not None
104
+
105
+
63
106
  def record_lifecycle_event(
64
107
  event_id: str,
65
108
  action: str,
@@ -70,9 +113,15 @@ def record_lifecycle_event(
70
113
  source: str = "desktop",
71
114
  schema_version: int = 1,
72
115
  ) -> Dict[str, Any]:
73
- """Idempotent upsert + process.
116
+ """Idempotent upsert + canonical plan generation (v7.5).
74
117
 
75
- Returns ``{status, event_id, diary_triggered, duplicate}``.
118
+ Returns ``{status, event_id, ...}`` where ``status`` is one of:
119
+ ``rejected`` | ``already_processed`` | ``processed`` |
120
+ ``canonical_pending`` | ``accepted``. When the answer is
121
+ ``canonical_pending``, the response also carries
122
+ ``canonical_plan_id``, ``canonical_plan_version`` and
123
+ ``canonical_actions[]`` — Desktop must execute those actions and
124
+ confirm via ``record_complete_canonical``.
76
125
  """
77
126
  if not event_id or not str(event_id).strip():
78
127
  return {"status": "rejected", "reason": "missing-event-id"}
@@ -83,12 +132,32 @@ def record_lifecycle_event(
83
132
 
84
133
  conn = get_db()
85
134
  existing = conn.execute(
86
- "SELECT delivery_status FROM lifecycle_events WHERE event_id = ?",
135
+ "SELECT delivery_status, canonical_plan_id, canonical_plan_version, "
136
+ "canonical_actions_json, canonical_dispatched_at, canonical_done_at "
137
+ "FROM lifecycle_events WHERE event_id = ?",
87
138
  (str(event_id),),
88
139
  ).fetchone()
89
140
 
141
+ plan = lifecycle_prompts.build_canonical_plan(
142
+ event_id=str(event_id),
143
+ action=str(action),
144
+ conversation_id=str(conversation_id),
145
+ session_id=str(session_id) if session_id else None,
146
+ payload_snapshot=payload_snapshot or {},
147
+ )
148
+
90
149
  if existing is not None:
91
150
  status = str(existing[0] or "")
151
+ prior_plan_id = existing[1]
152
+ prior_actions_json = existing[3]
153
+ prior_dispatched_at = existing[5] # column 5 is canonical_done_at — reuse?
154
+ # column indices (0-5): delivery_status, canonical_plan_id,
155
+ # canonical_plan_version, canonical_actions_json,
156
+ # canonical_dispatched_at, canonical_done_at.
157
+ prior_dispatched_at = existing[4]
158
+ prior_done_at = existing[5]
159
+
160
+ # Case A: terminal status already recorded — hard idempotency.
92
161
  if status in TERMINAL_STATUSES:
93
162
  return {
94
163
  "status": "already_processed",
@@ -96,8 +165,73 @@ def record_lifecycle_event(
96
165
  "duplicate": True,
97
166
  "prior_status": status,
98
167
  }
99
- # Non-terminal row (accepted / retryable_error) — flip to processed
100
- # now and record the transition.
168
+
169
+ # Case B: canonical was dispatched but never confirmed. Check
170
+ # whether the live session wrote a diary after dispatch; if so
171
+ # the intent has already been satisfied by the model and we
172
+ # must NOT ask Desktop to re-run the plan.
173
+ if prior_plan_id and prior_dispatched_at and not prior_done_at:
174
+ if session_id and _session_diary_since(conn, str(session_id), str(prior_dispatched_at)):
175
+ conn.execute(
176
+ "UPDATE lifecycle_events "
177
+ "SET delivery_status = 'already_processed', "
178
+ " canonical_done_at = datetime('now'), "
179
+ " last_error = NULL "
180
+ "WHERE event_id = ?",
181
+ (str(event_id),),
182
+ )
183
+ conn.commit()
184
+ return {
185
+ "status": "already_processed",
186
+ "event_id": event_id,
187
+ "duplicate": True,
188
+ "prior_status": status,
189
+ "reason": "session_diary-already-written",
190
+ }
191
+ # Re-hand the exact same plan so Desktop can resume / finish
192
+ # any actions it didn't complete before the crash.
193
+ try:
194
+ actions = json.loads(prior_actions_json) if prior_actions_json else []
195
+ except Exception:
196
+ actions = []
197
+ return {
198
+ "status": "canonical_pending",
199
+ "event_id": event_id,
200
+ "canonical_plan_id": prior_plan_id,
201
+ "canonical_plan_version": int(existing[2] or lifecycle_prompts.PLAN_VERSION),
202
+ "canonical_actions": actions,
203
+ "resumed_from_dispatch": True,
204
+ }
205
+
206
+ # Case C: non-terminal, no canonical plan yet — flip to processed
207
+ # (legacy ledger semantics) OR upgrade to canonical_pending if a
208
+ # plan applies.
209
+ if plan is not None:
210
+ conn.execute(
211
+ "UPDATE lifecycle_events "
212
+ "SET delivery_status = 'canonical_pending', "
213
+ " canonical_plan_id = ?, "
214
+ " canonical_plan_version = ?, "
215
+ " canonical_actions_json = ?, "
216
+ " canonical_dispatched_at = datetime('now'), "
217
+ " last_error = NULL "
218
+ "WHERE event_id = ?",
219
+ (
220
+ plan["canonical_plan_id"],
221
+ int(plan["canonical_plan_version"]),
222
+ json.dumps(plan["canonical_actions"], ensure_ascii=False),
223
+ str(event_id),
224
+ ),
225
+ )
226
+ conn.commit()
227
+ return {
228
+ "status": "canonical_pending",
229
+ "event_id": event_id,
230
+ "canonical_plan_id": plan["canonical_plan_id"],
231
+ "canonical_plan_version": plan["canonical_plan_version"],
232
+ "canonical_actions": plan["canonical_actions"],
233
+ "reopened": True,
234
+ }
101
235
  conn.execute(
102
236
  "UPDATE lifecycle_events SET delivery_status = 'processed', "
103
237
  "processed_at = datetime('now'), last_error = NULL "
@@ -113,6 +247,45 @@ def record_lifecycle_event(
113
247
  "reopened": True,
114
248
  }
115
249
 
250
+ # Brand new event.
251
+ if plan is not None:
252
+ conn.execute(
253
+ """
254
+ INSERT INTO lifecycle_events (
255
+ event_id, schema_version, source, action, conversation_id,
256
+ session_id, reason, payload_snapshot, delivery_status,
257
+ retry_count, canonical_plan_id, canonical_plan_version,
258
+ canonical_actions_json, canonical_dispatched_at
259
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'canonical_pending', 0,
260
+ ?, ?, ?, datetime('now'))
261
+ """,
262
+ (
263
+ str(event_id),
264
+ int(schema_version or 1),
265
+ str(source or "desktop"),
266
+ str(action),
267
+ str(conversation_id),
268
+ str(session_id) if session_id else None,
269
+ str(reason or "user_action"),
270
+ _normalise_payload(payload_snapshot),
271
+ plan["canonical_plan_id"],
272
+ int(plan["canonical_plan_version"]),
273
+ json.dumps(plan["canonical_actions"], ensure_ascii=False),
274
+ ),
275
+ )
276
+ conn.commit()
277
+ return {
278
+ "status": "canonical_pending",
279
+ "event_id": event_id,
280
+ "canonical_plan_id": plan["canonical_plan_id"],
281
+ "canonical_plan_version": plan["canonical_plan_version"],
282
+ "canonical_actions": plan["canonical_actions"],
283
+ "duplicate": False,
284
+ }
285
+
286
+ # No plan: ledger-only record (switch/window-close or missing
287
+ # session_id on a diary-triggering action). Mark processed
288
+ # immediately so callers get the v7.4.x contract back.
116
289
  conn.execute(
117
290
  """
118
291
  INSERT INTO lifecycle_events (
@@ -133,7 +306,6 @@ def record_lifecycle_event(
133
306
  ),
134
307
  )
135
308
  conn.commit()
136
-
137
309
  return {
138
310
  "status": "processed",
139
311
  "event_id": event_id,
@@ -142,13 +314,94 @@ def record_lifecycle_event(
142
314
  }
143
315
 
144
316
 
317
+ def record_complete_canonical(
318
+ event_id: str,
319
+ canonical_plan_id: str,
320
+ results: Optional[list] = None,
321
+ ) -> Dict[str, Any]:
322
+ """Close the 2-call contract: Desktop confirms it executed the plan.
323
+
324
+ Inputs:
325
+ - ``event_id``: the original event id.
326
+ - ``canonical_plan_id``: must match the one Brain handed out. A
327
+ mismatch means Desktop is confirming a stale plan — we ignore
328
+ it and answer ``rejected``.
329
+ - ``results``: list of ``{action_id, status, ...}``. If any
330
+ ``status != 'ok'`` we flip the row to ``retryable_error`` and
331
+ keep ``canonical_dispatched_at`` intact so reconciliation can
332
+ re-ask.
333
+
334
+ Returns the effective row status after the call.
335
+ """
336
+ if not event_id:
337
+ return {"status": "rejected", "reason": "missing-event-id"}
338
+ if not canonical_plan_id:
339
+ return {"status": "rejected", "reason": "missing-canonical-plan-id"}
340
+
341
+ conn = get_db()
342
+ row = conn.execute(
343
+ "SELECT delivery_status, canonical_plan_id, canonical_done_at "
344
+ "FROM lifecycle_events WHERE event_id = ?",
345
+ (str(event_id),),
346
+ ).fetchone()
347
+ if row is None:
348
+ return {"status": "rejected", "reason": "unknown-event-id"}
349
+ current_status = str(row[0] or "")
350
+ expected_plan = row[1]
351
+ already_done_at = row[2]
352
+
353
+ if expected_plan and canonical_plan_id != expected_plan:
354
+ return {
355
+ "status": "rejected",
356
+ "reason": "canonical_plan_id-mismatch",
357
+ "expected": expected_plan,
358
+ "received": canonical_plan_id,
359
+ }
360
+ if already_done_at and current_status == "canonical_done":
361
+ return {
362
+ "status": "already_processed",
363
+ "event_id": event_id,
364
+ "duplicate": True,
365
+ }
366
+
367
+ results_list = list(results or [])
368
+ any_failure = any(
369
+ str((r or {}).get("status", "")).lower() not in {"ok", "success", "already_processed"}
370
+ for r in results_list
371
+ )
372
+ effective = "retryable_error" if any_failure else "canonical_done"
373
+ conn.execute(
374
+ "UPDATE lifecycle_events "
375
+ "SET delivery_status = ?, "
376
+ " canonical_done_at = datetime('now'), "
377
+ " canonical_done_results = ?, "
378
+ " last_error = ? "
379
+ "WHERE event_id = ?",
380
+ (
381
+ effective,
382
+ json.dumps(results_list, ensure_ascii=False),
383
+ "one-or-more-actions-failed" if any_failure else None,
384
+ str(event_id),
385
+ ),
386
+ )
387
+ conn.commit()
388
+ return {
389
+ "status": effective,
390
+ "event_id": event_id,
391
+ "canonical_plan_id": canonical_plan_id,
392
+ "failed_actions": any_failure,
393
+ }
394
+
395
+
145
396
  def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
146
397
  if not event_id:
147
398
  return None
148
399
  row = get_db().execute(
149
400
  "SELECT event_id, schema_version, source, action, conversation_id, "
150
401
  "session_id, reason, payload_snapshot, delivery_status, retry_count, "
151
- "created_at, processed_at, last_error "
402
+ "created_at, processed_at, last_error, "
403
+ "canonical_plan_id, canonical_plan_version, canonical_actions_json, "
404
+ "canonical_dispatched_at, canonical_done_at, canonical_done_results "
152
405
  "FROM lifecycle_events WHERE event_id = ?",
153
406
  (str(event_id),),
154
407
  ).fetchone()
@@ -158,6 +411,14 @@ def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
158
411
  payload = json.loads(row[7] or "{}")
159
412
  except Exception:
160
413
  payload = {}
414
+ try:
415
+ actions = json.loads(row[15]) if row[15] else None
416
+ except Exception:
417
+ actions = None
418
+ try:
419
+ results = json.loads(row[18]) if row[18] else None
420
+ except Exception:
421
+ results = None
161
422
  return {
162
423
  "event_id": row[0],
163
424
  "schema_version": row[1],
@@ -172,6 +433,12 @@ def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
172
433
  "created_at": row[10],
173
434
  "processed_at": row[11],
174
435
  "last_error": row[12],
436
+ "canonical_plan_id": row[13],
437
+ "canonical_plan_version": row[14],
438
+ "canonical_actions": actions,
439
+ "canonical_dispatched_at": row[16],
440
+ "canonical_done_at": row[17],
441
+ "canonical_done_results": results,
175
442
  }
176
443
 
177
444
 
@@ -0,0 +1,138 @@
1
+ """NEXO Brain — canonical lifecycle action templates (v7.5).
2
+
3
+ Brain owns the prompt that Desktop injects into a live Claude session
4
+ at close / delete / archive / app-exit time. Desktop never hardcodes
5
+ the wording; it just receives a list of ``canonical_actions`` and
6
+ executes them against the conversation's stdin / lifecycle.
7
+
8
+ The template version is bumped whenever the prompt or the action
9
+ schema changes. The version is part of ``canonical_plan_id`` so two
10
+ dispatches of the same event produced by two different Brain versions
11
+ do NOT collide (a retry from an older Desktop hitting a newer Brain
12
+ will get the newer plan; a retry of a previous plan reuses the same
13
+ id because the event_id hasn't changed).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from typing import Any, Dict, List, Optional
20
+
21
+
22
+ PLAN_VERSION = 1
23
+
24
+
25
+ # Actions that trigger a canonical diary+stop plan. `switch` and
26
+ # `window-close` never do: they're observational transitions that Brain
27
+ # records in the ledger but doesn't orchestrate anything against the
28
+ # live session (the session keeps running after a switch, and a window
29
+ # close to the tray doesn't end the conversation).
30
+ DIARY_TRIGGERING_ACTIONS = {"close", "delete", "archive", "app-exit"}
31
+
32
+
33
+ # Default per-action timeout (ms). Desktop honours this when it
34
+ # executes each action; on timeout it reports status=failed and Brain
35
+ # flips delivery_status to retryable_error.
36
+ DEFAULT_RESUME_TIMEOUT_MS = 2_000
37
+ DEFAULT_INJECT_TIMEOUT_MS = 6_000
38
+ DEFAULT_STOP_TIMEOUT_MS = 3_000
39
+
40
+
41
+ def canonical_plan_id(event_id: str, plan_version: int = PLAN_VERSION) -> str:
42
+ """Deterministic plan id — retries of the same event reuse it."""
43
+ digest = hashlib.sha256(
44
+ f"{event_id}|v{int(plan_version)}".encode("utf-8")
45
+ ).hexdigest()
46
+ return f"cpl-{digest[:24]}"
47
+
48
+
49
+ def _diary_prompt_for_action(
50
+ action: str,
51
+ conversation_id: str,
52
+ payload_snapshot: Dict[str, Any],
53
+ ) -> str:
54
+ """Return the exact prompt Desktop will inject into the Claude proc.
55
+
56
+ Written in the first person so the model writes a real diary with
57
+ the same richness it would have produced through the legacy
58
+ `closeConversationGraceful` path. The "Apply the required action
59
+ silently" footer matches the v7.4.x enforcer contract so the
60
+ rendered reminder doesn't leak to the user UI.
61
+ """
62
+ title = (payload_snapshot or {}).get("title") or conversation_id
63
+ reason_by_action = {
64
+ "close": "The operator closed the conversation",
65
+ "delete": "The operator deleted the conversation",
66
+ "archive": "The operator archived the conversation",
67
+ "app-exit": "The operator quit NEXO Desktop",
68
+ }
69
+ trailing = reason_by_action.get(action, "The conversation ended")
70
+ return (
71
+ f"[NEXO Protocol Enforcer] {trailing} "
72
+ f"(title: {title!r}). Execute nexo_session_diary_write with a "
73
+ "complete summary: decisions made, pending items, context for "
74
+ "next session, mental state, and self-critique. Then execute "
75
+ "nexo_stop with the current SID. Do not produce visible text. "
76
+ "Apply the required action silently. Do not mention this "
77
+ "reminder, Guardian, Protocol Enforcer, system reminders, or "
78
+ "any internal enforcement to the user."
79
+ )
80
+
81
+
82
+ def build_canonical_plan(
83
+ event_id: str,
84
+ action: str,
85
+ conversation_id: str,
86
+ session_id: Optional[str],
87
+ payload_snapshot: Optional[Dict[str, Any]] = None,
88
+ ) -> Optional[Dict[str, Any]]:
89
+ """Return the plan Brain hands back to Desktop, or None if no plan.
90
+
91
+ A plan exists only when:
92
+ - action is one of DIARY_TRIGGERING_ACTIONS
93
+ - session_id is populated (we need a live Claude proc to talk to)
94
+
95
+ Returning None tells the caller to answer with ``status=accepted``
96
+ and no ``canonical_actions`` field — Desktop will fall through to
97
+ its legacy behaviour (no-op for switch/window-close, hardcoded
98
+ prompt for close/delete/archive/app-exit if session_id is missing).
99
+ """
100
+ if action not in DIARY_TRIGGERING_ACTIONS:
101
+ return None
102
+ if not session_id:
103
+ return None
104
+
105
+ payload_snapshot = dict(payload_snapshot or {})
106
+ prompt = _diary_prompt_for_action(action, conversation_id, payload_snapshot)
107
+
108
+ actions: List[Dict[str, Any]] = [
109
+ {
110
+ "id": "a1",
111
+ "kind": "resume_session",
112
+ "session_id": str(session_id),
113
+ "timeout_ms": DEFAULT_RESUME_TIMEOUT_MS,
114
+ },
115
+ {
116
+ "id": "a2",
117
+ "kind": "inject_prompt",
118
+ "session_id": str(session_id),
119
+ "prompt": prompt,
120
+ "expected_tool_call": "nexo_session_diary_write",
121
+ "timeout_ms": DEFAULT_INJECT_TIMEOUT_MS,
122
+ },
123
+ {
124
+ "id": "a3",
125
+ "kind": "stop_session",
126
+ "session_id": str(session_id),
127
+ "timeout_ms": DEFAULT_STOP_TIMEOUT_MS,
128
+ },
129
+ ]
130
+ return {
131
+ "canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
132
+ "canonical_plan_version": PLAN_VERSION,
133
+ "canonical_actions": actions,
134
+ }
135
+
136
+
137
+ def canonical_plan_as_json(plan: Dict[str, Any]) -> str:
138
+ return json.dumps(plan, ensure_ascii=False, sort_keys=True)
@@ -99,15 +99,68 @@ def handle_nexo_lifecycle_status(event_id: str) -> str:
99
99
  return json.dumps(row, ensure_ascii=False)
100
100
 
101
101
 
102
+ def handle_nexo_lifecycle_complete_canonical(
103
+ event_id: str,
104
+ canonical_plan_id: str,
105
+ results: str = "",
106
+ ) -> str:
107
+ """Close the v7.5 canonical-authority 2-call contract.
108
+
109
+ Desktop calls this tool after executing every action Brain returned
110
+ in the ``canonical_actions`` array from ``nexo_lifecycle_event``.
111
+ Brain then records ``canonical_done_at`` and flips the row status
112
+ to ``canonical_done`` (or ``retryable_error`` if any action
113
+ failed).
114
+
115
+ Args:
116
+ event_id: UUID of the original lifecycle event.
117
+ canonical_plan_id: The plan id Brain returned. Mismatch → rejected.
118
+ results: JSON-encoded array of per-action outcomes:
119
+ ``[{"action_id": "a1", "status": "ok"|"failed", ...}, ...]``
120
+
121
+ Returns:
122
+ JSON ack with the effective row status.
123
+ """
124
+ parsed_results: list = []
125
+ if results:
126
+ try:
127
+ parsed = json.loads(results)
128
+ if isinstance(parsed, list):
129
+ parsed_results = parsed
130
+ else:
131
+ parsed_results = [{"status": "failed", "reason": "results-not-array", "raw": str(results)[:200]}]
132
+ except Exception as exc:
133
+ parsed_results = [{"status": "failed", "reason": f"malformed-results:{exc}"}]
134
+
135
+ try:
136
+ ack = lifecycle_events.record_complete_canonical(
137
+ event_id=str(event_id or ""),
138
+ canonical_plan_id=str(canonical_plan_id or ""),
139
+ results=parsed_results,
140
+ )
141
+ except Exception as exc:
142
+ return json.dumps({
143
+ "status": "retryable_error",
144
+ "reason": f"{type(exc).__name__}: {exc}",
145
+ "handler_threw": True,
146
+ }, ensure_ascii=False)
147
+ return json.dumps(ack, ensure_ascii=False)
148
+
149
+
102
150
  TOOLS = [
103
151
  (
104
152
  handle_nexo_lifecycle_event,
105
153
  "nexo_lifecycle_event",
106
- "Record a durable lifecycle event (close/delete/archive/switch/app-exit/window-close) and return a canonical ack.",
154
+ "Record a durable lifecycle event (close/delete/archive/switch/app-exit/window-close). In v7.5 Brain returns a canonical_actions plan for diary-triggering actions with a live session_id.",
107
155
  ),
108
156
  (
109
157
  handle_nexo_lifecycle_status,
110
158
  "nexo_lifecycle_status",
111
159
  "Read the current delivery_status of a lifecycle event. Used by Desktop boot reconciliation.",
112
160
  ),
161
+ (
162
+ handle_nexo_lifecycle_complete_canonical,
163
+ "nexo_lifecycle_complete_canonical",
164
+ "Confirm that Desktop finished executing the canonical_actions Brain handed out in a prior nexo_lifecycle_event call. Brain marks canonical_done_at only on this confirmation.",
165
+ ),
113
166
  ]
@@ -1869,6 +1869,19 @@
1869
1869
  },
1870
1870
  "triggers_after": []
1871
1871
  },
1872
+ "nexo_lifecycle_complete_canonical": {
1873
+ "description": "Close the v7.5 canonical-authority 2-call contract: confirm that Desktop finished executing the canonical_actions Brain handed out.",
1874
+ "category": "lifecycle",
1875
+ "source": "plugin:lifecycle_events",
1876
+ "requires": [],
1877
+ "provides": [],
1878
+ "internal_calls": [],
1879
+ "enforcement": {
1880
+ "level": "none",
1881
+ "rules": []
1882
+ },
1883
+ "triggers_after": []
1884
+ },
1872
1885
  "nexo_media_memory_add": {
1873
1886
  "description": "Store non-text artifact metadata",
1874
1887
  "category": "media",