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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/cli.py +35 -4
- package/src/db/_schema.py +43 -0
- package/src/lifecycle_events.py +309 -42
- package/src/lifecycle_prompts.py +138 -0
- package/src/plugins/lifecycle_events.py +54 -1
- package/tool-enforcement-map.json +13 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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.
|
|
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
|
|
3331
|
-
#
|
|
3332
|
-
#
|
|
3333
|
-
|
|
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
|
|
package/src/lifecycle_events.py
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
|
-
"""NEXO Brain — canonical lifecycle event handler (v7.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
``
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 +
|
|
116
|
+
"""Idempotent upsert + canonical plan generation (v7.5).
|
|
74
117
|
|
|
75
|
-
Returns ``{status, event_id,
|
|
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
|
|
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
|
-
|
|
100
|
-
#
|
|
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)
|
|
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",
|