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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -2
- package/bin/nexo-brain.js +198 -92
- package/package.json +1 -1
- package/src/agent_runner.py +10 -8
- package/src/auto_close_sessions.py +19 -2
- package/src/auto_update.py +305 -42
- package/src/autonomy_mandate.py +260 -0
- package/src/bootstrap_docs.py +22 -1
- package/src/cli.py +181 -1
- package/src/cli_email.py +104 -73
- package/src/client_sync.py +22 -1
- package/src/cognitive/_core.py +5 -3
- package/src/core_prompts.py +50 -0
- package/src/cron_recovery.py +81 -7
- package/src/crons/manifest.json +57 -0
- package/src/crons/sync.py +95 -26
- package/src/dashboard/app.py +59 -0
- package/src/dashboard/templates/base.html +2 -0
- package/src/dashboard/templates/feature-disabled.html +27 -0
- package/src/db/_email_accounts.py +67 -18
- package/src/db/_fts.py +5 -5
- package/src/db/_personal_scripts.py +1 -1
- package/src/db/_skills.py +3 -3
- package/src/doctor/providers/runtime.py +35 -20
- package/src/email_config.py +18 -9
- package/src/enforcement_classifier.py +3 -12
- package/src/evolution_cycle.py +37 -149
- package/src/guardian_telemetry.py +3 -2
- package/src/hook_guardrails.py +61 -0
- package/src/hooks/capture-tool-logs.sh +11 -3
- package/src/hooks/daily-briefing-check.sh +7 -2
- package/src/hooks/heartbeat-enforcement.py +14 -1
- package/src/hooks/heartbeat-posttool.sh +2 -0
- package/src/hooks/heartbeat-user-msg.sh +2 -0
- package/src/hooks/inbox-hook.sh +6 -2
- package/src/hooks/post-compact.sh +12 -4
- package/src/hooks/pre-compact.sh +12 -4
- package/src/migrate_embeddings.py +5 -3
- package/src/nexo_migrate.py +3 -1
- package/src/plugin_loader.py +14 -5
- package/src/plugins/adaptive_mode.py +4 -1
- package/src/plugins/backup.py +32 -20
- package/src/plugins/evolution.py +2 -0
- package/src/plugins/memory_export.py +6 -1
- package/src/plugins/personal_plugins.py +17 -7
- package/src/plugins/personal_scripts.py +64 -3
- package/src/presets/entities_universal.json +67 -4
- package/src/product_mode.py +201 -0
- package/src/r14_correction_learning.py +5 -20
- package/src/r15_project_context.py +4 -10
- package/src/r16_declared_done.py +3 -16
- package/src/r17_promise_debt.py +3 -16
- package/src/r18_followup_autocomplete.py +5 -7
- package/src/r19_project_grep.py +5 -8
- package/src/r20_constant_change.py +5 -15
- package/src/r21_legacy_path.py +5 -7
- package/src/r22_personal_script.py +4 -8
- package/src/r23_ssh_without_atlas.py +4 -11
- package/src/r23b_deploy_vhost.py +7 -6
- package/src/r23c_cwd_mismatch.py +7 -6
- package/src/r23d_chown_chmod_recursive.py +6 -6
- package/src/r23e_force_push_main.py +5 -6
- package/src/r23f_db_no_where.py +5 -6
- package/src/r23g_secrets_in_output.py +5 -5
- package/src/r23h_shebang_mismatch.py +6 -5
- package/src/r23i_auto_deploy_ignored.py +5 -6
- package/src/r23j_global_install.py +5 -6
- package/src/r23k_script_duplicates_skill.py +7 -6
- package/src/r23l_resource_collision.py +7 -6
- package/src/r23m_message_duplicate.py +6 -5
- package/src/r24_stale_memory.py +4 -9
- package/src/r25_nora_maria_read_only.py +5 -10
- package/src/r34_identity_coherence.py +6 -13
- package/src/r_catalog.py +3 -7
- package/src/resonance_map.py +13 -13
- package/src/runtime_power.py +29 -80
- package/src/script_registry.py +236 -6
- package/src/scripts/check-context.py +8 -25
- package/src/scripts/deep-sleep/extract.py +6 -10
- package/src/scripts/nexo-auto-update.py +27 -4
- package/src/scripts/nexo-catchup.py +9 -19
- package/src/scripts/nexo-cognitive-decay.py +26 -3
- package/src/scripts/nexo-daily-self-audit.py +50 -51
- package/src/scripts/nexo-email-migrate-config.py +30 -11
- package/src/scripts/nexo-email-monitor.py +97 -238
- package/src/scripts/nexo-followup-runner.py +70 -133
- package/src/scripts/nexo-hook-record.py +1 -1
- package/src/scripts/nexo-immune.py +6 -31
- package/src/scripts/nexo-impact-scorer.py +27 -4
- package/src/scripts/nexo-learning-housekeep.py +26 -3
- package/src/scripts/nexo-learning-validator.py +34 -32
- package/src/scripts/nexo-migrate.py +28 -12
- package/src/scripts/nexo-morning-agent.py +9 -23
- package/src/scripts/nexo-outcome-checker.py +27 -4
- package/src/scripts/nexo-postmortem-consolidator.py +30 -62
- package/src/scripts/nexo-pre-commit.py +28 -0
- package/src/scripts/nexo-proactive-dashboard.py +27 -0
- package/src/scripts/nexo-reflection.py +33 -3
- package/src/scripts/nexo-runtime-preflight.py +27 -2
- package/src/scripts/nexo-send-reply.py +10 -8
- package/src/scripts/nexo-sleep.py +11 -25
- package/src/scripts/nexo-synthesis.py +7 -40
- package/src/scripts/nexo-watchdog-smoke.py +30 -1
- package/src/scripts/nexo-watchdog.sh +23 -17
- package/src/scripts/phase_guardian_analysis.py +27 -4
- package/src/server.py +14 -3
- package/src/storage_router.py +8 -6
- package/src/tools_drive.py +5 -13
- package/src/tools_guardian.py +3 -4
- package/src/tools_menu.py +2 -2
- package/src/tools_reminders_crud.py +17 -0
- package/src/tools_sessions.py +1 -4
- package/src/user_context.py +3 -6
- package/src/user_data_portability.py +31 -23
- package/templates/CLAUDE.md.template +11 -3
- package/templates/CODEX.AGENTS.md.template +11 -3
- package/templates/core-prompts/catchup-assessment.md +19 -0
- package/templates/core-prompts/check-context.md +24 -0
- package/templates/core-prompts/daily-self-audit.md +42 -0
- package/templates/core-prompts/daily-synthesis.md +40 -0
- package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
- package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
- package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
- package/templates/core-prompts/email-monitor.md +202 -0
- package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
- package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
- package/templates/core-prompts/evolution-public-contribution.md +32 -0
- package/templates/core-prompts/evolution-public-pr-review.md +38 -0
- package/templates/core-prompts/evolution-weekly.md +71 -0
- package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
- package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
- package/templates/core-prompts/followup-runner.md +74 -0
- package/templates/core-prompts/immune-triage.md +31 -0
- package/templates/core-prompts/interactive-startup.md +1 -0
- package/templates/core-prompts/json-object-only.md +1 -0
- package/templates/core-prompts/learning-validator.md +25 -0
- package/templates/core-prompts/morning-agent-json-output.md +1 -0
- package/templates/core-prompts/morning-agent.md +23 -0
- package/templates/core-prompts/postmortem-consolidator.md +60 -0
- package/templates/core-prompts/r-catalog.md +1 -0
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
- package/templates/core-prompts/r14-correction-learning-question.md +1 -0
- package/templates/core-prompts/r15-project-context-injection.md +1 -0
- package/templates/core-prompts/r16-declared-done-injection.md +1 -0
- package/templates/core-prompts/r16-declared-done-question.md +1 -0
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
- package/templates/core-prompts/r17-promise-debt-question.md +1 -0
- package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
- package/templates/core-prompts/r19-project-grep-injection.md +1 -0
- package/templates/core-prompts/r20-constant-change-injection.md +1 -0
- package/templates/core-prompts/r20-constant-change-question.md +1 -0
- package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
- package/templates/core-prompts/r22-personal-script-injection.md +1 -0
- package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
- package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
- package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
- package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
- package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
- package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
- package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
- package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
- package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
- package/templates/core-prompts/r23j-global-install-injection.md +1 -0
- package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
- package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
- package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
- package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
- package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
- package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
- package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
- package/templates/core-prompts/sleep.md +25 -0
- package/templates/email-template.md +55 -0
- package/templates/nexo_helper.py +31 -13
- package/templates/plugin-template.py +3 -3
- 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)
|
package/src/bootstrap_docs.py
CHANGED
|
@@ -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
|
|
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":
|