nexo-brain 7.2.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.
@@ -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
+ ]
@@ -103,6 +103,80 @@
103
103
  "pattern": "git\\s+(reset\\s+--hard|push\\s+(-f|--force)|clean\\s+-fd?x?)"
104
104
  }
105
105
  },
106
+ {
107
+ "type": "destructive_command",
108
+ "name": "curl_pipe_shell",
109
+ "aliases": [
110
+ "curl | bash",
111
+ "curl | sh",
112
+ "wget | bash",
113
+ "wget | sh"
114
+ ],
115
+ "metadata": {
116
+ "shell": "posix",
117
+ "severity": "critical",
118
+ "pattern": "(curl|wget)[^|]*\\|\\s*(sudo\\s+)?(bash|sh|zsh)\\b",
119
+ "note": "Piping network content straight to shell executes unreviewed code"
120
+ }
121
+ },
122
+ {
123
+ "type": "destructive_command",
124
+ "name": "dd_to_device",
125
+ "aliases": [
126
+ "dd of=/dev/"
127
+ ],
128
+ "metadata": {
129
+ "shell": "posix",
130
+ "severity": "critical",
131
+ "pattern": "\\bdd\\s+.*?\\bof=/dev/\\S+",
132
+ "note": "dd writing to a block device wipes whatever is there"
133
+ }
134
+ },
135
+ {
136
+ "type": "destructive_command",
137
+ "name": "chmod_recursive_wide_open",
138
+ "aliases": [
139
+ "chmod -R 777",
140
+ "chmod -R 666",
141
+ "chmod -R a+rw"
142
+ ],
143
+ "metadata": {
144
+ "shell": "posix",
145
+ "severity": "high",
146
+ "pattern": "\\bchmod\\s+-R\\s+(777|666|a\\+rw)\\b",
147
+ "note": "Recursive world-writable is almost never intended"
148
+ }
149
+ },
150
+ {
151
+ "type": "destructive_command",
152
+ "name": "ssh_remote_overwrite",
153
+ "aliases": [
154
+ "ssh host \"cat > file\"",
155
+ "ssh host \"tee file\"",
156
+ "ssh host \"sed -i ...\"",
157
+ "ssh host \"echo ... > file\""
158
+ ],
159
+ "metadata": {
160
+ "shell": "ssh",
161
+ "severity": "high",
162
+ "pattern": "\\bssh\\b[^'\"`]*?(?:['\"`])(?:[^'\"`]*?)(?:cat\\s*>>?\\s|tee\\s|sed\\s+-i\\b|echo\\s.*\\s>>?\\s|rm\\s+-\\S*[rRfF])",
163
+ "note": "Remote-write patterns routed through ssh bypass the local destructive gate"
164
+ }
165
+ },
166
+ {
167
+ "type": "destructive_command",
168
+ "name": "scp_rsync_upload",
169
+ "aliases": [
170
+ "scp local host:path",
171
+ "rsync local host:path"
172
+ ],
173
+ "metadata": {
174
+ "shell": "ssh",
175
+ "severity": "medium",
176
+ "pattern": "\\b(scp|rsync)\\b[^|&;]*?\\s\\S+\\s+[^:\\s]+:[^\\s]+",
177
+ "note": "Upload direction mutates remote filesystem"
178
+ }
179
+ },
106
180
  {
107
181
  "type": "legacy_path",
108
182
  "name": "claude_hooks_to_nexo",
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """guardian_metrics_aggregate — Plan Consolidado 0.25.
3
+
4
+ Reads ``~/.nexo/logs/guardian-telemetry.ndjson`` (produced per-enqueue
5
+ by ``guardian_telemetry.log_event``) and the latest drift baseline under
6
+ ``~/.nexo/reports/drift-baseline-*.json``, and writes a rolling aggregate
7
+ of KPIs to ``~/.nexo/logs/guardian-metrics.ndjson``.
8
+
9
+ KPIs (Plan Consolidado 0.25):
10
+ - capture_rate (injected / triggered)
11
+ - core_rule_violations_per_session (R13/R14/R16/R25/R30 injects / #sessions)
12
+ - declared_done_without_evidence_ratio (R16 hard hits / sessions)
13
+ - false_positive_correction_rate (events tagged fp / events total)
14
+ - avg_minutes_between_guard_check_failures
15
+
16
+ Pure reader: never writes inside the telemetry file. ``NEXO_HOME`` is
17
+ honoured so tests isolate via tmp_path.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import statistics
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Iterable
28
+
29
+
30
+ CORE_RULE_IDS = {
31
+ "R13_pre_edit_guard",
32
+ "R14_correction_learning",
33
+ "R16_declared_done",
34
+ "R25_nora_maria_read_only",
35
+ "R30_pre_done_evidence_system_prompt",
36
+ }
37
+
38
+
39
+ def _nexo_home() -> Path:
40
+ env = os.environ.get("NEXO_HOME")
41
+ if env:
42
+ return Path(env)
43
+ return Path.home() / ".nexo"
44
+
45
+
46
+ def _read_ndjson(path: Path) -> Iterable[dict]:
47
+ try:
48
+ with path.open("r", encoding="utf-8") as fh:
49
+ for line in fh:
50
+ line = line.strip()
51
+ if not line:
52
+ continue
53
+ try:
54
+ yield json.loads(line)
55
+ except json.JSONDecodeError:
56
+ continue
57
+ except OSError:
58
+ return
59
+
60
+
61
+ def _read_latest_drift_baseline(home: Path) -> dict | None:
62
+ reports_dir = home / "reports"
63
+ if not reports_dir.is_dir():
64
+ return None
65
+ candidates = sorted(reports_dir.glob("drift-baseline-*.json"))
66
+ if not candidates:
67
+ return None
68
+ try:
69
+ return json.loads(candidates[-1].read_text(encoding="utf-8"))
70
+ except (OSError, json.JSONDecodeError):
71
+ return None
72
+
73
+
74
+ def aggregate(home: Path | None = None) -> dict:
75
+ home = home if home is not None else _nexo_home()
76
+ telemetry_path = home / "logs" / "guardian-telemetry.ndjson"
77
+ events = list(_read_ndjson(telemetry_path))
78
+
79
+ by_rule: dict[str, dict[str, int]] = {}
80
+ sessions: set[str] = set()
81
+ core_violations = 0
82
+ r16_hard = 0
83
+ fp_count = 0
84
+ guard_check_failure_ts: list[float] = []
85
+
86
+ for ev in events:
87
+ rid = str(ev.get("rule_id") or ev.get("rule") or "").strip() or "unknown"
88
+ event = str(ev.get("event") or "").strip()
89
+ mode = str(ev.get("mode") or "").strip().lower()
90
+ session_id = str(ev.get("session_id") or ev.get("sid") or "").strip()
91
+ if session_id:
92
+ sessions.add(session_id)
93
+ bucket = by_rule.setdefault(rid, {"triggered": 0, "injected": 0, "fp": 0})
94
+ if event in ("trigger", "fire"):
95
+ bucket["triggered"] += 1
96
+ elif event in ("enqueue", "inject"):
97
+ bucket["injected"] += 1
98
+ bucket["triggered"] += 1
99
+ if rid in CORE_RULE_IDS:
100
+ core_violations += 1
101
+ if rid == "R16_declared_done" and mode == "hard":
102
+ r16_hard += 1
103
+ if ev.get("fp") is True:
104
+ bucket["fp"] += 1
105
+ fp_count += 1
106
+ if event == "guard_check_failed":
107
+ try:
108
+ guard_check_failure_ts.append(float(ev.get("ts") or 0))
109
+ except (TypeError, ValueError):
110
+ pass
111
+
112
+ total_triggered = sum(b["triggered"] for b in by_rule.values()) or 0
113
+ total_injected = sum(b["injected"] for b in by_rule.values()) or 0
114
+ capture_rate = (total_injected / total_triggered) if total_triggered else 0.0
115
+
116
+ guard_check_failure_ts.sort()
117
+ deltas_min = [
118
+ (b - a) / 60.0
119
+ for a, b in zip(guard_check_failure_ts, guard_check_failure_ts[1:])
120
+ if (b - a) > 0
121
+ ]
122
+ avg_min_between = statistics.mean(deltas_min) if deltas_min else None
123
+
124
+ n_sessions = len(sessions) or 1
125
+
126
+ baseline = _read_latest_drift_baseline(home)
127
+
128
+ return {
129
+ "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
130
+ "nexo_home": str(home),
131
+ "events_read": len(events),
132
+ "sessions_seen": len(sessions),
133
+ "capture_rate": round(capture_rate, 4),
134
+ "core_rule_violations_per_session": round(core_violations / n_sessions, 4),
135
+ "declared_done_without_evidence_ratio": round(r16_hard / n_sessions, 4),
136
+ "false_positive_correction_rate": round(
137
+ (fp_count / total_triggered) if total_triggered else 0.0, 4
138
+ ),
139
+ "avg_minutes_between_guard_check_failures": avg_min_between,
140
+ "per_rule": {
141
+ rid: {
142
+ "triggered": b["triggered"],
143
+ "injected": b["injected"],
144
+ "fp": b["fp"],
145
+ "baseline_hits": (baseline or {}).get("rule_counts", {}).get(rid, 0),
146
+ }
147
+ for rid, b in by_rule.items()
148
+ },
149
+ "drift_baseline_source": (
150
+ str(sorted((home / "reports").glob("drift-baseline-*.json"))[-1])
151
+ if baseline else None
152
+ ),
153
+ }
154
+
155
+
156
+ def write_metrics(result: dict, *, home: Path | None = None) -> Path:
157
+ home = home if home is not None else _nexo_home()
158
+ logs_dir = home / "logs"
159
+ logs_dir.mkdir(parents=True, exist_ok=True)
160
+ path = logs_dir / "guardian-metrics.ndjson"
161
+ with path.open("a", encoding="utf-8") as fh:
162
+ fh.write(json.dumps(result, ensure_ascii=False) + "\n")
163
+ return path
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ result = aggregate()
168
+ path = write_metrics(result)
169
+ print(
170
+ f"guardian_metrics_aggregate: {result['events_read']} events across "
171
+ f"{result['sessions_seen']} sessions → appended to {path}"
172
+ )
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__": # pragma: no cover
177
+ sys.exit(main(sys.argv[1:]))