nexo-brain 7.9.11 → 7.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.11",
3
+ "version": "7.9.13",
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,7 @@
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.9.11` is the current packaged-runtime line. Patch release over `7.9.10`: canonical Desktop lifecycle completion now requires both diary evidence and real stop evidence before Brain marks archive/delete/app-exit done, canonical plans add `wait_for_stop`, and Brain exposes the exact stop-wait primitives Desktop uses to avoid leaving duplicate live NEXO sessions behind a reopened conversation. Coordinated Desktop release: v0.28.12.
21
+ Version `7.9.13` is the current packaged-runtime line. Patch release over `7.9.12`: the remaining lifecycle/enforcement system prompts are now centralized in the core prompt catalog, operator-facing guard/followup/diary outputs are forced back into the operator language even when the base prompt is English, packaged installs recover the missing Guardian default preset, and the startup Guardian Health briefing now queries `hook_runs` correctly instead of reporting false green states. Coordinated Desktop release: v0.28.14.
22
22
 
23
23
  Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -3442,6 +3442,31 @@ async function runSetup() {
3442
3442
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
3443
3443
  ' exit 1',
3444
3444
  'fi',
3445
+ 'read_runtime_version() {',
3446
+ ' local base="${1:-}"',
3447
+ ' [ -n "$base" ] || return 0',
3448
+ ' local candidate=""',
3449
+ ' for candidate in "$base/version.json" "$base/package.json"; do',
3450
+ ' [ -f "$candidate" ] || continue',
3451
+ ' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue',
3452
+ ' return 0',
3453
+ ' done',
3454
+ ' return 0',
3455
+ '}',
3456
+ 'repair_stale_current_runtime() {',
3457
+ ' local core_root="$NEXO_HOME/core"',
3458
+ ' local current_root="$NEXO_HOME/core/current"',
3459
+ ' [ -d "$core_root" ] || return 0',
3460
+ ' [ -e "$current_root" ] || return 0',
3461
+ ' local core_version=""',
3462
+ ' local current_version=""',
3463
+ ' core_version="$(read_runtime_version "$core_root")"',
3464
+ ' current_version="$(read_runtime_version "$current_root")"',
3465
+ ' [ -n "$core_version" ] || return 0',
3466
+ ' [ "$core_version" = "$current_version" ] && return 0',
3467
+ ' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0',
3468
+ '}',
3469
+ 'repair_stale_current_runtime',
3445
3470
  'CLI_PY="$NEXO_CODE/cli.py"',
3446
3471
  'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then',
3447
3472
  ' NEXO_CODE="$NEXO_HOME/core/current"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.11",
3
+ "version": "7.9.13",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — 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",
@@ -593,6 +593,31 @@ def _runtime_cli_wrapper_text() -> str:
593
593
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
594
594
  ' exit 1\n'
595
595
  'fi\n'
596
+ 'read_runtime_version() {\n'
597
+ ' local base="${1:-}"\n'
598
+ ' [ -n "$base" ] || return 0\n'
599
+ ' local candidate=""\n'
600
+ ' for candidate in "$base/version.json" "$base/package.json"; do\n'
601
+ ' [ -f "$candidate" ] || continue\n'
602
+ ' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue\n'
603
+ ' return 0\n'
604
+ ' done\n'
605
+ ' return 0\n'
606
+ '}\n'
607
+ 'repair_stale_current_runtime() {\n'
608
+ ' local core_root="$NEXO_HOME/core"\n'
609
+ ' local current_root="$NEXO_HOME/core/current"\n'
610
+ ' [ -d "$core_root" ] || return 0\n'
611
+ ' [ -e "$current_root" ] || return 0\n'
612
+ ' local core_version=""\n'
613
+ ' local current_version=""\n'
614
+ ' core_version="$(read_runtime_version "$core_root")"\n'
615
+ ' current_version="$(read_runtime_version "$current_root")"\n'
616
+ ' [ -n "$core_version" ] || return 0\n'
617
+ ' [ "$core_version" = "$current_version" ] && return 0\n'
618
+ ' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0\n'
619
+ '}\n'
620
+ 'repair_stale_current_runtime\n'
596
621
  'CLI_PY="$NEXO_CODE/cli.py"\n'
597
622
  'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then\n'
598
623
  ' NEXO_CODE="$NEXO_HOME/core/current"\n'
@@ -4232,7 +4257,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
4232
4257
  def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
4233
4258
  import shutil
4234
4259
 
4235
- packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
4260
+ packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks", "presets"]
4236
4261
  flat_files = _runtime_flat_files(src_dir)
4237
4262
  copied_packages = 0
4238
4263
  copied_files = 0
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  import re
18
18
  import paths
19
19
  from core_prompts import render_core_prompt
20
+ from operator_language import append_operator_language_contract
20
21
 
21
22
  try:
22
23
  from r13_pre_edit_guard import should_inject_r13, ToolCallRecord, WATCHED_WRITE_TOOLS
@@ -2493,7 +2494,7 @@ class HeadlessEnforcer:
2493
2494
  if entry["enf"].get("level") == "must":
2494
2495
  p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
2495
2496
  if p:
2496
- prompts.append(p)
2497
+ prompts.append(append_operator_language_contract(p))
2497
2498
  _logger.info("END_PROMPTS: %d prompts to inject", len(prompts))
2498
2499
  return prompts
2499
2500
 
@@ -2545,7 +2546,8 @@ class HeadlessEnforcer:
2545
2546
  if tool in self.tools_called and not tag.startswith("periodic_"):
2546
2547
  _logger.info("SKIP: %s — already called", tag)
2547
2548
  return
2548
- self.injection_queue.append({"prompt": prompt, "tag": tag, "at": time.time(), "rule_id": rule_id})
2549
+ localized_prompt = append_operator_language_contract(prompt)
2550
+ self.injection_queue.append({"prompt": localized_prompt, "tag": tag, "at": time.time(), "rule_id": rule_id})
2549
2551
  _logger.info("ENQUEUED: %s (queue size: %d rule_id=%s)", tag, len(self.injection_queue), rule_id or "?")
2550
2552
  # Fase F telemetry — log one "injection" event per enqueue. The
2551
2553
  # engine does not see the final event lifecycle (compliance / FP);
@@ -13,6 +13,7 @@ import paths
13
13
 
14
14
  from core_prompts import render_core_prompt
15
15
  from db import create_protocol_debt, get_db
16
+ from operator_language import append_operator_language_contract
16
17
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
17
18
  from protocol_settings import get_protocol_strictness
18
19
  from product_mode import core_writes_allowed, is_protected_runtime_core_path
@@ -709,7 +710,7 @@ def _task_needs_workflow(task: dict | None) -> bool:
709
710
 
710
711
 
711
712
  def _append_protocol_warning(warnings: list[dict], message: str) -> None:
712
- clean = (message or "").strip()
713
+ clean = append_operator_language_contract(message)
713
714
  if not clean:
714
715
  return
715
716
  if any((item.get("message") or "").strip() == clean for item in warnings):
@@ -44,6 +44,8 @@ import sqlite3
44
44
  import time
45
45
  from pathlib import Path
46
46
 
47
+ from operator_language import append_operator_language_contract
48
+
47
49
 
48
50
  G1_GRACE_SECONDS = int(os.environ.get("NEXO_G1_GRACE_SECONDS", "120"))
49
51
  G1_RATE_LIMIT_SECONDS = int(os.environ.get("NEXO_G1_RATE_LIMIT_SECONDS", "180"))
@@ -229,12 +231,14 @@ def _render_message(task: dict) -> str:
229
231
  else: # ask
230
232
  action = "nexo_cortex_decide(...) or a user turn"
231
233
  reason = "ask mode needs clarifying input before the visible answer"
232
- return (
234
+ return append_operator_language_contract(
235
+ (
233
236
  "[NEXO Protocol Enforcer] G1 gate: task "
234
237
  f"{task_id} is open with response_mode='{mode}' "
235
238
  f"({reason}). Run {action} or close the task with "
236
239
  "nexo_task_close BEFORE emitting the next user-visible answer. "
237
240
  "Silent-compliant: do not mention this reminder to the user."
241
+ )
238
242
  )
239
243
 
240
244
 
@@ -31,6 +31,7 @@ if str(_DIR.parent) not in sys.path:
31
31
  sys.path.insert(0, str(_DIR.parent))
32
32
 
33
33
  from core_prompts import render_core_prompt
34
+ from operator_language import append_operator_language_contract
34
35
 
35
36
  _NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
36
37
 
@@ -112,9 +113,11 @@ def check_inbox_and_emit_reminder(sid: str, now: float | None = None) -> str | N
112
113
  if current - last_rem < INBOX_CHECK_THRESHOLD_SECONDS:
113
114
  return None # rate limit: max 1 reminder/min/session
114
115
  mark_reminder_sent(sid, current)
115
- return render_core_prompt(
116
- "post-tool-inbox-reminder",
117
- pending=str(pending),
116
+ return append_operator_language_contract(
117
+ render_core_prompt(
118
+ "post-tool-inbox-reminder",
119
+ pending=str(pending),
120
+ )
118
121
  )
119
122
 
120
123
 
@@ -35,6 +35,9 @@ fi
35
35
  NEXO_HOOK_START_MS=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo 0)
36
36
  NEXO_HOOK_NAME="session-start"
37
37
  _nexo_record_hook_run() {
38
+ if [ "${NEXO_DISABLE_SHELL_HOOK_RECORD:-0}" = "1" ]; then
39
+ return
40
+ fi
38
41
  local exit_code=$?
39
42
  local duration_ms=0
40
43
  if [ "$NEXO_HOOK_START_MS" != "0" ]; then
@@ -283,7 +286,7 @@ try:
283
286
  try:
284
287
  row = _conn.execute(
285
288
  \"SELECT COUNT(*) FROM hook_runs \"
286
- \"WHERE exit_code != 0 AND started_at > datetime('now', '-1 day')\"
289
+ \"WHERE exit_code != 0 AND started_at > (strftime('%s','now') - 24*3600)\"
287
290
  ).fetchone()
288
291
  _health['failing_hooks_24h'] = int(row[0] or 0) if row else 0
289
292
  except Exception:
@@ -47,12 +47,16 @@ def _record(duration_ms: int, exit_code: int, summary: str) -> None:
47
47
 
48
48
 
49
49
  def _run_step(cmd: list[str], timeout: int) -> tuple[int, str]:
50
+ env = os.environ.copy()
51
+ if cmd and str(cmd[-1]).endswith("session-start.sh"):
52
+ env["NEXO_DISABLE_SHELL_HOOK_RECORD"] = "1"
50
53
  try:
51
54
  result = subprocess.run(
52
55
  cmd,
53
56
  timeout=timeout,
54
57
  capture_output=True,
55
58
  text=True,
59
+ env=env,
56
60
  )
57
61
  tail = (result.stdout or result.stderr or "").strip().splitlines()[-1:] or [""]
58
62
  return result.returncode, tail[0][:200]
@@ -18,8 +18,10 @@ import hashlib
18
18
  import json
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
+ from core_prompts import render_core_prompt
22
+ from operator_language import build_operator_language_contract
21
23
 
22
- PLAN_VERSION = 4
24
+ PLAN_VERSION = 5
23
25
 
24
26
 
25
27
  # Actions that trigger a canonical diary+stop plan. `switch` and
@@ -69,15 +71,11 @@ def _diary_prompt_for_action(
69
71
  "app-exit": "The operator quit NEXO Desktop",
70
72
  }
71
73
  trailing = reason_by_action.get(action, "The conversation ended")
72
- return (
73
- f"[NEXO Protocol Enforcer] {trailing} "
74
- f"(title: {title!r}). Execute nexo_session_diary_write with a "
75
- "complete summary: decisions made, pending items, context for "
76
- "next session, mental state, and self-critique. Then execute "
77
- "nexo_stop with the current SID. Do not produce visible text. "
78
- "Apply the required action silently. Do not mention this "
79
- "reminder, Guardian, Protocol Enforcer, system reminders, or "
80
- "any internal enforcement to the user."
74
+ return render_core_prompt(
75
+ "lifecycle-diary-stop",
76
+ trailing=trailing,
77
+ title_repr=repr(title),
78
+ operator_language_contract_block=f" {build_operator_language_contract()}",
81
79
  )
82
80
 
83
81
 
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ """Operator-language helpers shared by prompts, hooks, and automations."""
4
+
5
+ from functools import lru_cache
6
+
7
+ from core_prompts import render_core_prompt
8
+
9
+
10
+ _LANGUAGE_LABELS = {
11
+ "ca": "Catalan (ca)",
12
+ "de": "German (de)",
13
+ "en": "English (en)",
14
+ "es": "Spanish (es)",
15
+ "fr": "French (fr)",
16
+ "it": "Italian (it)",
17
+ "ja": "Japanese (ja)",
18
+ "pt": "Portuguese (pt)",
19
+ "zh": "Chinese (zh)",
20
+ }
21
+
22
+
23
+ def normalize_operator_language(value: str | None = "") -> str:
24
+ raw = str(value or "").strip().lower().replace("_", "-")
25
+ if not raw:
26
+ return ""
27
+ primary = raw.split("-", 1)[0]
28
+ return primary or raw
29
+
30
+
31
+ def load_operator_language() -> str:
32
+ try:
33
+ from calibration_runtime import load_runtime_calibration
34
+ from paths import brain_dir
35
+
36
+ payload = load_runtime_calibration(brain_dir() / "calibration.json")
37
+ except Exception:
38
+ payload = {}
39
+ user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
40
+ return normalize_operator_language(
41
+ str(user.get("language") or "").strip()
42
+ or str(payload.get("language") or "").strip()
43
+ or str(payload.get("lang") or "").strip()
44
+ )
45
+
46
+
47
+ def describe_operator_language(language: str | None = "") -> str:
48
+ normalized = normalize_operator_language(language)
49
+ if not normalized:
50
+ return "the user's current conversation language"
51
+ return _LANGUAGE_LABELS.get(normalized, f"{normalized} language")
52
+
53
+
54
+ @lru_cache(maxsize=8)
55
+ def build_operator_language_contract(language: str | None = "") -> str:
56
+ label = describe_operator_language(language or load_operator_language())
57
+ return render_core_prompt(
58
+ "operator-language-contract",
59
+ operator_language_label=label,
60
+ )
61
+
62
+
63
+ def append_operator_language_contract(prompt: str, language: str | None = "") -> str:
64
+ clean = str(prompt or "").strip()
65
+ if not clean:
66
+ return clean
67
+ contract = build_operator_language_contract(language).strip()
68
+ if contract and contract not in clean:
69
+ clean = f"{clean} {contract}"
70
+ return clean
71
+
72
+
73
+ __all__ = [
74
+ "append_operator_language_contract",
75
+ "build_operator_language_contract",
76
+ "describe_operator_language",
77
+ "load_operator_language",
78
+ "normalize_operator_language",
79
+ ]
@@ -59,6 +59,7 @@ from automation_controls import (
59
59
  )
60
60
  from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
61
61
  from core_prompts import render_core_prompt
62
+ from operator_language import build_operator_language_contract, normalize_operator_language
62
63
  import db as nexo_db
63
64
 
64
65
  NEXO_DB = db_path()
@@ -73,6 +74,7 @@ LOCK_FILE = LOG_DIR / "followup-runner.lock"
73
74
  MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
74
75
  COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
75
76
  DEFAULT_ASSISTANT_NAME = "Nova"
77
+ DEFAULT_OPERATOR_LANGUAGE = "en"
76
78
 
77
79
  # ── Logging ─────────────────────────────────────────────────────────────
78
80
  def log(msg: str):
@@ -137,6 +139,17 @@ def _operator_attention_label_set(operator_name: str = "") -> tuple[str, str, st
137
139
  )
138
140
 
139
141
 
142
+ def _operator_language(operator: dict | None = None) -> str:
143
+ payload = operator if isinstance(operator, dict) else get_operator_profile()
144
+ return normalize_operator_language(
145
+ str(payload.get("language") or DEFAULT_OPERATOR_LANGUAGE).strip() or DEFAULT_OPERATOR_LANGUAGE
146
+ )
147
+
148
+
149
+ def _uses_spanish(language: str) -> bool:
150
+ return _operator_language({"language": language}).startswith("es")
151
+
152
+
140
153
  def _fallback_operator_attention_hint(followup: dict) -> bool:
141
154
  """Last-resort structural fallback.
142
155
 
@@ -428,15 +441,25 @@ def attention_reminder_category(status: str) -> str:
428
441
  return "decisions" if status == "needs_decision" else "waiting"
429
442
 
430
443
 
431
- def attention_reminder_description(fu_id: str, *, summary: str, options, status: str) -> str:
432
- prefix = "Decision needed" if status == "needs_decision" else "Execution blocked"
444
+ def attention_reminder_description(
445
+ fu_id: str,
446
+ *,
447
+ summary: str,
448
+ options,
449
+ status: str,
450
+ operator_language: str,
451
+ ) -> str:
433
452
  detail = " ".join((summary or "").split())
434
453
  if not detail:
435
- detail = "The runner cannot close this item without operator input."
436
- description = f"{prefix} en {fu_id}: {detail}"
454
+ detail = (
455
+ "El runner no puede cerrar este punto sin intervención del operador."
456
+ if _uses_spanish(operator_language)
457
+ else "The runner cannot close this item without operator input."
458
+ )
459
+ description = f"{fu_id}: {detail}"
437
460
  opts_text = render_options(options)
438
461
  if opts_text:
439
- description += f" Options: {opts_text}"
462
+ description += f" {'Opciones' if _uses_spanish(operator_language) else 'Options'}: {opts_text}"
440
463
  return description[:480]
441
464
 
442
465
 
@@ -446,9 +469,16 @@ def upsert_attention_reminder(
446
469
  summary: str,
447
470
  options,
448
471
  status: str,
472
+ operator_language: str,
449
473
  ):
450
474
  reminder_id = attention_reminder_id(fu_id)
451
- description = attention_reminder_description(fu_id, summary=summary, options=options, status=status)
475
+ description = attention_reminder_description(
476
+ fu_id,
477
+ summary=summary,
478
+ options=options,
479
+ status=status,
480
+ operator_language=operator_language,
481
+ )
452
482
  category = attention_reminder_category(status)
453
483
  today = date.today().isoformat()
454
484
  existing = nexo_db.get_reminder(reminder_id)
@@ -462,7 +492,7 @@ def upsert_attention_reminder(
462
492
  category=category,
463
493
  history_actor="followup-runner",
464
494
  history_event="updated",
465
- history_note=f"{fu_id}: refreshed after {status}.",
495
+ history_note=f"{fu_id}: status={status}",
466
496
  )
467
497
  if result.get("error"):
468
498
  log(f" {fu_id}: failed to update reminder {reminder_id} ({result['error']})")
@@ -482,7 +512,7 @@ def upsert_attention_reminder(
482
512
  return
483
513
  nexo_db.add_reminder_note(
484
514
  reminder_id,
485
- f"Creado desde {fu_id} tras resultado {status}.",
515
+ f"source_followup={fu_id} status={status}",
486
516
  actor="followup-runner",
487
517
  )
488
518
  log(f" {fu_id}: reminder {reminder_id} creado para orchestrator")
@@ -516,6 +546,7 @@ def defer_followup_after_attention(
516
546
  options,
517
547
  status: str,
518
548
  priority: str = "",
549
+ operator_language: str,
519
550
  ):
520
551
  next_review = (date.today() + timedelta(days=1)).isoformat()
521
552
  details = summary.strip()
@@ -536,7 +567,7 @@ def defer_followup_after_attention(
536
567
  status="PENDING",
537
568
  priority=priority,
538
569
  history_event="rescheduled",
539
- history_note=f"Runner deferred after {status}; next review scheduled for {next_review}.",
570
+ history_note=f"status={status}; next_review={next_review}",
540
571
  )
541
572
  if ok:
542
573
  log(f" {fu_id}: {status} → reprogramado para {next_review}")
@@ -545,6 +576,7 @@ def defer_followup_after_attention(
545
576
  summary=summary,
546
577
  options=options,
547
578
  status=status,
579
+ operator_language=operator_language,
548
580
  )
549
581
 
550
582
 
@@ -636,6 +668,7 @@ def build_prompt(actionable: list[dict]) -> str:
636
668
  operator = get_operator_profile()
637
669
  operator_name = str(operator.get("operator_name") or "the operator")
638
670
  assistant_name = str(operator.get("assistant_name") or DEFAULT_ASSISTANT_NAME)
671
+ operator_language = _operator_language(operator)
639
672
  operator_email = str(operator.get("operator_email") or "").strip()
640
673
  send_reply_script = get_send_reply_script_path(local_script_dir=_script_dir)
641
674
  send_target = operator_email or "OPERATOR_EMAIL_NOT_CONFIGURED"
@@ -691,6 +724,7 @@ Do not repeat queries, verifications, or operator emails that already happened t
691
724
  recent_block=recent_block,
692
725
  proactive_block=proactive_block,
693
726
  extra_instructions_block=(extra_instructions_block + "\n\n") if extra_instructions_block else "",
727
+ operator_language_contract_block=build_operator_language_contract(operator_language) + "\n\n",
694
728
  python_executable=sys.executable,
695
729
  send_reply_script=send_reply_script,
696
730
  send_target=send_target,
@@ -810,6 +844,7 @@ def main():
810
844
  options=options,
811
845
  status=r["status"],
812
846
  priority=priority,
847
+ operator_language=_operator_language(),
813
848
  )
814
849
  # Cooldown: don't retry for COOLDOWN_DAYS
815
850
  record_attempt(state, fid, r["status"])
@@ -1,7 +1,7 @@
1
1
  You are [[assistant_name]] running automated followups in headless mode (no user present).
2
2
  [[work_intro]]
3
3
 
4
- [[followup_block]][[recent_block]][[proactive_block]][[extra_instructions_block]]== STARTUP AND SHUTDOWN ==
4
+ [[operator_language_contract_block]][[followup_block]][[recent_block]][[proactive_block]][[extra_instructions_block]]== STARTUP AND SHUTDOWN ==
5
5
 
6
6
  Start:
7
7
  - `nexo_startup(task="followup-runner-cycle")`
@@ -69,6 +69,7 @@ Statuses:
69
69
  - EXECUTE first, report after
70
70
  - NEVER mark something complete without real verification
71
71
  - `summary` must ALWAYS include REAL facts about what you DID (metrics, values, URLs, dates)
72
+ - `summary`, `options`, and any operator-facing text MUST stay in the operator's language
72
73
  - NEVER include internal NEXO system noise (diaries, buffers, post-mortem)
73
74
  - The operator needs results, not internal runtime chatter
74
75
  - If there is nothing pending and nothing worth fixing, finish quickly — do not invent work
@@ -0,0 +1 @@
1
+ [NEXO Protocol Enforcer] [[trailing]] (title: [[title_repr]]). Execute nexo_session_diary_write with a complete summary: decisions made, pending items, context for next session, mental state, and self-critique. Then execute nexo_stop with the current SID. Do not produce visible text. Apply the required action silently. Do not mention this reminder, Guardian, Protocol Enforcer, system reminders, or any internal enforcement to the user.[[operator_language_contract_block]]
@@ -0,0 +1 @@
1
+ CRITICAL LANGUAGE CONTRACT: even when this reminder or automation prompt is written in English, any operator-facing reply, diary entry, reminder/followup note, summary, escalation, or answer you generate while handling it MUST be written in [[operator_language_label]]. Keep machine identifiers, JSON keys, and explicit code/tool names unchanged only when the schema requires them.
@@ -1970,6 +1970,32 @@
1970
1970
  },
1971
1971
  "triggers_after": []
1972
1972
  },
1973
+ "nexo_lifecycle_wait_for_stop": {
1974
+ "description": "Wait until a canonical lifecycle event no longer has an active NEXO session.",
1975
+ "category": "lifecycle",
1976
+ "source": "plugin:lifecycle_events",
1977
+ "requires": [],
1978
+ "provides": [],
1979
+ "internal_calls": [],
1980
+ "enforcement": {
1981
+ "level": "none",
1982
+ "rules": []
1983
+ },
1984
+ "triggers_after": []
1985
+ },
1986
+ "nexo_lifecycle_stop_nexo_session": {
1987
+ "description": "Best-effort explicit stop of a NEXO SID for Desktop lifecycle cleanup.",
1988
+ "category": "lifecycle",
1989
+ "source": "plugin:lifecycle_events",
1990
+ "requires": [],
1991
+ "provides": [],
1992
+ "internal_calls": [],
1993
+ "enforcement": {
1994
+ "level": "none",
1995
+ "rules": []
1996
+ },
1997
+ "triggers_after": []
1998
+ },
1973
1999
  "nexo_media_memory_add": {
1974
2000
  "description": "Store non-text artifact metadata",
1975
2001
  "category": "media",