nexo-brain 7.11.8 → 7.12.1

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.11.8",
3
+ "version": "7.12.1",
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,9 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.11.8` is the current packaged-runtime line. Patch release — silent Guardian reminders now explicitly own the whole reminder turn: lifecycle close/app-exit diary-stop prompts, post-tool inbox reminders, and the server-side enforcement contract all require empty visible output unless there is a fresh operator message. Result: consecutive silent reminders stop leaking visible prose such as "En pausa..." into Desktop conversations, and canonical lifecycle plans publish the stricter v6 prompt contract. Validation so far: `38` targeted tests across prompt/enforcement/lifecycle surfaces plus release-readiness.
21
+ Version `7.12.1` is the current packaged-runtime line. Patch release — Guardian G3 now sees SSH remote-write intent even when the shell payload arrives through pipe, heredoc, or stdin redirect, recent same-task Cortex decisions unlock the next matching G3 retry for a short TTL, and personal text automations now run as bare one-shots with short timeouts and overlap locks instead of spawning full agent sessions. Result: remote deploy/recovery flows stop dead-ending after `nexo_cortex_decide`, and short cron-owned text jobs stop hanging for hours or stacking parallel subprocesses.
22
22
 
23
- Previously in `7.11.6`: patch release — Guardian G4 now filters more false-positive slash fragments before they become debt, `strict_protocol_write_without_task` downgrades to `warn` when the session has a fresh heartbeat, and Deep Sleep extraction validates the real prompt contract instead of accepting any syntactically valid JSON. Validation so far: `50` targeted tests across hook guardrails and Deep Sleep extraction.
23
+ Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
24
 
25
25
  Previously in `7.11.5`: patch release — Desktop-managed installs now block the standalone dashboard at the same product-mode layer as evolution, so `installation_live`, cron sync, and watchdog no longer disagree about whether `com.nexo.dashboard` should exist. Validation: `125` targeted tests across product-mode, cron sync, and doctor, plus a full pre-release wrapper (`2321 passed, 2 skipped, 1 xfailed, 4 xpassed`).
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.11.8",
3
+ "version": "7.12.1",
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",
package/src/cli.py CHANGED
@@ -29,6 +29,7 @@ Entry points:
29
29
  nexo clients sync [--json]
30
30
  nexo contributor status|on|off [--json]
31
31
  nexo doctor [--tier boot|runtime|deep|all] [--plane runtime_personal|installation_live|database_real] [--json] [--fix]
32
+ nexo support-snapshot [--json] [--include-doctor] [--log-lines N]
32
33
  nexo uninstall [--dry-run] [--delete-data] [--json]
33
34
  """
34
35
  from __future__ import annotations
@@ -2315,6 +2316,18 @@ def _doctor(args):
2315
2316
  return 0
2316
2317
 
2317
2318
 
2319
+ def _support_snapshot(args):
2320
+ """Collect a generic runtime snapshot for support and diagnostics."""
2321
+ from support_snapshot import collect_snapshot
2322
+
2323
+ payload = collect_snapshot(
2324
+ log_lines=getattr(args, "log_lines", 80),
2325
+ include_doctor=bool(getattr(args, "include_doctor", False)),
2326
+ )
2327
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
2328
+ return 0
2329
+
2330
+
2318
2331
  def _skills_list(args):
2319
2332
  from db import init_db, list_skills, sync_skill_directories
2320
2333
 
@@ -2725,6 +2738,7 @@ Commands:
2725
2738
  nexo import-inspect PATH Inspect a portable user-data bundle
2726
2739
  nexo import PATH Import a portable user-data bundle
2727
2740
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
2741
+ nexo support-snapshot [--include-doctor] [--log-lines N]
2728
2742
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|schedule|run|doctor|call|unschedule|remove
2729
2743
  Personal scripts
2730
2744
  nexo skills list|apply|sync|approve Executable skills
@@ -3064,6 +3078,11 @@ def main():
3064
3078
  doctor_parser.add_argument("--calibration-dry-run", action="store_true",
3065
3079
  help="Preview the calibration migration without writing")
3066
3080
 
3081
+ support_snapshot_parser = sub.add_parser("support-snapshot", help="Generic runtime snapshot for support and diagnostics")
3082
+ support_snapshot_parser.add_argument("--json", action="store_true", help="JSON output (default)")
3083
+ support_snapshot_parser.add_argument("--include-doctor", action="store_true", help="Include runtime doctor report")
3084
+ support_snapshot_parser.add_argument("--log-lines", type=int, default=80, help="How many recent log lines to include")
3085
+
3067
3086
  # -- contributor --
3068
3087
  contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
3069
3088
  contributor_parser.add_argument("action", choices=["status", "on", "off"], help="Manage contributor mode")
@@ -3432,6 +3451,8 @@ def main():
3432
3451
  return _preferences(args)
3433
3452
  elif args.command == "doctor":
3434
3453
  return _doctor(args)
3454
+ elif args.command == "support-snapshot":
3455
+ return _support_snapshot(args)
3435
3456
  elif args.command == "contributor":
3436
3457
  if args.action == "status":
3437
3458
  return _contributor_status(args)
@@ -305,6 +305,16 @@ _SECRET_REDACT_PATTERNS = [
305
305
  (re.compile(r"\$\{?[A-Za-z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|BEARER)[A-Za-z_]*\}?", re.IGNORECASE), "<redacted-env-ref>"),
306
306
  ]
307
307
 
308
+ _SILENT_REMINDER_TURN_SUFFIX = (
309
+ " Silence applies to the entire reminder turn: no prose before the required action(s), "
310
+ "no prose after them, and no standalone waiting/acknowledgement/continuation phrases. "
311
+ "If there is an operator request you still need to answer in this same turn, continue only "
312
+ "that request after the action(s). Otherwise your visible output must stay empty."
313
+ )
314
+ _SILENT_REMINDER_DISCLOSURE_SUFFIX = (
315
+ " Do not mention this reminder or any internal enforcement to the user."
316
+ )
317
+
308
318
 
309
319
  def _redact_for_log(text: str, max_len: int = 200) -> str:
310
320
  """Return a log-safe truncation of `text` with secret-like tokens
@@ -321,6 +331,29 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
321
331
  return out
322
332
 
323
333
 
334
+ def _upgrade_silent_reminder_prompt(prompt: str) -> str:
335
+ """Normalize old silent-reminder copy to the full turn-wide contract.
336
+
337
+ Background reminders historically stopped at "Do not produce visible
338
+ text.", which allowed the model to satisfy the tool call itself and
339
+ still emit an orphan visible continuation phrase afterwards. The
340
+ stricter contract makes silence apply to the entire reminder turn
341
+ unless the same turn still has a real operator request to answer.
342
+ """
343
+ text = str(prompt or "").strip()
344
+ if not text:
345
+ return ""
346
+ lowered = text.lower()
347
+ if "do not produce visible text" not in lowered:
348
+ return text
349
+ if "entire reminder turn" not in lowered and "visible output must stay empty" not in lowered:
350
+ text += _SILENT_REMINDER_TURN_SUFFIX
351
+ lowered = text.lower()
352
+ if "do not mention this reminder" not in lowered:
353
+ text += _SILENT_REMINDER_DISCLOSURE_SUFFIX
354
+ return text
355
+
356
+
324
357
  def _load_map() -> dict | None:
325
358
  # .resolve() is required: at runtime this module is usually imported via
326
359
  # the symlink $NEXO_HOME/enforcement_engine.py -> core/enforcement_engine.py,
@@ -2571,6 +2604,7 @@ class HeadlessEnforcer:
2571
2604
  the efficacy metric in Fase F cannot aggregate it; callers
2572
2605
  MUST pass the canonical ID.
2573
2606
  """
2607
+ normalized_prompt = _upgrade_silent_reminder_prompt(prompt)
2574
2608
  if any(q["tag"] == tag for q in self.injection_queue):
2575
2609
  return
2576
2610
  # v7.11.2: suppress reminders that ask the agent to call nexo_*
@@ -2581,7 +2615,7 @@ class HeadlessEnforcer:
2581
2615
  # and the agent burns cycles on guaranteed no-ops. Reminders that
2582
2616
  # don't reference nexo_* (R23 deploy guards, R25 nora/maria
2583
2617
  # read-only, etc) still fire — they don't depend on the MCP.
2584
- if "nexo_" in prompt and self._mcp_restart_pending():
2618
+ if "nexo_" in normalized_prompt and self._mcp_restart_pending():
2585
2619
  _logger.info(
2586
2620
  "SKIP: %s — mcp_restart_required marker present (rule_id=%s)",
2587
2621
  tag,
@@ -2599,7 +2633,7 @@ class HeadlessEnforcer:
2599
2633
  if tool in self.tools_called and not tag.startswith("periodic_"):
2600
2634
  _logger.info("SKIP: %s — already called", tag)
2601
2635
  return
2602
- localized_prompt = append_operator_language_contract(prompt)
2636
+ localized_prompt = append_operator_language_contract(normalized_prompt)
2603
2637
  self.injection_queue.append({"prompt": localized_prompt, "tag": tag, "at": time.time(), "rule_id": rule_id})
2604
2638
  _logger.info("ENQUEUED: %s (queue size: %d rule_id=%s)", tag, len(self.injection_queue), rule_id or "?")
2605
2639
  # Fase F telemetry — log one "injection" event per enqueue. The
@@ -191,6 +191,14 @@ _SFTP_BATCH_RE = re.compile(
191
191
  r"\bsftp\b(?:[^|&;]*\s)?-b\s+\S+",
192
192
  re.IGNORECASE,
193
193
  )
194
+ _SSH_REMOTE_PIPE_RE = re.compile(
195
+ r"\|\s*ssh\b",
196
+ re.IGNORECASE,
197
+ )
198
+ _SSH_REMOTE_STDIN_RE = re.compile(
199
+ r"\bssh\b[^\n|&;]*(?:<\s*\S+|<<-?\s*(?:['\"]?[A-Za-z0-9_]+['\"]?))",
200
+ re.IGNORECASE,
201
+ )
194
202
 
195
203
 
196
204
  def _classify_ssh_remote_write(command: str) -> str | None:
@@ -231,6 +239,10 @@ def _classify_ssh_remote_write(command: str) -> str | None:
231
239
  for pattern in _SSH_REMOTE_WRITE_VERBS:
232
240
  if pattern.search(trimmed):
233
241
  return "ssh_remote_shell_write"
242
+ if _SSH_REMOTE_PIPE_RE.search(cmd):
243
+ return "ssh_remote_shell_write"
244
+ if _SSH_REMOTE_STDIN_RE.search(cmd):
245
+ return "ssh_remote_shell_write"
234
246
  return None
235
247
 
236
248
 
@@ -270,6 +282,64 @@ _PATH_ARTIFACT_RE = re.compile(
270
282
  )
271
283
  _DATE_LIKE_PATH_RE = re.compile(r"^/\d{1,4}/\d{1,4}(?:/\d{1,4})?$")
272
284
  _STRICT_WRITE_HEARTBEAT_WINDOW_SECONDS = 300
285
+ _G3_CORTEX_AUTH_WINDOW_SECONDS = max(
286
+ 60,
287
+ int(os.environ.get("NEXO_G3_CORTEX_AUTH_WINDOW_SECONDS", "900")),
288
+ )
289
+ _CORTEX_NEGATIVE_TOKENS = (
290
+ "abort",
291
+ "avoid",
292
+ "block",
293
+ "cancel",
294
+ "decline",
295
+ "defer",
296
+ "deny",
297
+ "do_not",
298
+ "dont",
299
+ "no_",
300
+ "not_now",
301
+ "reject",
302
+ "skip",
303
+ "wait",
304
+ )
305
+ _G3_CORTEX_GENERIC_APPROVAL_TOKENS = (
306
+ "allow",
307
+ "apply",
308
+ "approve",
309
+ "continue",
310
+ "deploy",
311
+ "execute",
312
+ "go_ahead",
313
+ "proceed",
314
+ "publish",
315
+ "retry",
316
+ "run",
317
+ )
318
+ _G3_CORTEX_FAMILY_TOKENS = {
319
+ "destructive": (
320
+ "chmod",
321
+ "cleanup",
322
+ "delete",
323
+ "drop",
324
+ "force",
325
+ "git_push",
326
+ "purge",
327
+ "remove",
328
+ "rm",
329
+ "truncate",
330
+ "wipe",
331
+ ),
332
+ "ssh": (
333
+ "deploy",
334
+ "remote",
335
+ "rsync",
336
+ "scp",
337
+ "sftp",
338
+ "ssh",
339
+ "sync",
340
+ "upload",
341
+ ),
342
+ }
273
343
 
274
344
  # Single-segment ``/word`` candidates that match a small dictionary block-list
275
345
  # of confirmed false positives observed in the live debt log.
@@ -655,6 +725,7 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
655
725
  target = _normalize_file_path(filepath)
656
726
  rows = conn.execute(
657
727
  """SELECT task_id, files, guard_has_blocking, guard_acknowledged, task_type, plan, unknowns,
728
+ opened_at,
658
729
  verification_step, opened_with_guard, must_change_log, must_verify
659
730
  FROM protocol_tasks
660
731
  WHERE session_id = ? AND status = 'open'
@@ -675,6 +746,7 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
675
746
  def _find_any_open_task(conn, sid: str) -> dict | None:
676
747
  row = conn.execute(
677
748
  """SELECT task_id, files, guard_has_blocking, guard_acknowledged, task_type, plan, unknowns,
749
+ opened_at,
678
750
  verification_step, opened_with_guard, must_change_log, must_verify
679
751
  FROM protocol_tasks
680
752
  WHERE session_id = ? AND status = 'open'
@@ -685,6 +757,86 @@ def _find_any_open_task(conn, sid: str) -> dict | None:
685
757
  return dict(row) if row else None
686
758
 
687
759
 
760
+ def _normalize_cortex_tokens(value: str) -> str:
761
+ return re.sub(r"[^a-z0-9]+", "_", str(value or "").lower()).strip("_")
762
+
763
+
764
+ def _text_has_any_token(value: str, tokens: tuple[str, ...]) -> bool:
765
+ normalized = _normalize_cortex_tokens(value)
766
+ if not normalized:
767
+ return False
768
+ return any(token in normalized for token in tokens)
769
+
770
+
771
+ def _cortex_choice_is_negative(value: str) -> bool:
772
+ return _text_has_any_token(value, _CORTEX_NEGATIVE_TOKENS)
773
+
774
+
775
+ def _find_recent_cortex_authorization(
776
+ conn,
777
+ *,
778
+ sid: str,
779
+ task: dict | None,
780
+ gate_family: str,
781
+ pattern_name: str = "",
782
+ ) -> dict | None:
783
+ if not sid or not task:
784
+ return None
785
+ task_id = str(task.get("task_id") or "").strip()
786
+ if not task_id:
787
+ return None
788
+ params: list[object] = [
789
+ sid,
790
+ task_id,
791
+ f"-{_G3_CORTEX_AUTH_WINDOW_SECONDS} seconds",
792
+ ]
793
+ sql = (
794
+ """SELECT id, task_id, recommended_choice, selected_choice,
795
+ recommended_reasoning, selection_reason, context_hint, created_at
796
+ FROM cortex_evaluations
797
+ WHERE session_id = ?
798
+ AND task_id = ?
799
+ AND created_at >= datetime('now', ?)"""
800
+ )
801
+ opened_at = str(task.get("opened_at") or "").strip()
802
+ if opened_at:
803
+ sql += " AND created_at >= ?"
804
+ params.append(opened_at)
805
+ sql += " ORDER BY created_at DESC, id DESC LIMIT 5"
806
+ try:
807
+ rows = conn.execute(sql, params).fetchall()
808
+ except sqlite3.OperationalError:
809
+ return None
810
+ family_tokens = _G3_CORTEX_FAMILY_TOKENS.get(gate_family, ())
811
+ pattern_tokens = tuple(
812
+ token for token in _normalize_cortex_tokens(pattern_name).split("_") if token
813
+ )
814
+ fallback_candidates: list[dict] = []
815
+ for row in rows:
816
+ item = dict(row)
817
+ choice = str(item.get("selected_choice") or item.get("recommended_choice") or "").strip()
818
+ if not choice or _cortex_choice_is_negative(choice):
819
+ continue
820
+ combined = " ".join(
821
+ [
822
+ choice,
823
+ str(item.get("selection_reason") or ""),
824
+ str(item.get("recommended_reasoning") or ""),
825
+ str(item.get("context_hint") or ""),
826
+ ]
827
+ )
828
+ if (
829
+ _text_has_any_token(choice, _G3_CORTEX_GENERIC_APPROVAL_TOKENS)
830
+ or _text_has_any_token(combined, family_tokens)
831
+ or _text_has_any_token(combined, pattern_tokens)
832
+ ):
833
+ return item
834
+ fallback_candidates.append(item)
835
+ if len(fallback_candidates) == 1:
836
+ return fallback_candidates[0]
837
+ return None
838
+
839
+
688
840
  def _find_any_open_workflow(conn, sid: str) -> dict | None:
689
841
  row = conn.execute(
690
842
  """SELECT run_id, protocol_task_id, current_step_key
@@ -1164,6 +1316,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1164
1316
  if not claude_sid:
1165
1317
  claude_sid = _read_claude_session_id_from_coordination()
1166
1318
  sid = _resolve_nexo_sid(conn, claude_sid)
1319
+ open_task = _find_any_open_task(conn, sid) if sid else None
1167
1320
  automation_blocks = _collect_automation_live_repo_blocks(
1168
1321
  conn,
1169
1322
  sid=sid,
@@ -1228,12 +1381,22 @@ def process_pre_tool_event(payload: dict) -> dict:
1228
1381
  if g3_mode in {"shadow", "hard"} and tool_name == "Bash":
1229
1382
  shell_command = _extract_bash_command(tool_input)
1230
1383
  destructive_pattern = _classify_destructive_intent(shell_command)
1384
+ if destructive_pattern:
1385
+ if _find_recent_cortex_authorization(
1386
+ conn,
1387
+ sid=sid,
1388
+ task=open_task,
1389
+ gate_family="destructive",
1390
+ pattern_name=destructive_pattern,
1391
+ ):
1392
+ destructive_pattern = None
1231
1393
  if destructive_pattern:
1232
1394
  severity = "error" if g3_mode == "hard" else "warn"
1395
+ task_id = str((open_task or {}).get("task_id") or "").strip()
1233
1396
  debt = _ensure_protocol_debt(
1234
1397
  conn,
1235
1398
  session_id=sid,
1236
- task_id="",
1399
+ task_id=task_id,
1237
1400
  debt_type="g3_destructive_command_requires_cortex",
1238
1401
  severity=severity,
1239
1402
  evidence=(
@@ -1253,7 +1416,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1253
1416
  "blocks": [
1254
1417
  {
1255
1418
  "file": "",
1256
- "task_id": "",
1419
+ "task_id": task_id,
1257
1420
  "debt_id": debt.get("id"),
1258
1421
  "debt_type": "g3_destructive_command_requires_cortex",
1259
1422
  "reason_code": "g3_destructive_blocked",
@@ -1283,12 +1446,22 @@ def process_pre_tool_event(payload: dict) -> dict:
1283
1446
  if g3_ssh_mode in {"shadow", "hard"} and tool_name == "Bash":
1284
1447
  shell_command = _extract_bash_command(tool_input)
1285
1448
  ssh_pattern = _classify_ssh_remote_write(shell_command)
1449
+ if ssh_pattern:
1450
+ if _find_recent_cortex_authorization(
1451
+ conn,
1452
+ sid=sid,
1453
+ task=open_task,
1454
+ gate_family="ssh",
1455
+ pattern_name=ssh_pattern,
1456
+ ):
1457
+ ssh_pattern = None
1286
1458
  if ssh_pattern:
1287
1459
  severity = "error" if g3_ssh_mode == "hard" else "warn"
1460
+ task_id = str((open_task or {}).get("task_id") or "").strip()
1288
1461
  debt = _ensure_protocol_debt(
1289
1462
  conn,
1290
1463
  session_id=sid,
1291
- task_id="",
1464
+ task_id=task_id,
1292
1465
  debt_type="g3_ssh_remote_write_requires_cortex",
1293
1466
  severity=severity,
1294
1467
  evidence=(
@@ -1309,7 +1482,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1309
1482
  "blocks": [
1310
1483
  {
1311
1484
  "file": "",
1312
- "task_id": "",
1485
+ "task_id": task_id,
1313
1486
  "debt_id": debt.get("id"),
1314
1487
  "debt_type": "g3_ssh_remote_write_requires_cortex",
1315
1488
  "reason_code": "g3_ssh_remote_write_blocked",
@@ -50,6 +50,12 @@ def main(argv: list[str] | None = None) -> int:
50
50
  parser.add_argument("--timeout", type=int, default=AUTOMATION_SUBPROCESS_TIMEOUT, help="Timeout in seconds")
51
51
  parser.add_argument("--output-format", default="text", help="Requested output format")
52
52
  parser.add_argument("--allowed-tools", default="", help="Claude-style allowed tools contract")
53
+ parser.add_argument(
54
+ "--bare-mode",
55
+ choices=("auto", "on", "off"),
56
+ default="auto",
57
+ help="Bare mode for one-shot runs: auto|on|off.",
58
+ )
53
59
  parser.add_argument("--append-system-prompt", default="", help="Extra system prompt text")
54
60
  parser.add_argument("--append-system-prompt-file", default="", help="Read extra system prompt from a file")
55
61
  args = parser.parse_args(argv)
@@ -62,6 +68,11 @@ def main(argv: list[str] | None = None) -> int:
62
68
  return 1
63
69
 
64
70
  append_system_prompt = args.append_system_prompt or _read_text(args.append_system_prompt_file)
71
+ bare_mode = None
72
+ if args.bare_mode == "on":
73
+ bare_mode = True
74
+ elif args.bare_mode == "off":
75
+ bare_mode = False
65
76
 
66
77
  try:
67
78
  result = run_automation_prompt(
@@ -76,6 +87,7 @@ def main(argv: list[str] | None = None) -> int:
76
87
  output_format=args.output_format,
77
88
  append_system_prompt=append_system_prompt,
78
89
  allowed_tools=args.allowed_tools,
90
+ bare_mode=bare_mode,
79
91
  )
80
92
  except AutomationBackendUnavailableError as exc:
81
93
  print(str(exc), file=sys.stderr)
@@ -17,8 +17,11 @@ Both paths are probed so dev and live operators get identical behaviour.
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ import inspect
20
21
  import os
22
+ import re
21
23
  import sys
24
+ import time
22
25
  from pathlib import Path
23
26
 
24
27
 
@@ -31,6 +34,12 @@ if str(_repo_src) not in sys.path:
31
34
 
32
35
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
33
36
  DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
37
+ DEFAULT_SHORT_TEXT_ALLOWED_TOOLS = ""
38
+ DEFAULT_SHORT_TEXT_TIMEOUT = max(
39
+ 30,
40
+ int(os.environ.get("NEXO_PERSONAL_AUTOMATION_TIMEOUT", "180")),
41
+ )
42
+ _PROCESS_LOCK_COUNTS: dict[str, int] = {}
34
43
 
35
44
  # Templates live next to the code at repo time and at ``~/.nexo/templates``
36
45
  # once installed. Probe both and surface whichever exists first so the
@@ -50,14 +59,126 @@ except Exception:
50
59
  from nexo_helper import run_automation_text as _run_automation_text
51
60
 
52
61
 
62
+ def _infer_personal_caller() -> str:
63
+ env_caller = str(os.environ.get("NEXO_AUTOMATION_CALLER") or "").strip()
64
+ if env_caller:
65
+ return env_caller
66
+ candidates: list[Path] = []
67
+ argv0 = str(sys.argv[0] or "").strip()
68
+ if argv0:
69
+ candidates.append(Path(argv0).expanduser())
70
+ current = Path(__file__).resolve()
71
+ for frame in inspect.stack()[1:]:
72
+ try:
73
+ path = Path(frame.filename).resolve()
74
+ except Exception:
75
+ continue
76
+ if path != current:
77
+ candidates.append(path)
78
+ for candidate in candidates:
79
+ parts = candidate.parts
80
+ if "personal" in parts and "scripts" in parts:
81
+ stem = candidate.stem.strip()
82
+ if stem:
83
+ return f"personal/{stem}"
84
+ if argv0:
85
+ stem = Path(argv0).stem.strip()
86
+ if stem and stem not in {"python", "python3", "-m"}:
87
+ return f"personal/{stem}"
88
+ return "agent_run/generic"
89
+
90
+
91
+ def _caller_lock_path(caller: str) -> Path:
92
+ slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", caller).strip("-") or "generic"
93
+ return NEXO_HOME / "runtime" / "locks" / "personal-automation" / f"{slug}.lock"
94
+
95
+
96
+ def _read_lock_pid(path: Path) -> int:
97
+ try:
98
+ raw = path.read_text().splitlines()
99
+ except Exception:
100
+ return 0
101
+ if not raw:
102
+ return 0
103
+ try:
104
+ return int(raw[0].strip())
105
+ except Exception:
106
+ return 0
107
+
108
+
109
+ def _pid_is_alive(pid: int) -> bool:
110
+ if pid <= 0:
111
+ return False
112
+ try:
113
+ os.kill(pid, 0)
114
+ except ProcessLookupError:
115
+ return False
116
+ except PermissionError:
117
+ return True
118
+ return True
119
+
120
+
121
+ def _acquire_personal_caller_lock(caller: str) -> str:
122
+ clean = str(caller or "").strip()
123
+ if not clean.startswith("personal/"):
124
+ return ""
125
+ if _PROCESS_LOCK_COUNTS.get(clean, 0) > 0:
126
+ _PROCESS_LOCK_COUNTS[clean] += 1
127
+ return clean
128
+ pid = os.getpid()
129
+ lock_path = _caller_lock_path(clean)
130
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
131
+ while True:
132
+ try:
133
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
134
+ except FileExistsError:
135
+ existing_pid = _read_lock_pid(lock_path)
136
+ if existing_pid == pid:
137
+ _PROCESS_LOCK_COUNTS[clean] = 1
138
+ return clean
139
+ if existing_pid and _pid_is_alive(existing_pid):
140
+ raise RuntimeError(
141
+ f"Automation caller {clean} already has a live run (pid {existing_pid})."
142
+ )
143
+ try:
144
+ lock_path.unlink()
145
+ except FileNotFoundError:
146
+ pass
147
+ continue
148
+ with os.fdopen(fd, "w", encoding="ascii") as handle:
149
+ handle.write(f"{pid}\n{int(time.time())}\n{clean}\n")
150
+ _PROCESS_LOCK_COUNTS[clean] = 1
151
+ return clean
152
+
153
+
154
+ def _release_personal_caller_lock(caller: str) -> None:
155
+ clean = str(caller or "").strip()
156
+ if not clean.startswith("personal/"):
157
+ return
158
+ count = _PROCESS_LOCK_COUNTS.get(clean, 0)
159
+ if count > 1:
160
+ _PROCESS_LOCK_COUNTS[clean] = count - 1
161
+ return
162
+ _PROCESS_LOCK_COUNTS.pop(clean, None)
163
+ lock_path = _caller_lock_path(clean)
164
+ if _read_lock_pid(lock_path) == os.getpid():
165
+ try:
166
+ lock_path.unlink()
167
+ except FileNotFoundError:
168
+ pass
169
+
170
+
53
171
  def run_personal_automation_text(
54
172
  prompt: str,
55
173
  *,
56
174
  model: str = "",
57
175
  cwd: str = "",
58
- timeout: int = 21600,
59
- allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
176
+ timeout: int = DEFAULT_SHORT_TEXT_TIMEOUT,
177
+ allowed_tools: str = DEFAULT_SHORT_TEXT_ALLOWED_TOOLS,
60
178
  append_system_prompt: str = "",
179
+ caller: str = "",
180
+ tier: str = "",
181
+ bare_mode: bool | None = True,
61
182
  ) -> str:
62
183
  """Run ``prompt`` through the configured NEXO automation backend.
63
184
 
@@ -68,18 +189,29 @@ def run_personal_automation_text(
68
189
  Every other kwarg passes through verbatim.
69
190
  """
70
191
  effective_model = model or _USER_MODEL or "opus"
71
- return _run_automation_text(
72
- prompt,
73
- model=effective_model,
74
- cwd=cwd or "",
75
- timeout=timeout,
76
- allowed_tools=allowed_tools,
77
- append_system_prompt=append_system_prompt,
78
- )
192
+ effective_caller = caller or _infer_personal_caller()
193
+ lock_token = _acquire_personal_caller_lock(effective_caller)
194
+ try:
195
+ return _run_automation_text(
196
+ prompt,
197
+ model=effective_model,
198
+ cwd=cwd or "",
199
+ timeout=timeout,
200
+ allowed_tools=allowed_tools,
201
+ append_system_prompt=append_system_prompt,
202
+ include_bootstrap=False,
203
+ caller=effective_caller,
204
+ tier=tier,
205
+ bare_mode=bare_mode,
206
+ )
207
+ finally:
208
+ _release_personal_caller_lock(lock_token)
79
209
 
80
210
 
81
211
  __all__ = [
82
212
  "DEFAULT_ALLOWED_TOOLS",
213
+ "DEFAULT_SHORT_TEXT_ALLOWED_TOOLS",
214
+ "DEFAULT_SHORT_TEXT_TIMEOUT",
83
215
  "NEXO_HOME",
84
216
  "run_personal_automation_text",
85
217
  ]
@@ -0,0 +1,151 @@
1
+ """Generic runtime snapshot for support and diagnostics.
2
+
3
+ Open-source boundary:
4
+ - no billing
5
+ - no customer workflow
6
+ - no managed support policy
7
+ - only local runtime/install state
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import platform
14
+ import time
15
+ from dataclasses import asdict
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import paths
20
+ from doctor.formatters import format_report
21
+ from doctor.orchestrator import run_doctor
22
+ from health_check import collect as collect_health
23
+
24
+
25
+ def _nexo_home() -> Path:
26
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
+
28
+
29
+ def _read_version() -> str:
30
+ candidates = [
31
+ _nexo_home() / "version.json",
32
+ paths.core_dir() / "package.json",
33
+ Path(__file__).resolve().parents[1] / "package.json",
34
+ ]
35
+ for candidate in candidates:
36
+ try:
37
+ if not candidate.is_file():
38
+ continue
39
+ payload = json.loads(candidate.read_text())
40
+ version = str(payload.get("version") or "").strip()
41
+ if version:
42
+ return version
43
+ except Exception:
44
+ continue
45
+ return "unknown"
46
+
47
+
48
+ def _path_status() -> dict[str, dict[str, Any]]:
49
+ mapping = {
50
+ "home": _nexo_home(),
51
+ "runtime": paths.runtime_dir(),
52
+ "core": paths.core_dir(),
53
+ "data": paths.data_dir(),
54
+ "logs": paths.logs_dir(),
55
+ "operations": paths.operations_dir(),
56
+ "config": paths.config_dir(),
57
+ }
58
+ result: dict[str, dict[str, Any]] = {}
59
+ for key, path in mapping.items():
60
+ try:
61
+ exists = path.exists()
62
+ except Exception:
63
+ exists = False
64
+ result[key] = {
65
+ "path": str(path),
66
+ "exists": exists,
67
+ "is_dir": path.is_dir() if exists else False,
68
+ }
69
+ return result
70
+
71
+
72
+ def _recent_logs(lines: int = 80) -> dict[str, Any]:
73
+ lines = max(1, int(lines))
74
+ home = _nexo_home()
75
+ events_file = home / "runtime" / "events.ndjson"
76
+ ops_dir = paths.operations_dir()
77
+ event_tail: list[dict[str, Any]] = []
78
+ operation_tail: list[dict[str, Any]] = []
79
+
80
+ if events_file.is_file():
81
+ try:
82
+ raw = events_file.read_text(errors="ignore").splitlines()[-lines:]
83
+ for line in raw:
84
+ try:
85
+ event_tail.append(json.loads(line))
86
+ except Exception:
87
+ continue
88
+ except Exception as exc:
89
+ event_tail.append({"error": str(exc)})
90
+
91
+ if ops_dir.is_dir():
92
+ try:
93
+ files = sorted(
94
+ ops_dir.glob("*.log"),
95
+ key=lambda item: item.stat().st_mtime if item.exists() else 0,
96
+ reverse=True,
97
+ )[:5]
98
+ for log_file in files:
99
+ try:
100
+ for line in log_file.read_text(errors="ignore").splitlines()[-lines:]:
101
+ operation_tail.append({"file": log_file.name, "line": line})
102
+ except Exception as exc:
103
+ operation_tail.append({"file": log_file.name, "error": str(exc)})
104
+ except Exception as exc:
105
+ operation_tail.append({"error": str(exc)})
106
+
107
+ return {
108
+ "events": event_tail[-lines:],
109
+ "operations": operation_tail[-lines:],
110
+ }
111
+
112
+
113
+ def collect_snapshot(*, log_lines: int = 80, include_doctor: bool = False) -> dict[str, Any]:
114
+ payload: dict[str, Any] = {
115
+ "generated_at": time.time(),
116
+ "version": _read_version(),
117
+ "platform": {
118
+ "system": platform.system(),
119
+ "release": platform.release(),
120
+ "machine": platform.machine(),
121
+ "python": platform.python_version(),
122
+ },
123
+ "paths": _path_status(),
124
+ "health": collect_health(),
125
+ "logs": _recent_logs(log_lines),
126
+ }
127
+
128
+ if include_doctor:
129
+ report = run_doctor(tier="runtime", fix=False, plane="runtime_personal")
130
+ payload["doctor"] = asdict(report)
131
+ payload["doctor_text"] = format_report(report, fmt="text")
132
+
133
+ return payload
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ import argparse
138
+
139
+ parser = argparse.ArgumentParser(description="NEXO generic support snapshot")
140
+ parser.add_argument("--json", action="store_true", help="JSON output")
141
+ parser.add_argument("--include-doctor", action="store_true", help="Include runtime doctor report")
142
+ parser.add_argument("--log-lines", type=int, default=80, help="How many recent log lines to include")
143
+ args = parser.parse_args(argv)
144
+
145
+ payload = collect_snapshot(log_lines=args.log_lines, include_doctor=args.include_doctor)
146
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -226,6 +226,7 @@ def run_automation_text(
226
226
  include_bootstrap: bool = True,
227
227
  caller: str = "",
228
228
  tier: str = "",
229
+ bare_mode: bool | None = None,
229
230
  ) -> str:
230
231
  """Run the configured NEXO automation backend and return text output.
231
232
 
@@ -264,6 +265,10 @@ def run_automation_text(
264
265
  cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
265
266
  if allowed_tools:
266
267
  cmd.extend(["--allowed-tools", allowed_tools])
268
+ if bare_mode is True:
269
+ cmd.extend(["--bare-mode", "on"])
270
+ elif bare_mode is False:
271
+ cmd.extend(["--bare-mode", "off"])
267
272
 
268
273
  env = os.environ.copy()
269
274
  env.setdefault("NEXO_HOME", str(NEXO_HOME))
@@ -292,6 +297,7 @@ def run_automation_json(
292
297
  include_bootstrap: bool = True,
293
298
  caller: str = "",
294
299
  tier: str = "",
300
+ bare_mode: bool | None = None,
295
301
  ) -> dict:
296
302
  """Run the configured backend and return a parsed JSON object.
297
303
 
@@ -326,6 +332,10 @@ def run_automation_json(
326
332
  cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
327
333
  if allowed_tools:
328
334
  cmd.extend(["--allowed-tools", allowed_tools])
335
+ if bare_mode is True:
336
+ cmd.extend(["--bare-mode", "on"])
337
+ elif bare_mode is False:
338
+ cmd.extend(["--bare-mode", "off"])
329
339
 
330
340
  env = os.environ.copy()
331
341
  env.setdefault("NEXO_HOME", str(NEXO_HOME))
@@ -386,7 +386,7 @@
386
386
  "event": "post_compaction"
387
387
  }
388
388
  ],
389
- "inject_prompt": "Context was just compacted. Execute nexo_checkpoint_read to restore session state. Do not produce visible text."
389
+ "inject_prompt": "Context was just compacted. Execute nexo_checkpoint_read to restore session state. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
390
390
  },
391
391
  "triggers_after": []
392
392
  },
@@ -409,7 +409,7 @@
409
409
  "event": "pre_compaction"
410
410
  }
411
411
  ],
412
- "inject_prompt": "Context compaction is imminent. Execute nexo_checkpoint_save with current task, goal, and active files. Do not produce visible text."
412
+ "inject_prompt": "Context compaction is imminent. Execute nexo_checkpoint_save with current task, goal, and active files. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
413
413
  },
414
414
  "triggers_after": []
415
415
  },
@@ -1551,7 +1551,7 @@
1551
1551
  "condition": "only_when_editing_code_files_not_already_guarded_by_task_open"
1552
1552
  }
1553
1553
  ],
1554
- "inject_prompt": "You are about to edit files without running guard_check. Execute nexo_guard_check with the files you are about to modify. Do not produce visible text.",
1554
+ "inject_prompt": "You are about to edit files without running guard_check. Execute nexo_guard_check with the files you are about to modify. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user.",
1555
1555
  "note": "task_open already calls guard_check internally for action tasks with files. This enforcement is for edits OUTSIDE of a protocol task."
1556
1556
  },
1557
1557
  "triggers_after": []
@@ -1659,7 +1659,7 @@
1659
1659
  "threshold": 3
1660
1660
  }
1661
1661
  ],
1662
- "inject_prompt": "Execute nexo_heartbeat with the current session SID and a brief description of what you are doing. Do not produce visible text."
1662
+ "inject_prompt": "Execute nexo_heartbeat with the current session SID and a brief description of what you are doing. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
1663
1663
  },
1664
1664
  "triggers_after": []
1665
1665
  },
@@ -1827,7 +1827,7 @@
1827
1827
  "grace_messages": 0
1828
1828
  }
1829
1829
  ],
1830
- "inject_prompt": "The user corrected you but no learning was captured. Execute nexo_learning_add to record this correction as a reusable learning. Do not produce visible text.",
1830
+ "inject_prompt": "The user corrected you but no learning was captured. Execute nexo_learning_add to record this correction as a reusable learning. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user.",
1831
1831
  "note": "heartbeat already includes LEARNING_REMINDER signal. This enforcement adds a hard injection if the model ignores the reminder for 3+ messages."
1832
1832
  },
1833
1833
  "triggers_after": []
@@ -2785,7 +2785,7 @@
2785
2785
  ]
2786
2786
  }
2787
2787
  ],
2788
- "inject_prompt": "Execute nexo_reminders with filter='due' to check pending reminders and followups. Do not produce visible text."
2788
+ "inject_prompt": "Execute nexo_reminders with filter='due' to check pending reminders and followups. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
2789
2789
  },
2790
2790
  "triggers_after": []
2791
2791
  },
@@ -2902,7 +2902,7 @@
2902
2902
  ]
2903
2903
  }
2904
2904
  ],
2905
- "inject_prompt": "Execute nexo_session_diary_read with last_day=true and brief=true to load context from previous sessions. Do not produce visible text."
2905
+ "inject_prompt": "Execute nexo_session_diary_read with last_day=true and brief=true to load context from previous sessions. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
2906
2906
  },
2907
2907
  "triggers_after": []
2908
2908
  },
@@ -2932,8 +2932,8 @@
2932
2932
  "type": "on_session_end"
2933
2933
  }
2934
2934
  ],
2935
- "inject_prompt": "It has been 15 minutes of active session without writing a diary. Execute nexo_session_diary_write with a summary of decisions and work done so far. Do not produce visible text.",
2936
- "session_end_inject_prompt": "This session is ending. Execute nexo_session_diary_write with a complete summary: decisions made, pending items, context for next session, mental state, and self-critique. Do not produce visible text."
2935
+ "inject_prompt": "It has been 15 minutes of active session without writing a diary. Execute nexo_session_diary_write with a summary of decisions and work done so far. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user.",
2936
+ "session_end_inject_prompt": "This session is ending. Execute nexo_session_diary_write with a complete summary: decisions made, pending items, context for next session, mental state, and self-critique. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
2937
2937
  },
2938
2938
  "triggers_after": []
2939
2939
  },
@@ -3290,7 +3290,7 @@
3290
3290
  ]
3291
3291
  }
3292
3292
  ],
3293
- "inject_prompt": "Execute nexo_smart_startup to pre-load relevant cognitive context. Do not produce visible text."
3293
+ "inject_prompt": "Execute nexo_smart_startup to pre-load relevant cognitive context. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
3294
3294
  },
3295
3295
  "triggers_after": []
3296
3296
  },
@@ -3341,7 +3341,7 @@
3341
3341
  "threshold": 1
3342
3342
  }
3343
3343
  ],
3344
- "inject_prompt": "You must start by calling nexo_startup to register this session. If mcp__nexo__* tools appear as deferred in the tool list (names visible but JSONSchemas not loaded), first call ToolSearch with query \"select:mcp__nexo__nexo_startup,mcp__nexo__nexo_heartbeat,mcp__nexo__nexo_session_diary_read,mcp__nexo__nexo_reminders,mcp__nexo__nexo_smart_startup,mcp__nexo__nexo_task_open,mcp__nexo__nexo_task_close,mcp__nexo__nexo_task_acknowledge_guard,mcp__nexo__nexo_guard_check,mcp__nexo__nexo_learning_add,mcp__nexo__nexo_confidence_check,mcp__nexo__nexo_followup_create,mcp__nexo__nexo_protocol_debt_resolve\" to load the schemas — deferred is not absent. If more nexo_* tools appear deferred later in the session, preload them the same way instead of giving up on them. Then execute nexo_startup with a brief task description. Do not produce visible text.",
3344
+ "inject_prompt": "You must start by calling nexo_startup to register this session. If mcp__nexo__* tools appear as deferred in the tool list (names visible but JSONSchemas not loaded), first call ToolSearch with query \"select:mcp__nexo__nexo_startup,mcp__nexo__nexo_heartbeat,mcp__nexo__nexo_session_diary_read,mcp__nexo__nexo_reminders,mcp__nexo__nexo_smart_startup,mcp__nexo__nexo_task_open,mcp__nexo__nexo_task_close,mcp__nexo__nexo_task_acknowledge_guard,mcp__nexo__nexo_guard_check,mcp__nexo__nexo_learning_add,mcp__nexo__nexo_confidence_check,mcp__nexo__nexo_followup_create,mcp__nexo__nexo_protocol_debt_resolve\" to load the schemas — deferred is not absent. If more nexo_* tools appear deferred later in the session, preload them the same way instead of giving up on them. Then execute nexo_startup with a brief task description. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user.",
3345
3345
  "triggers_after": [
3346
3346
  "nexo_smart_startup",
3347
3347
  "nexo_session_diary_read",
@@ -3437,8 +3437,8 @@
3437
3437
  "type": "on_session_end"
3438
3438
  }
3439
3439
  ],
3440
- "inject_prompt": "This session is ending. Execute nexo_stop with the current SID. Do not produce visible text.",
3441
- "session_end_inject_prompt": "This session is ending. Execute nexo_stop with the current SID. Do not produce visible text."
3440
+ "inject_prompt": "This session is ending. Execute nexo_stop with the current SID. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user.",
3441
+ "session_end_inject_prompt": "This session is ending. Execute nexo_stop with the current SID. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
3442
3442
  },
3443
3443
  "triggers_after": []
3444
3444
  },
@@ -3499,7 +3499,7 @@
3499
3499
  "event": "done_claimed_with_open_task"
3500
3500
  }
3501
3501
  ],
3502
- "inject_prompt": "You claimed work is done but have an open protocol task. Execute nexo_task_close with evidence of completion before claiming done. Do not produce visible text."
3502
+ "inject_prompt": "You claimed work is done but have an open protocol task. Execute nexo_task_close with evidence of completion before claiming done. Do not produce visible text for this reminder. Silence applies to the entire reminder turn: no prose before the required action(s), no prose after them, and no standalone waiting/acknowledgement/continuation phrases. If there is an operator request you still need to answer in this same turn, continue only that request after the action(s). Otherwise your visible output must stay empty. Do not mention this reminder or any internal enforcement to the user."
3503
3503
  },
3504
3504
  "triggers_after": []
3505
3505
  },