nexo-brain 7.31.4 → 7.31.7

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.
@@ -190,6 +190,9 @@ def record_session_outcome(
190
190
  "consecutive_failures": 0,
191
191
  "closed_at": _now(),
192
192
  "recovered_from": entry.get("reason") if was_open else None,
193
+ # The pause email promises "another notice when work resumes":
194
+ # arm that notice only when the OPENING was actually notified.
195
+ "resume_notice_pending": bool(was_open and entry.get("operator_notified_at")),
193
196
  }
194
197
  _save_state(state)
195
198
  return state[backend]
@@ -213,6 +216,21 @@ def record_session_outcome(
213
216
  return entry
214
217
 
215
218
 
219
+ def should_notify_operator_resumed(backend: str) -> bool:
220
+ """True exactly once after a notified opening closes (engine resumed).
221
+
222
+ The pause notice tells the operator "you will get another notice when
223
+ work resumes" — this is that notice's gate. Clears the flag on read.
224
+ """
225
+ state = _load_state()
226
+ entry = _entry(state, backend)
227
+ if entry.get("state") == "closed" and entry.get("resume_notice_pending"):
228
+ entry["resume_notice_pending"] = False
229
+ _save_state(state)
230
+ return True
231
+ return False
232
+
233
+
216
234
  def should_notify_operator(backend: str) -> bool:
217
235
  """True exactly once per opening — callers use it to send ONE notice."""
218
236
  state = _load_state()
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "_meta": {
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "NEXO Brain Core System Rules — battle-tested behavioral rules that ship with every installation",
5
5
  "created": "2026-03-26",
6
6
  "source": "Consolidated from production use, managed bootstrap CORE files, ticket/conversation analysis and product-core review",
7
- "total_rules": 70,
8
- "blocking": 65,
7
+ "total_rules": 71,
8
+ "blocking": 66,
9
9
  "advisory": 5,
10
10
  "immutable": true,
11
11
  "immutable_note": "Core rules are the DNA of NEXO Brain. They CANNOT be deleted or modified by the user. Only the migration system (version updates from the creators) can add, modify, or remove rules. Users can configure behavioral intensity (autonomy, communication, proactivity) but not the rules themselves."
@@ -630,6 +630,14 @@
630
630
  "importance": 5,
631
631
  "type": "blocking",
632
632
  "added_in": "1.1.0"
633
+ },
634
+ {
635
+ "id": "PC33",
636
+ "rule": "Stateful answers need evidence before wording",
637
+ "why": "Before answering about releases, commits, branches, tags, tickets, servers, ports, DNS, deployments, sent messages, uploads, installs, or verified/closed status, NEXO must consult live/continuity evidence or explicitly state the gap instead of relying on recollection.",
638
+ "importance": 5,
639
+ "type": "blocking",
640
+ "added_in": "1.2.0"
633
641
  }
634
642
  ]
635
643
  }
@@ -325,6 +325,19 @@ def collect_learnings() -> list[dict]:
325
325
  return safe_query(NEXO_DB, "SELECT * FROM learnings ORDER BY updated_at DESC LIMIT 200")
326
326
 
327
327
 
328
+ def collect_all_active_learnings(db_path: Path | str = NEXO_DB) -> list[dict]:
329
+ """Full active learning list for Deep Sleep file-backed analysis."""
330
+ status_filter = ""
331
+ if "status" in _table_columns(Path(db_path), "learnings"):
332
+ status_filter = "WHERE COALESCE(status, 'active') NOT IN ('superseded', 'deleted', 'archived')"
333
+ return safe_query(
334
+ Path(db_path),
335
+ "SELECT * FROM learnings "
336
+ f"{status_filter} "
337
+ "ORDER BY COALESCE(updated_at, created_at) DESC, id DESC"
338
+ )
339
+
340
+
328
341
  def collect_diaries(target_date: str) -> list[dict]:
329
342
  """Today's session diaries."""
330
343
  # Diaries store created_at as unix timestamp or ISO string -- handle both
@@ -959,6 +972,9 @@ def main():
959
972
  learnings = collect_learnings()
960
973
  print(f" Learnings: {len(learnings)}")
961
974
 
975
+ all_active_learnings = collect_all_active_learnings()
976
+ print(f" Active learnings dump: {len(all_active_learnings)}")
977
+
962
978
  diaries = collect_diaries(target_date)
963
979
  print(f" Diaries today: {len(diaries)}")
964
980
 
@@ -997,6 +1013,12 @@ def main():
997
1013
  ]
998
1014
  shared_parts.append(format_section("ACTIVE FOLLOWUPS", followups))
999
1015
  shared_parts.append(format_section("LEARNINGS (recent 200)", learnings))
1016
+ shared_parts.append(
1017
+ "\n## FULL ACTIVE LEARNINGS DUMP\n"
1018
+ f"- File: learnings-dump.json\n"
1019
+ f"- Count: {len(all_active_learnings)}\n"
1020
+ "- Use this file for complete learning coverage; do not call nexo_learning_list from inside Deep Sleep analysis.\n"
1021
+ )
1000
1022
  shared_parts.append(format_section("SESSION DIARIES TODAY", diaries))
1001
1023
  shared_parts.append(format_section("TRUST SCORE HISTORY (7d)", trust_history))
1002
1024
  shared_parts.append(format_section("DISCOVERED NON-CORE CONTENT", extras))
@@ -1012,6 +1034,22 @@ def main():
1012
1034
  long_horizon_file.write_text(json.dumps(long_horizon, indent=2, ensure_ascii=False), encoding="utf-8")
1013
1035
  print(f" Long horizon JSON: {long_horizon_file.name}")
1014
1036
 
1037
+ learnings_dump_file = date_dir / "learnings-dump.json"
1038
+ learnings_dump_file.write_text(
1039
+ json.dumps(
1040
+ {
1041
+ "generated_at": datetime.now().isoformat(),
1042
+ "source_db": str(NEXO_DB),
1043
+ "active_learning_count": len(all_active_learnings),
1044
+ "learnings": all_active_learnings,
1045
+ },
1046
+ indent=2,
1047
+ ensure_ascii=False,
1048
+ ),
1049
+ encoding="utf-8",
1050
+ )
1051
+ print(f" Full learnings JSON: {learnings_dump_file.name} ({len(all_active_learnings)} active)")
1052
+
1015
1053
  # Individual session files
1016
1054
  session_files_written = []
1017
1055
  session_txt_map = {}
@@ -1091,11 +1129,13 @@ def main():
1091
1129
  "total_tool_uses": sum(s["tool_use_count"] for s in sessions),
1092
1130
  "followups_active": len(followups),
1093
1131
  "learnings_count": len(learnings),
1132
+ "active_learnings_count": len(all_active_learnings),
1094
1133
  "diaries_today": len(diaries),
1095
1134
  "error_log_files": len(error_logs),
1096
1135
  "date_dir": str(date_dir),
1097
1136
  "shared_context_file": str(shared_file),
1098
1137
  "long_horizon_file": str(long_horizon_file),
1138
+ "learnings_dump_file": str(learnings_dump_file),
1099
1139
  "context_file": str(legacy_file),
1100
1140
  "total_size_bytes": total_size,
1101
1141
  }
@@ -9,7 +9,8 @@ Token list (case-insensitive substring match), taken verbatim from the
9
9
  followup spec:
10
10
 
11
11
  Learning #, protocol debt, cortex eval, runtime-core, guard_check,
12
- heartbeat, pre-emptive guard, enforcer, task_open, task_close, NF-
12
+ heartbeat, pre-emptive guard, enforcer, task_open, task_close, NF-,
13
+ Subscription inactive, WSL, scorer, match, cortex
13
14
 
14
15
  Use as:
15
16
 
@@ -53,6 +54,11 @@ PROHIBITED_TOKENS: Sequence[str] = (
53
54
  "task_close",
54
55
  "heartbeat",
55
56
  "NF-",
57
+ "Subscription inactive",
58
+ "WSL",
59
+ "scorer",
60
+ "match",
61
+ "cortex",
56
62
  )
57
63
 
58
64
  # Heuristic signals from the *user* message that mean "operator asked for
@@ -103,15 +109,13 @@ def scan_text(text: str, *, tokens: Iterable[str] = PROHIBITED_TOKENS) -> List[d
103
109
  target = _first_visible_paragraph(text)
104
110
  if not target:
105
111
  return []
106
- lowered = target.lower()
107
112
  found: List[dict] = []
108
113
  for token in tokens:
109
- needle = token.lower()
110
- start = 0
111
- while True:
112
- idx = lowered.find(needle, start)
113
- if idx < 0:
114
- break
114
+ pattern = re.escape(token)
115
+ if token.isalpha() and len(token) <= 6:
116
+ pattern = rf"\b{pattern}\b"
117
+ for match in re.finditer(pattern, target, re.IGNORECASE):
118
+ idx = match.start()
115
119
  snippet_start = max(0, idx - 25)
116
120
  snippet_end = min(len(target), idx + len(token) + 25)
117
121
  found.append({
@@ -119,7 +123,6 @@ def scan_text(text: str, *, tokens: Iterable[str] = PROHIBITED_TOKENS) -> List[d
119
123
  "index": idx,
120
124
  "snippet": target[snippet_start:snippet_end].replace("\n", " "),
121
125
  })
122
- start = idx + max(1, len(needle))
123
126
  found.sort(key=lambda row: row["index"])
124
127
  return found
125
128
 
@@ -90,12 +90,83 @@ CONCURRENT_THRESHOLD_MINUTES = 15
90
90
  MAX_CONCURRENT_SESSIONS = 2
91
91
  MAX_EMAIL_ATTEMPTS = 3
92
92
  DEFAULT_OPERATOR_LANGUAGE = "en"
93
+ SUPPORTED_NOTIFICATION_LANGUAGES = {"en", "es"}
93
94
  EMPTY_INBOX_BACKOFF_STEPS = (
94
95
  (12, 60 * 60),
95
96
  (6, 30 * 60),
96
97
  (3, 15 * 60),
97
98
  )
98
99
  DEFAULT_ASSISTANT_NAME = "Nova"
100
+ HEADLESS_NOTIFICATION_TEMPLATES = {
101
+ "email_needs_interactive": {
102
+ "es": {
103
+ "subject": "[{assistant_name}] Emails que necesitan tu atención ({count})",
104
+ "body": (
105
+ "Hola {user_name},\n\n"
106
+ "Los siguientes emails ya se han intentado {max_attempts} veces sin poder completarse:\n\n"
107
+ "{details}\n\n"
108
+ "Los he marcado como `needs_interactive`.\n"
109
+ "Abre {assistant_name} Desktop y pregunta por el email afectado para resolverlo manualmente.\n\n"
110
+ "— {assistant_name}"
111
+ ),
112
+ },
113
+ "en": {
114
+ "subject": "[{assistant_name}] Emails requiring your attention ({count})",
115
+ "body": (
116
+ "Hello {user_name},\n\n"
117
+ "The following emails have already been attempted {max_attempts} times without completing:\n\n"
118
+ "{details}\n\n"
119
+ "I marked them as `needs_interactive`.\n"
120
+ "Open {assistant_name} Desktop and ask about the affected email so it can be resolved manually.\n\n"
121
+ "— {assistant_name}"
122
+ ),
123
+ },
124
+ },
125
+ "provider_breaker_open": {
126
+ "es": {
127
+ "subject": "[{assistant_name}] Motor {backend} en pausa ({reason})",
128
+ "body": (
129
+ "Hola {user_name},\n\n"
130
+ "He pausado las automatizaciones que usan {backend} porque no está disponible: {reason}.\n\n"
131
+ "El trabajo pendiente queda EN COLA; no se pierde nada.\n"
132
+ "Se reanudará automáticamente cuando el motor vuelva a estar disponible{retry_hint}.\n\n"
133
+ "No recibirás un aviso por cada tarea: solo este aviso y otro cuando el trabajo se reanude.\n\n"
134
+ "— {assistant_name}"
135
+ ),
136
+ },
137
+ "en": {
138
+ "subject": "[{assistant_name}] Engine {backend} paused ({reason})",
139
+ "body": (
140
+ "Hello {user_name},\n\n"
141
+ "I paused the automations that use {backend} because it is unavailable: {reason}.\n\n"
142
+ "Pending work stays QUEUED; nothing is lost.\n"
143
+ "It will resume automatically when the engine becomes available again{retry_hint}.\n\n"
144
+ "You will not get one notice per task: only this notice and another one when work resumes.\n\n"
145
+ "— {assistant_name}"
146
+ ),
147
+ },
148
+ },
149
+ "provider_breaker_resumed": {
150
+ "es": {
151
+ "subject": "[{assistant_name}] Motor {backend} reanudado",
152
+ "body": (
153
+ "Hola {user_name},\n\n"
154
+ "El motor {backend} vuelve a estar disponible y he reanudado las automatizaciones.\n\n"
155
+ "La cola pendiente se está procesando ya, en orden. No tienes que hacer nada.\n\n"
156
+ "— {assistant_name}"
157
+ ),
158
+ },
159
+ "en": {
160
+ "subject": "[{assistant_name}] Engine {backend} resumed",
161
+ "body": (
162
+ "Hello {user_name},\n\n"
163
+ "The {backend} engine is available again and I have resumed the automations.\n\n"
164
+ "The pending queue is being processed now, in order. Nothing is needed from you.\n\n"
165
+ "— {assistant_name}"
166
+ ),
167
+ },
168
+ },
169
+ }
99
170
  EVENT_TABLE_SQL = """
100
171
  CREATE TABLE IF NOT EXISTS email_events (
101
172
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1990,6 +2061,104 @@ def _uses_spanish(language: str) -> bool:
1990
2061
  return normalized == "es" or normalized.startswith("es-")
1991
2062
 
1992
2063
 
2064
+ def _normalize_notification_language(value: str | None = "") -> str:
2065
+ normalized = str(value or "").strip().lower().replace("_", "-")
2066
+ if normalized == "es" or normalized.startswith("es-"):
2067
+ return "es"
2068
+ if normalized == "en" or normalized.startswith("en-"):
2069
+ return "en"
2070
+ return ""
2071
+
2072
+
2073
+ def _desktop_settings_candidates() -> list[Path]:
2074
+ home = Path.home()
2075
+ candidates: list[Path] = []
2076
+ explicit_settings = os.environ.get("NEXO_DESKTOP_SETTINGS", "").strip()
2077
+ if explicit_settings:
2078
+ candidates.append(Path(explicit_settings))
2079
+ explicit_user_data = os.environ.get("NEXO_DESKTOP_USER_DATA", "").strip()
2080
+ if explicit_user_data:
2081
+ candidates.append(Path(explicit_user_data) / "app-settings.json")
2082
+ candidates.extend([
2083
+ home / "Library" / "Application Support" / "nexo-desktop-mvp" / "app-settings.json",
2084
+ home / "Library" / "Application Support" / "NEXO Desktop" / "app-settings.json",
2085
+ home / "Library" / "Application Support" / "nexo-desktop" / "app-settings.json",
2086
+ ])
2087
+ return candidates
2088
+
2089
+
2090
+ def _read_json_file(path: Path) -> dict:
2091
+ try:
2092
+ if path.is_file():
2093
+ payload = json.loads(path.read_text(encoding="utf-8"))
2094
+ if isinstance(payload, dict):
2095
+ return payload
2096
+ except Exception:
2097
+ return {}
2098
+ return {}
2099
+
2100
+
2101
+ def get_desktop_ui_language(settings_payload: dict | None = None) -> str:
2102
+ """Return the Desktop UI language for deterministic non-LLM notices."""
2103
+ payloads: list[dict] = []
2104
+ if isinstance(settings_payload, dict):
2105
+ payloads.append(settings_payload)
2106
+ else:
2107
+ payloads.extend(_read_json_file(candidate) for candidate in _desktop_settings_candidates())
2108
+
2109
+ for payload in payloads:
2110
+ app = payload.get("app") if isinstance(payload.get("app"), dict) else {}
2111
+ for candidate in (app.get("ui_language"), payload.get("ui_language")):
2112
+ lang = _normalize_notification_language(str(candidate or ""))
2113
+ if lang:
2114
+ return lang
2115
+ return ""
2116
+
2117
+
2118
+ def resolve_notification_language(
2119
+ operator_language: str | None = "",
2120
+ *,
2121
+ settings_payload: dict | None = None,
2122
+ ) -> str:
2123
+ """Resolve static notification language: Desktop UI first, profile fallback, EN final."""
2124
+ return (
2125
+ get_desktop_ui_language(settings_payload=settings_payload)
2126
+ or _normalize_notification_language(operator_language)
2127
+ or DEFAULT_OPERATOR_LANGUAGE
2128
+ )
2129
+
2130
+
2131
+ def render_headless_notification_template(
2132
+ template_id: str,
2133
+ lang: str,
2134
+ variables: dict,
2135
+ ) -> tuple[str, str]:
2136
+ normalized = _normalize_notification_language(lang) or DEFAULT_OPERATOR_LANGUAGE
2137
+ template_group = HEADLESS_NOTIFICATION_TEMPLATES.get(template_id)
2138
+ if not template_group:
2139
+ raise KeyError(f"unknown headless notification template: {template_id}")
2140
+ template = template_group.get(normalized) or template_group[DEFAULT_OPERATOR_LANGUAGE]
2141
+ values = {key: "" if value is None else value for key, value in dict(variables or {}).items()}
2142
+ return template["subject"].format(**values), template["body"].format(**values)
2143
+
2144
+
2145
+ def _provider_reason(reason: str, lang: str) -> str:
2146
+ normalized = _normalize_notification_language(lang) or DEFAULT_OPERATOR_LANGUAGE
2147
+ reasons = {
2148
+ "es": {
2149
+ "credits": "créditos agotados",
2150
+ "rate_limit": "límite de uso alcanzado",
2151
+ "auth": "sesión caducada (hay que volver a conectar)",
2152
+ },
2153
+ "en": {
2154
+ "credits": "credits exhausted",
2155
+ "rate_limit": "rate limit reached",
2156
+ "auth": "session expired (needs re-login)",
2157
+ },
2158
+ }
2159
+ return reasons.get(normalized, reasons["en"]).get(reason, reason)
2160
+
2161
+
1993
2162
  def _localized_operator_escalation_email(
1994
2163
  *,
1995
2164
  operator_name: str,
@@ -1998,33 +2167,18 @@ def _localized_operator_escalation_email(
1998
2167
  exhausted_count: int,
1999
2168
  details: str,
2000
2169
  ) -> tuple[str, str]:
2001
- # Phase 1.6 — subjects are signed by the AGENT (assistant_name, dynamic
2002
- # per install), not by the product: the operator talks to their agent.
2003
- if _uses_spanish(operator_language):
2004
- # Phase 1.6 — this branch used to contain the ENGLISH text copied
2005
- # verbatim (operator-reported 10-jun: escalation mails arrived in
2006
- # English with language=es configured). Real Spanish now.
2007
- subject = f"[{assistant_name}] Emails que necesitan tu atención ({exhausted_count})"
2008
- body = (
2009
- f"Hola {operator_name},\n\n"
2010
- f"Los siguientes emails ya se han intentado {MAX_EMAIL_ATTEMPTS} veces "
2011
- f"sin conseguirlo (la sesión muere antes de terminar):\n\n{details}\n\n"
2012
- "Los he marcado como `needs_interactive`. "
2013
- f"Abre {assistant_name} Desktop y pregunta por el email afectado para resolverlo manualmente.\n\n"
2014
- f"— {assistant_name}"
2015
- )
2016
- return subject, body
2017
-
2018
- subject = f"[{assistant_name}] Emails requiring manual attention ({exhausted_count})"
2019
- body = (
2020
- f"Hello {operator_name},\n\n"
2021
- f"The following emails have already been attempted {MAX_EMAIL_ATTEMPTS} times "
2022
- f"without succeeding (the session dies before completion):\n\n{details}\n\n"
2023
- f"I marked them as `needs_interactive`. "
2024
- f"Open {assistant_name} Desktop and ask about the affected email so it can be resolved manually.\n\n"
2025
- f"— {assistant_name}"
2170
+ lang = resolve_notification_language(operator_language)
2171
+ return render_headless_notification_template(
2172
+ "email_needs_interactive",
2173
+ lang,
2174
+ {
2175
+ "assistant_name": assistant_name,
2176
+ "user_name": operator_name,
2177
+ "count": exhausted_count,
2178
+ "max_attempts": MAX_EMAIL_ATTEMPTS,
2179
+ "details": details,
2180
+ },
2026
2181
  )
2027
- return subject, body
2028
2182
 
2029
2183
 
2030
2184
  def _operator_aliases(config) -> list[str]:
@@ -2443,6 +2597,45 @@ def _decrement_attempts(email_ids):
2443
2597
  log.warning(f"Failed to decrement attempts: {e}")
2444
2598
 
2445
2599
 
2600
+ def _notify_provider_breaker_resumed_once():
2601
+ """Send the resume notice the pause email promises — once per recovery."""
2602
+ try:
2603
+ from provider_circuit_breaker import should_notify_operator_resumed
2604
+ for backend in ("claude_code", "codex"):
2605
+ if not should_notify_operator_resumed(backend):
2606
+ continue
2607
+ operator_name, assistant_name, operator_language = _get_operator_info()
2608
+ config = load_config()
2609
+ operator_email = config.get("operator_email", "") if config else ""
2610
+ if not operator_email:
2611
+ return
2612
+ subject, body = render_headless_notification_template(
2613
+ "provider_breaker_resumed",
2614
+ resolve_notification_language(operator_language),
2615
+ {
2616
+ "assistant_name": assistant_name,
2617
+ "user_name": operator_name,
2618
+ "backend": backend,
2619
+ },
2620
+ )
2621
+ body_file = BASE_DIR / ".breaker-resume-body.txt"
2622
+ body_file.write_text(body, encoding="utf-8")
2623
+ send_script = get_send_reply_script_path(local_script_dir=_script_dir)
2624
+ subprocess.run(
2625
+ [
2626
+ sys.executable, str(send_script),
2627
+ "--to", f"{operator_name} <{operator_email}>",
2628
+ "--subject", subject,
2629
+ "--body-file", str(body_file),
2630
+ ],
2631
+ timeout=30,
2632
+ capture_output=True,
2633
+ )
2634
+ log.info(f"Breaker resume notice sent for {backend}")
2635
+ except Exception as e:
2636
+ log.warning(f"Breaker resume notice failed: {e}")
2637
+
2638
+
2446
2639
  def _notify_provider_breaker_open_once(error):
2447
2640
  """Fase 1.6 — ONE operator notice per breaker opening, in their language.
2448
2641
 
@@ -2464,36 +2657,21 @@ def _notify_provider_breaker_open_once(error):
2464
2657
  retry_hint = ""
2465
2658
  if error.retry_after_ts:
2466
2659
  retry_hint = datetime.fromtimestamp(error.retry_after_ts).strftime("%H:%M")
2467
- reason_es = {
2468
- "credits": "créditos agotados",
2469
- "rate_limit": "límite de uso alcanzado",
2470
- "auth": "sesión caducada (hay que volver a conectar)",
2471
- }.get(error.reason, error.reason)
2472
- reason_en = {
2473
- "credits": "credits exhausted",
2474
- "rate_limit": "rate limit reached",
2475
- "auth": "session expired (needs re-login)",
2476
- }.get(error.reason, error.reason)
2477
- if _uses_spanish(operator_language):
2478
- subject = f"[{assistant_name}] Motor {error.backend} en pausa ({reason_es})"
2479
- body = (
2480
- f"Hola {operator_name},\n\n"
2481
- f"He pausado las automatizaciones que usan {error.backend} porque está no disponible: {reason_es}.\n\n"
2482
- "El trabajo pendiente queda EN COLA (no se pierde nada) y se reanudará solo en cuanto el motor vuelva"
2483
- + (f" (próxima comprobación ~{retry_hint})" if retry_hint else "")
2484
- + ".\n\nNo recibirás un aviso por cada tarea: solo este, y otro cuando se reanude.\n\n"
2485
- f"— {assistant_name}"
2486
- )
2487
- else:
2488
- subject = f"[{assistant_name}] Engine {error.backend} paused ({reason_en})"
2489
- body = (
2490
- f"Hello {operator_name},\n\n"
2491
- f"I paused the automations that use {error.backend} because it is unavailable: {reason_en}.\n\n"
2492
- "Pending work stays QUEUED (nothing is lost) and resumes automatically once the engine is back"
2493
- + (f" (next probe ~{retry_hint})" if retry_hint else "")
2494
- + ".\n\nYou will not get one notice per task — just this one, and another when work resumes.\n\n"
2495
- f"— {assistant_name}"
2496
- )
2660
+ lang = resolve_notification_language(operator_language)
2661
+ retry_label = ""
2662
+ if retry_hint:
2663
+ retry_label = f" (próxima comprobación ~{retry_hint})" if lang == "es" else f" (next probe ~{retry_hint})"
2664
+ subject, body = render_headless_notification_template(
2665
+ "provider_breaker_open",
2666
+ lang,
2667
+ {
2668
+ "assistant_name": assistant_name,
2669
+ "user_name": operator_name,
2670
+ "backend": error.backend,
2671
+ "reason": _provider_reason(error.reason, lang),
2672
+ "retry_hint": retry_label,
2673
+ },
2674
+ )
2497
2675
  body_file = BASE_DIR / ".breaker-notice-body.txt"
2498
2676
  body_file.write_text(body, encoding="utf-8")
2499
2677
  send_script = get_send_reply_script_path(local_script_dir=_script_dir)
@@ -2675,6 +2853,7 @@ def main():
2675
2853
  backoff_state = load_empty_inbox_backoff_state()
2676
2854
  debt_block = scan_debt()
2677
2855
 
2856
+ _notify_provider_breaker_resumed_once()
2678
2857
  reconcile_orphaned_seen(config, hours=24)
2679
2858
  reconcile_terminal_unseen(config, hours=48)
2680
2859
  # Recovery window widened from 24h to 7 days (168h): a single email can
@@ -37,6 +37,7 @@ The full protected registry lives in NEXO Brain `core_rules`. Keep this compact
37
37
  - Do not ask the user to do work that NEXO can safely do with available tools.
38
38
  - Prepare up to the safe boundary; ask only for real decisions, missing credentials, approvals, payments, destructive actions, or legally required consent.
39
39
  - Verify current reality before claiming facts about external state, product capabilities, dates, versions, servers, routes, ports, schemas, or tickets.
40
+ - Before answering about releases, commits, branches, tags, tickets, servers, ports, DNS, deployments, sent messages, uploads, installs, or verified/closed status, consult live/continuity evidence or explicitly state that the state is not verified yet.
40
41
  - Do not invent or deny NEXO capabilities without checking the live product/source of truth first.
41
42
  - Preserve one continuous user-facing identity across supported clients and sessions.
42
43
  - Treat Brain, calibration, profile, decisions, learnings, diary, and followups as stronger authority than legacy client memory files.
@@ -32,6 +32,7 @@ The full protected registry lives in NEXO Brain `core_rules`. Keep this compact
32
32
  - Do not ask the user to do work that NEXO can safely do with available tools.
33
33
  - Prepare up to the safe boundary; ask only for real decisions, missing credentials, approvals, payments, destructive actions, or legally required consent.
34
34
  - Verify current reality before claiming facts about external state, product capabilities, dates, versions, servers, routes, ports, schemas, or tickets.
35
+ - Before answering about releases, commits, branches, tags, tickets, servers, ports, DNS, deployments, sent messages, uploads, installs, or verified/closed status, consult live/continuity evidence or explicitly state that the state is not verified yet.
35
36
  - Do not invent or deny NEXO capabilities without checking the live product/source of truth first.
36
37
  - Preserve one continuous user-facing identity across supported clients and sessions.
37
38
  - Treat Brain, calibration, profile, decisions, learnings, diary, and followups as stronger authority than legacy client memory files.
@@ -0,0 +1 @@
1
+ Reescribe antes de responder: la primera respuesta o el resumen de cierre usa terminos internos sin traducir. Empieza por la conclusion en lenguaje normal y, si necesitas mencionar un termino tecnico, explicalo en la misma frase.
@@ -0,0 +1 @@
1
+ Antes de negar una capacidad o declarar un bloqueo, verifica la realidad viva: catálogo/sistema, recetas o skills previos, scripts existentes, producto activo, BD o fuente oficial. Si no has hecho esa comprobación en este turno, no afirmes que no se puede/no existe; ejecuta la comprobación primero o formula el estado como no verificado.
@@ -0,0 +1 @@
1
+ La ultima orden del operador fue imperativa y la accion parece reversible. Ejecuta el siguiente paso seguro con las herramientas disponibles y deja alternativas o dudas no bloqueantes para el final. Pregunta solo si falta una credencial, hay pago, accion destructiva o ambiguedad real.
@@ -0,0 +1 @@
1
+ Has ejecutado una mutacion de produccion. Antes de cerrar la tarea, registra el cambio con `nexo_change_log(...)` o cierra con `nexo_task_close(...)` incluyendo archivos/artefacto, motivo, riesgo y verificacion real.
@@ -29,7 +29,7 @@
29
29
  - **Dissonance:** user contradicts memory→`nexo_cognitive_dissonance`. Frustrated→force=True
30
30
  - **Trust:** <40=paranoid verify twice, >80=fluid. Check: `nexo_cognitive_trust`
31
31
 
32
- ## Guardian Rules (Phase 2 Layer 3 — R26–R33)
32
+ ## Guardian Rules (Phase 2 Layer 3 — R26–R37)
33
33
  - **R26 no jargon:** Never use NEXO internal jargon (protocol debt, cortex evaluation, guard_check, heartbeat, runtime check, shadow/soft/hard) when talking to the user. Translate to plain operational language. First answer to the operator on any thread leads with the recommendation or status, not the diagnostic.
34
34
  - **R26b silent enforcement:** Never tell the user that Guardian / Protocol Enforcer / a system reminder forced you to do something. Apply the required action silently. When a reminder says "Do not produce visible text", that silence applies to the entire reminder turn: no prose before the tool call, no prose after it, and if there is no fresh operator message then the visible output must stay empty.
35
35
  - **R27 2–3 sentences:** Keep each decision point to 2–3 short sentences. Details only if asked. Prefer conclusion + next action over option dumps or raw diagnostics.
@@ -40,3 +40,4 @@
40
40
  - **R32 Nora/María read-only:** Their infrastructure is observed, never written. Only act on Nora/María resources with explicit permission in the current user message. See learnings #283, #336, #358; the Layer 2 engine enforces this with R25.
41
41
  - **R33 procedure lookup:** To discover available tools, reusable skills, or the canonical location of an artifact, use `nexo_system_catalog` / `nexo_tool_explain` / `nexo_skill_match` — do not assume tool names from memory. If a skill already covers the flow, use it instead of improvising.
42
42
  - **R34 identity coherence:** NEXO is a single identity shared across terminals. When there are 2+ active sessions, THEY ARE ALL YOU. Before denying an action ("I haven't done that" / "I didn't do that" / "I didn't"), first consult `nexo_recent_context` / `nexo_session_diary_read` / `nexo_change_log`. Another terminal may have acted. Anti-example: saying "I did not send that email" without checking `nexo_change_log` when a sibling session already sent it.
43
+ - **R37 pre-answer evidence:** Before answering about releases, commits, branches, tags, tickets, servers, ports, DNS, deployments, sent messages, uploads, installs, verified/closed status, or prior external actions, consult live/continuity evidence first. If evidence is unavailable in the turn, say the state is not verified yet and keep checking instead of guessing from memory.
@@ -2244,7 +2244,7 @@
2244
2244
  "triggers_after": []
2245
2245
  },
2246
2246
  "nexo_guardian_rule_override": {
2247
- "description": "Temporarily override a Guardian rule's mode (off/shadow/soft/hard) with TTL (1h/24h/session). Core rules (R13/R14/R16/R25/R30) cannot be set to off. Writes to ~/.nexo/config/guardian-runtime-overrides.json.",
2247
+ "description": "Temporarily override a Guardian rule's mode (off/shadow/soft/hard) with TTL (1h/24h/session). Core rules (R13/R14/R16/R25/R30/R34/R37) cannot be set to off. Writes to ~/.nexo/config/guardian-runtime-overrides.json.",
2248
2248
  "category": "guardian",
2249
2249
  "source": "server",
2250
2250
  "requires": [],
@@ -5192,7 +5192,9 @@
5192
5192
  "R14_correction_learning",
5193
5193
  "R16_declared_done",
5194
5194
  "R25_nora_maria_read_only",
5195
- "R30_pre_done_evidence_system_prompt"
5195
+ "R30_pre_done_evidence_system_prompt",
5196
+ "R34_identity_coherence",
5197
+ "R37_pre_answer_evidence_gate"
5196
5198
  ],
5197
5199
  "core_rules_disallow_off": true,
5198
5200
  "server_side_rules_schema": {