nexo-brain 7.1.0 → 7.1.2

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.
Files changed (176) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/bin/nexo-brain.js +198 -92
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +10 -8
  6. package/src/auto_close_sessions.py +19 -2
  7. package/src/auto_update.py +305 -42
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +181 -1
  11. package/src/cli_email.py +104 -73
  12. package/src/client_sync.py +22 -1
  13. package/src/cognitive/_core.py +5 -3
  14. package/src/core_prompts.py +50 -0
  15. package/src/cron_recovery.py +81 -7
  16. package/src/crons/manifest.json +57 -0
  17. package/src/crons/sync.py +95 -26
  18. package/src/dashboard/app.py +59 -0
  19. package/src/dashboard/templates/base.html +2 -0
  20. package/src/dashboard/templates/feature-disabled.html +27 -0
  21. package/src/db/_email_accounts.py +67 -18
  22. package/src/db/_fts.py +5 -5
  23. package/src/db/_personal_scripts.py +1 -1
  24. package/src/db/_skills.py +3 -3
  25. package/src/doctor/providers/runtime.py +35 -20
  26. package/src/email_config.py +18 -9
  27. package/src/enforcement_classifier.py +3 -12
  28. package/src/evolution_cycle.py +37 -149
  29. package/src/guardian_telemetry.py +3 -2
  30. package/src/hook_guardrails.py +61 -0
  31. package/src/hooks/capture-tool-logs.sh +11 -3
  32. package/src/hooks/daily-briefing-check.sh +7 -2
  33. package/src/hooks/heartbeat-enforcement.py +14 -1
  34. package/src/hooks/heartbeat-posttool.sh +2 -0
  35. package/src/hooks/heartbeat-user-msg.sh +2 -0
  36. package/src/hooks/inbox-hook.sh +6 -2
  37. package/src/hooks/post-compact.sh +12 -4
  38. package/src/hooks/pre-compact.sh +12 -4
  39. package/src/migrate_embeddings.py +5 -3
  40. package/src/nexo_migrate.py +3 -1
  41. package/src/plugin_loader.py +14 -5
  42. package/src/plugins/adaptive_mode.py +4 -1
  43. package/src/plugins/backup.py +32 -20
  44. package/src/plugins/evolution.py +2 -0
  45. package/src/plugins/memory_export.py +6 -1
  46. package/src/plugins/personal_plugins.py +17 -7
  47. package/src/plugins/personal_scripts.py +64 -3
  48. package/src/presets/entities_universal.json +67 -4
  49. package/src/product_mode.py +201 -0
  50. package/src/r14_correction_learning.py +5 -20
  51. package/src/r15_project_context.py +4 -10
  52. package/src/r16_declared_done.py +3 -16
  53. package/src/r17_promise_debt.py +3 -16
  54. package/src/r18_followup_autocomplete.py +5 -7
  55. package/src/r19_project_grep.py +5 -8
  56. package/src/r20_constant_change.py +5 -15
  57. package/src/r21_legacy_path.py +5 -7
  58. package/src/r22_personal_script.py +4 -8
  59. package/src/r23_ssh_without_atlas.py +4 -11
  60. package/src/r23b_deploy_vhost.py +7 -6
  61. package/src/r23c_cwd_mismatch.py +7 -6
  62. package/src/r23d_chown_chmod_recursive.py +6 -6
  63. package/src/r23e_force_push_main.py +5 -6
  64. package/src/r23f_db_no_where.py +5 -6
  65. package/src/r23g_secrets_in_output.py +5 -5
  66. package/src/r23h_shebang_mismatch.py +6 -5
  67. package/src/r23i_auto_deploy_ignored.py +5 -6
  68. package/src/r23j_global_install.py +5 -6
  69. package/src/r23k_script_duplicates_skill.py +7 -6
  70. package/src/r23l_resource_collision.py +7 -6
  71. package/src/r23m_message_duplicate.py +6 -5
  72. package/src/r24_stale_memory.py +4 -9
  73. package/src/r25_nora_maria_read_only.py +5 -10
  74. package/src/r34_identity_coherence.py +6 -13
  75. package/src/r_catalog.py +3 -7
  76. package/src/resonance_map.py +13 -13
  77. package/src/runtime_power.py +29 -80
  78. package/src/script_registry.py +236 -6
  79. package/src/scripts/check-context.py +8 -25
  80. package/src/scripts/deep-sleep/extract.py +6 -10
  81. package/src/scripts/nexo-auto-update.py +27 -4
  82. package/src/scripts/nexo-catchup.py +9 -19
  83. package/src/scripts/nexo-cognitive-decay.py +26 -3
  84. package/src/scripts/nexo-daily-self-audit.py +50 -51
  85. package/src/scripts/nexo-email-migrate-config.py +30 -11
  86. package/src/scripts/nexo-email-monitor.py +97 -238
  87. package/src/scripts/nexo-followup-runner.py +70 -133
  88. package/src/scripts/nexo-hook-record.py +1 -1
  89. package/src/scripts/nexo-immune.py +6 -31
  90. package/src/scripts/nexo-impact-scorer.py +27 -4
  91. package/src/scripts/nexo-learning-housekeep.py +26 -3
  92. package/src/scripts/nexo-learning-validator.py +34 -32
  93. package/src/scripts/nexo-migrate.py +28 -12
  94. package/src/scripts/nexo-morning-agent.py +9 -23
  95. package/src/scripts/nexo-outcome-checker.py +27 -4
  96. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  97. package/src/scripts/nexo-pre-commit.py +28 -0
  98. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  99. package/src/scripts/nexo-reflection.py +33 -3
  100. package/src/scripts/nexo-runtime-preflight.py +27 -2
  101. package/src/scripts/nexo-send-reply.py +10 -8
  102. package/src/scripts/nexo-sleep.py +11 -25
  103. package/src/scripts/nexo-synthesis.py +7 -40
  104. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  105. package/src/scripts/nexo-watchdog.sh +23 -17
  106. package/src/scripts/phase_guardian_analysis.py +27 -4
  107. package/src/server.py +14 -3
  108. package/src/storage_router.py +8 -6
  109. package/src/tools_drive.py +5 -13
  110. package/src/tools_guardian.py +3 -4
  111. package/src/tools_menu.py +2 -2
  112. package/src/tools_reminders_crud.py +17 -0
  113. package/src/tools_sessions.py +1 -4
  114. package/src/user_context.py +3 -6
  115. package/src/user_data_portability.py +31 -23
  116. package/templates/CLAUDE.md.template +11 -3
  117. package/templates/CODEX.AGENTS.md.template +11 -3
  118. package/templates/core-prompts/catchup-assessment.md +19 -0
  119. package/templates/core-prompts/check-context.md +24 -0
  120. package/templates/core-prompts/daily-self-audit.md +42 -0
  121. package/templates/core-prompts/daily-synthesis.md +40 -0
  122. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  123. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  124. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  125. package/templates/core-prompts/email-monitor.md +202 -0
  126. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  127. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  128. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  129. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  130. package/templates/core-prompts/evolution-weekly.md +71 -0
  131. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  132. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  133. package/templates/core-prompts/followup-runner.md +74 -0
  134. package/templates/core-prompts/immune-triage.md +31 -0
  135. package/templates/core-prompts/interactive-startup.md +1 -0
  136. package/templates/core-prompts/json-object-only.md +1 -0
  137. package/templates/core-prompts/learning-validator.md +25 -0
  138. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  139. package/templates/core-prompts/morning-agent.md +23 -0
  140. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  141. package/templates/core-prompts/r-catalog.md +1 -0
  142. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  143. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  144. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  145. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  146. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  147. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  148. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  149. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  150. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  151. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  152. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  153. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  154. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  155. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  156. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  157. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  158. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  159. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  160. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  161. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  162. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  164. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  165. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  166. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  167. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  168. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  169. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  170. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  171. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  172. package/templates/core-prompts/sleep.md +25 -0
  173. package/templates/email-template.md +55 -0
  174. package/templates/nexo_helper.py +31 -13
  175. package/templates/plugin-template.py +3 -3
  176. package/templates/skill-template.md +2 -1
@@ -0,0 +1,260 @@
1
+ """Runtime state + enforcement for operator autonomy mandates.
2
+
3
+ When the operator states `autonomía total`, `sin esperas`, `todo ya`, or an
4
+ equivalent marker, the agent must stop deferring in-scope work to the future
5
+ via `nexo_followup_create`. This module captures that mandate as a small
6
+ JSON state file under `$NEXO_HOME/runtime/data/autonomy_mandate.json`, and
7
+ exposes helpers the CRUD layer uses before creating a followup.
8
+
9
+ Design notes:
10
+
11
+ * State is session-scoped, not global — the mandate decays when the session
12
+ ends or after `DEFAULT_TTL_SECONDS` whichever comes first.
13
+ * Detection is intentionally simple (substring match on a marker list). The
14
+ S4 incident that motivated this guardrail only needed a coarse trigger.
15
+ * The block is surgical: a followup is rejected only when the call pattern
16
+ matches the procrastination shape the operator flagged (owner=user or
17
+ date within 7 days). Long-horizon commitments (>7 days) and shared/agent
18
+ followups pass through, because those are not what the operator was
19
+ pushing back on.
20
+ * Three explicit exceptions stay allowed even under the mandate:
21
+ (a) a download >1GB, (b) a credential the operator must physically enter,
22
+ (c) a presence-dependent session with María or Nora. These are declared
23
+ by keywords inside `description` or the explicit `exception` kwarg.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import time
31
+ from dataclasses import dataclass
32
+ from datetime import date, datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+
37
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
38
+ STATE_PATH = NEXO_HOME / "runtime" / "data" / "autonomy_mandate.json"
39
+
40
+ # Marker list per NF-DS-45569A27. Case-insensitive substring match.
41
+ MARKERS = (
42
+ "autonomía total",
43
+ "autonomia total",
44
+ "sin esperas",
45
+ "todo ya",
46
+ "no esperes",
47
+ "no te escondas",
48
+ "llevo 3 veces",
49
+ "lo quiero ya",
50
+ )
51
+
52
+ # Default mandate TTL. Longer than a normal working session but short enough
53
+ # that a stale state does not block followups weeks later.
54
+ DEFAULT_TTL_SECONDS = 6 * 60 * 60 # 6h
55
+
56
+ # Exception keywords — when any appear in the description (or the explicit
57
+ # `exception` parameter), the followup is allowed even under an active
58
+ # mandate.
59
+ EXCEPTION_KEYWORDS = (
60
+ ">1gb", ">1 gb", "> 1gb", "> 1 gb",
61
+ "large download", "descarga grande",
62
+ "credential", "credencial", "operator must enter",
63
+ "el operador introduce", "api key rotation",
64
+ "maría", "maria",
65
+ "nora",
66
+ "sesión presencial", "sesion presencial", "in-person",
67
+ )
68
+
69
+ PROCRASTINATION_WINDOW_DAYS = 7
70
+
71
+
72
+ @dataclass
73
+ class MandateState:
74
+ active: bool
75
+ session_id: str
76
+ set_at: float
77
+ expires_at: float
78
+ marker: str
79
+ source: str
80
+
81
+ def remaining_seconds(self, now: Optional[float] = None) -> float:
82
+ now = time.time() if now is None else now
83
+ return max(0.0, self.expires_at - now)
84
+
85
+ def to_dict(self) -> dict:
86
+ return {
87
+ "active": self.active,
88
+ "session_id": self.session_id,
89
+ "set_at": self.set_at,
90
+ "expires_at": self.expires_at,
91
+ "marker": self.marker,
92
+ "source": self.source,
93
+ }
94
+
95
+
96
+ def _detect_marker(text: str) -> Optional[str]:
97
+ if not text:
98
+ return None
99
+ lowered = text.lower()
100
+ for marker in MARKERS:
101
+ if marker in lowered:
102
+ return marker
103
+ return None
104
+
105
+
106
+ def _ensure_dir() -> None:
107
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
108
+
109
+
110
+ def load_state() -> Optional[MandateState]:
111
+ """Return the current mandate state or None if absent/expired."""
112
+ try:
113
+ raw = json.loads(STATE_PATH.read_text())
114
+ except FileNotFoundError:
115
+ return None
116
+ except (OSError, json.JSONDecodeError):
117
+ return None
118
+ try:
119
+ st = MandateState(
120
+ active=bool(raw.get("active")),
121
+ session_id=str(raw.get("session_id", "")),
122
+ set_at=float(raw.get("set_at", 0.0)),
123
+ expires_at=float(raw.get("expires_at", 0.0)),
124
+ marker=str(raw.get("marker", "")),
125
+ source=str(raw.get("source", "")),
126
+ )
127
+ except (TypeError, ValueError):
128
+ return None
129
+ if not st.active or st.expires_at <= time.time():
130
+ return None
131
+ return st
132
+
133
+
134
+ def set_mandate(
135
+ session_id: str,
136
+ marker: str,
137
+ source: str = "manual",
138
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
139
+ ) -> MandateState:
140
+ _ensure_dir()
141
+ now = time.time()
142
+ st = MandateState(
143
+ active=True,
144
+ session_id=str(session_id or "").strip(),
145
+ set_at=now,
146
+ expires_at=now + max(60, int(ttl_seconds)),
147
+ marker=marker,
148
+ source=source,
149
+ )
150
+ STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
151
+ return st
152
+
153
+
154
+ def clear_mandate() -> None:
155
+ try:
156
+ STATE_PATH.unlink()
157
+ except FileNotFoundError:
158
+ pass
159
+
160
+
161
+ def maybe_ingest_from_text(
162
+ text: str,
163
+ session_id: str,
164
+ source: str = "auto",
165
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
166
+ ) -> Optional[MandateState]:
167
+ """Scan free-form text for a mandate marker and persist if found.
168
+
169
+ Used by heartbeat / hook paths that capture operator messages so the
170
+ mandate can be set transparently, without a separate explicit tool.
171
+ Returns the new state when a marker was detected, otherwise None.
172
+ """
173
+ marker = _detect_marker(text or "")
174
+ if not marker:
175
+ return None
176
+ return set_mandate(
177
+ session_id=session_id,
178
+ marker=marker,
179
+ source=source,
180
+ ttl_seconds=ttl_seconds,
181
+ )
182
+
183
+
184
+ def _description_has_exception(description: str, exception: str) -> bool:
185
+ haystack = f"{description or ''}\n{exception or ''}".lower()
186
+ return any(kw in haystack for kw in EXCEPTION_KEYWORDS)
187
+
188
+
189
+ def _parse_target_date(raw: str) -> Optional[date]:
190
+ if not raw:
191
+ return None
192
+ m = re.match(r"(\d{4})-(\d{2})-(\d{2})", raw.strip())
193
+ if not m:
194
+ return None
195
+ try:
196
+ return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
197
+ except ValueError:
198
+ return None
199
+
200
+
201
+ def _days_until(target: date, today: Optional[date] = None) -> int:
202
+ today = today or datetime.now(timezone.utc).date()
203
+ return (target - today).days
204
+
205
+
206
+ def check_followup_against_mandate(
207
+ *,
208
+ owner: str = "",
209
+ date: str = "",
210
+ description: str = "",
211
+ exception: str = "",
212
+ state: Optional[MandateState] = None,
213
+ ) -> Optional[str]:
214
+ """Return a human-readable error string when the followup should be
215
+ rejected under an active mandate, otherwise None.
216
+
217
+ The caller is responsible for turning the string into a tool-level
218
+ error. `state` is injectable for tests; production callers leave it
219
+ None so the current on-disk state is consulted.
220
+ """
221
+ st = state if state is not None else load_state()
222
+ if st is None:
223
+ return None
224
+
225
+ if _description_has_exception(description, exception):
226
+ return None
227
+
228
+ owner_norm = (owner or "").strip().lower()
229
+ offending_owner = owner_norm == "user"
230
+
231
+ target = _parse_target_date(date)
232
+ within_window = False
233
+ if target is not None:
234
+ days = _days_until(target)
235
+ if 0 <= days < PROCRASTINATION_WINDOW_DAYS:
236
+ within_window = True
237
+
238
+ if not (offending_owner or within_window):
239
+ return None
240
+
241
+ reasons = []
242
+ if offending_owner:
243
+ reasons.append("owner=user")
244
+ if within_window:
245
+ reasons.append(f"date within {PROCRASTINATION_WINDOW_DAYS} days")
246
+
247
+ lines = [
248
+ "ERROR: Autonomy mandate active — nexo_followup_create blocked.",
249
+ f"Marker: '{st.marker}' (source={st.source}, session={st.session_id or 'n/a'}).",
250
+ f"Reason: {' + '.join(reasons)}.",
251
+ "This followup looks like procrastination inside the current scope.",
252
+ "Do the work now via nexo_task_open, or promote it to a goal/workflow.",
253
+ "Exceptions that stay allowed even under the mandate:",
254
+ " - Download >1GB that must finish before the next step.",
255
+ " - Credential the operator must physically enter.",
256
+ " - Presence-dependent session with María or Nora.",
257
+ "Pass exception='<reason>' or reference one of the keywords in the",
258
+ "description to override. Do not use force='true' for this.",
259
+ ]
260
+ return "\n".join(lines)
@@ -30,6 +30,7 @@ def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
30
30
 
31
31
 
32
32
  TEMPLATES_DIR = _resolve_templates_dir(__file__)
33
+ DEFAULT_ASSISTANT_NAME = "Nova"
33
34
 
34
35
  CORE_LABEL = "******CORE******"
35
36
  USER_LABEL = "******USER******"
@@ -64,6 +65,16 @@ def _default_nexo_home() -> Path:
64
65
  return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
65
66
 
66
67
 
68
+ def _read_json_file(path: Path) -> dict:
69
+ if not path.is_file():
70
+ return {}
71
+ try:
72
+ payload = json.loads(path.read_text())
73
+ except Exception:
74
+ return {}
75
+ return payload if isinstance(payload, dict) else {}
76
+
77
+
67
78
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
68
79
  explicit = (explicit or "").strip()
69
80
  if explicit:
@@ -71,6 +82,16 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
71
82
  env_name = os.environ.get("NEXO_NAME", "").strip()
72
83
  if env_name:
73
84
  return env_name
85
+ calibration = _read_json_file(nexo_home / "personal" / "brain" / "calibration.json")
86
+ user_payload = calibration.get("user")
87
+ if isinstance(user_payload, dict):
88
+ candidate = str(user_payload.get("assistant_name", "")).strip()
89
+ if candidate:
90
+ return candidate
91
+ for key in ("assistant_name", "identity", "operator_name"):
92
+ candidate = str(calibration.get(key, "")).strip()
93
+ if candidate:
94
+ return candidate
74
95
  version_file = nexo_home / "version.json"
75
96
  if version_file.is_file():
76
97
  try:
@@ -79,7 +100,7 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
79
100
  return candidate
80
101
  except Exception:
81
102
  pass
82
- return "NEXO"
103
+ return DEFAULT_ASSISTANT_NAME
83
104
 
84
105
 
85
106
  def _read_version(text: str, pattern: str) -> str:
package/src/cli.py CHANGED
@@ -576,6 +576,129 @@ def _scripts_status(args):
576
576
  return 0
577
577
 
578
578
 
579
+ def _automations_list(args):
580
+ from script_registry import list_operator_automations
581
+
582
+ rows = list_operator_automations(include_all=bool(getattr(args, "all", False)))
583
+ if args.json:
584
+ print(json.dumps({"ok": True, "automations": rows}, indent=2, ensure_ascii=False))
585
+ return 0
586
+ if not rows:
587
+ print("No automations registered.")
588
+ return 0
589
+ for row in rows:
590
+ state = "enabled" if row.get("enabled", True) else "disabled"
591
+ availability = "ready" if row.get("available", True) else "setup required"
592
+ schedule = str(row.get("effective_schedule_label") or row.get("first_schedule_label") or "—")
593
+ print(f"{row.get('name')} [{state}] · {availability} · {schedule}")
594
+ return 0
595
+
596
+
597
+ def _automations_set_enabled(args, enabled):
598
+ from script_registry import set_automation_enabled
599
+
600
+ result = set_automation_enabled(args.name, enabled)
601
+ if args.json:
602
+ print(json.dumps(result, indent=2, ensure_ascii=False))
603
+ return 0 if result.get("ok") else 1
604
+ if not result.get("ok"):
605
+ print(result.get("error", "Failed to toggle automation"), file=sys.stderr)
606
+ return 1
607
+ verb = "enabled" if enabled else "disabled"
608
+ print(f"Automation {result['name']} {verb}.")
609
+ return 0
610
+
611
+
612
+ def _automations_status(args):
613
+ from script_registry import get_automation_status
614
+
615
+ result = get_automation_status(args.name)
616
+ if args.json:
617
+ print(json.dumps(result, indent=2, ensure_ascii=False))
618
+ return 0 if result.get("ok") else 1
619
+ if not result.get("ok"):
620
+ print(result.get("error", "Failed to read automation status"), file=sys.stderr)
621
+ return 1
622
+ state = "enabled" if result.get("enabled") else "DISABLED"
623
+ print(f"{result.get('name')} [{result.get('classification')}] -> {state}")
624
+ blocked_reason = (result.get("blocked_reason") or "").strip()
625
+ if blocked_reason:
626
+ print(f" blocked: {blocked_reason}")
627
+ schedule_label = (result.get("effective_schedule_label") or "").strip()
628
+ if schedule_label:
629
+ schedule_source = (result.get("schedule_source") or "manifest").strip()
630
+ print(f" schedule: {schedule_label} ({schedule_source})")
631
+ extra = (result.get("operator_extra_instructions") or "").strip()
632
+ if extra:
633
+ print(f" extra instructions: {extra[:160]}")
634
+ return 0
635
+
636
+
637
+ def _automations_set_instructions(args):
638
+ from script_registry import set_automation_instructions
639
+
640
+ if getattr(args, "clear", False):
641
+ text = ""
642
+ elif getattr(args, "stdin", False):
643
+ try:
644
+ text = sys.stdin.read()
645
+ except Exception as exc:
646
+ msg = f"Could not read instructions from stdin: {exc}"
647
+ if args.json:
648
+ print(json.dumps({"ok": False, "error": msg}, ensure_ascii=False))
649
+ else:
650
+ print(msg, file=sys.stderr)
651
+ return 1
652
+ else:
653
+ text = getattr(args, "text", None) or ""
654
+
655
+ result = set_automation_instructions(args.name, text)
656
+ if args.json:
657
+ print(json.dumps(result, indent=2, ensure_ascii=False))
658
+ return 0 if result.get("ok") else 1
659
+ if not result.get("ok"):
660
+ print(result.get("error", "Failed to update automation instructions"), file=sys.stderr)
661
+ return 1
662
+ if result.get("cleared"):
663
+ print(f"Extra instructions cleared for {result['name']}.")
664
+ else:
665
+ print(f"Extra instructions updated for {result['name']}.")
666
+ return 0
667
+
668
+
669
+ def _automations_set_schedule(args):
670
+ from script_registry import set_automation_schedule
671
+
672
+ interval_seconds = None
673
+ daily_at = None
674
+ if getattr(args, "every_minutes", None) is not None:
675
+ interval_seconds = int(args.every_minutes) * 60
676
+ elif getattr(args, "every_seconds", None) is not None:
677
+ interval_seconds = int(args.every_seconds)
678
+ elif getattr(args, "daily_at", None):
679
+ daily_at = str(args.daily_at).strip()
680
+
681
+ result = set_automation_schedule(
682
+ args.name,
683
+ interval_seconds=interval_seconds,
684
+ daily_at=daily_at,
685
+ clear=bool(getattr(args, "reset", False)),
686
+ )
687
+ if args.json:
688
+ print(json.dumps(result, indent=2, ensure_ascii=False))
689
+ return 0 if result.get("ok") else 1
690
+ if not result.get("ok"):
691
+ print(result.get("error", "Failed to update automation cadence"), file=sys.stderr)
692
+ return 1
693
+ label = str(result.get("effective_schedule_label") or "").strip()
694
+ source = str(result.get("schedule_source") or "manifest").strip()
695
+ if label:
696
+ print(f"Schedule updated for {result['name']}: {label} ({source})")
697
+ else:
698
+ print(f"Schedule updated for {result['name']}.")
699
+ return 0
700
+
701
+
579
702
  def _scripts_remove(args):
580
703
  from script_registry import remove_personal_script
581
704
 
@@ -2264,7 +2387,7 @@ def main():
2264
2387
  create_p.add_argument("--json", action="store_true", help="JSON output")
2265
2388
 
2266
2389
  # scripts classify
2267
- classify_p = scripts_sub.add_parser("classify", help="Classify all files in NEXO_HOME/scripts")
2390
+ classify_p = scripts_sub.add_parser("classify", help="Classify all files in NEXO_HOME/personal/scripts")
2268
2391
  classify_p.add_argument("--json", action="store_true", help="JSON output")
2269
2392
 
2270
2393
  # scripts sync
@@ -2348,6 +2471,48 @@ def main():
2348
2471
  call_p.add_argument("--input", default="{}", help="JSON input payload")
2349
2472
  call_p.add_argument("--json-output", action="store_true", help="Force JSON output")
2350
2473
 
2474
+ automations_parser = sub.add_parser("automations", help="Manage Desktop-facing automations")
2475
+ automations_sub = automations_parser.add_subparsers(dest="automations_command")
2476
+
2477
+ automations_list_p = automations_sub.add_parser("list", help="List operator-facing automations")
2478
+ automations_list_p.add_argument("--all", action="store_true", help="Include support/debug automation rows as well")
2479
+ automations_list_p.add_argument("--json", action="store_true", help="JSON output")
2480
+
2481
+ automations_enable_p = automations_sub.add_parser("enable", help="Enable an automation")
2482
+ automations_enable_p.add_argument("name", help="Automation name or path")
2483
+ automations_enable_p.add_argument("--json", action="store_true", help="JSON output")
2484
+
2485
+ automations_disable_p = automations_sub.add_parser("disable", help="Disable an automation")
2486
+ automations_disable_p.add_argument("name", help="Automation name or path")
2487
+ automations_disable_p.add_argument("--json", action="store_true", help="JSON output")
2488
+
2489
+ automations_status_p = automations_sub.add_parser("status", help="Read automation status")
2490
+ automations_status_p.add_argument("name", help="Automation name or path")
2491
+ automations_status_p.add_argument("--json", action="store_true", help="JSON output")
2492
+
2493
+ automations_instructions_p = automations_sub.add_parser(
2494
+ "instructions",
2495
+ help="Set or clear operator extra instructions for an automation",
2496
+ )
2497
+ automations_instructions_p.add_argument("name", help="Automation name or path")
2498
+ automations_instructions_group = automations_instructions_p.add_mutually_exclusive_group(required=True)
2499
+ automations_instructions_group.add_argument("--text", help="Instruction text to persist")
2500
+ automations_instructions_group.add_argument("--stdin", action="store_true", help="Read instruction text from stdin")
2501
+ automations_instructions_group.add_argument("--clear", action="store_true", help="Clear any persisted extra instructions")
2502
+ automations_instructions_p.add_argument("--json", action="store_true", help="JSON output")
2503
+
2504
+ automations_schedule_p = automations_sub.add_parser(
2505
+ "schedule",
2506
+ help="Change the cadence of an operator-facing automation",
2507
+ )
2508
+ automations_schedule_p.add_argument("name", help="Automation name or path")
2509
+ automations_schedule_group = automations_schedule_p.add_mutually_exclusive_group(required=True)
2510
+ automations_schedule_group.add_argument("--every-minutes", type=int, help="Run the automation every N minutes")
2511
+ automations_schedule_group.add_argument("--every-seconds", type=int, help="Run the automation every N seconds")
2512
+ automations_schedule_group.add_argument("--daily-at", type=str, help="Run the automation every day at HH:MM (24h)")
2513
+ automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
2514
+ automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
2515
+
2351
2516
  # -- update --
2352
2517
  update_parser = sub.add_parser("update", help="Update installed runtime")
2353
2518
  update_parser.add_argument("--json", action="store_true", help="JSON output")
@@ -2638,6 +2803,21 @@ def main():
2638
2803
  else:
2639
2804
  scripts_parser.print_help()
2640
2805
  return 0
2806
+ elif args.command == "automations":
2807
+ if args.automations_command == "list":
2808
+ return _automations_list(args)
2809
+ elif args.automations_command == "enable":
2810
+ return _automations_set_enabled(args, True)
2811
+ elif args.automations_command == "disable":
2812
+ return _automations_set_enabled(args, False)
2813
+ elif args.automations_command == "status":
2814
+ return _automations_status(args)
2815
+ elif args.automations_command == "instructions":
2816
+ return _automations_set_instructions(args)
2817
+ elif args.automations_command == "schedule":
2818
+ return _automations_set_schedule(args)
2819
+ automations_parser.print_help()
2820
+ return 0
2641
2821
  elif args.command == "chat":
2642
2822
  return _chat(args)
2643
2823
  elif args.command == "export":