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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/auto_update.py +50 -22
- package/src/cli.py +2 -0
- package/src/enforcement_engine.py +247 -0
- package/src/evidence_ledger.py +31 -0
- package/src/guardian_config.py +3 -1
- package/src/hooks/post_tool_use.py +228 -2
- package/src/hooks/stop.py +112 -0
- package/src/local_context/api.py +23 -18
- package/src/local_context/health.py +9 -4
- package/src/local_context/usage_events.py +85 -0
- package/src/mcp_write_queue.py +21 -1
- package/src/plugins/protocol.py +272 -1
- package/src/plugins/workflow.py +99 -2
- package/src/pre_answer_router.py +114 -3
- package/src/pre_answer_runtime.py +3 -0
- package/src/presets/guardian_default.json +7 -4
- package/src/provider_circuit_breaker.py +18 -0
- package/src/rules/core-rules.json +11 -3
- package/src/scripts/deep-sleep/collect.py +40 -0
- package/src/scripts/jargon_first_response.py +12 -9
- package/src/scripts/nexo-email-monitor.py +235 -56
- package/templates/CLAUDE.md.template +1 -0
- package/templates/CODEX.AGENTS.md.template +1 -0
- package/templates/core-prompts/r26-jargon-rewrite.md +1 -0
- package/templates/core-prompts/r34-capability-reality-check.md +1 -0
- package/templates/core-prompts/r35-execute-before-ask.md +1 -0
- package/templates/core-prompts/r36-production-change-log-required.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +2 -1
- package/tool-enforcement-map.json +4 -2
|
@@ -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.
|
|
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":
|
|
8
|
-
"blocking":
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
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
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
"
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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–
|
|
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": {
|