nexo-brain 2.6.11 → 2.6.12

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.
@@ -11,6 +11,11 @@ import sys
11
11
  import time
12
12
  from pathlib import Path
13
13
 
14
+ from client_preferences import (
15
+ detect_installed_clients,
16
+ normalize_client_preferences,
17
+ resolve_client_runtime_profile,
18
+ )
14
19
  from cron_recovery import should_run_at_load
15
20
  from doctor.models import DoctorCheck
16
21
 
@@ -31,6 +36,7 @@ DEFAULT_CRON_THRESHOLD = 7200 # Fallback when manifest data is unavailable
31
36
  SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
32
37
  SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
33
38
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
39
+ SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
34
40
 
35
41
 
36
42
  def _file_age_seconds(path: Path) -> float | None:
@@ -90,6 +96,14 @@ def _enabled_manifest_crons() -> list[dict]:
90
96
  NEXO_CODE / "crons" / "manifest.json",
91
97
  ]
92
98
  optionals = _enabled_optionals()
99
+ automation_default = True
100
+ try:
101
+ if SCHEDULE_FILE.is_file():
102
+ schedule = _load_json(SCHEDULE_FILE)
103
+ if isinstance(schedule, dict):
104
+ automation_default = bool(schedule.get("automation_enabled", True))
105
+ except Exception:
106
+ pass
93
107
  for manifest_path in manifest_candidates:
94
108
  if not manifest_path.is_file():
95
109
  continue
@@ -104,7 +118,11 @@ def _enabled_manifest_crons() -> list[dict]:
104
118
  if not cron_id:
105
119
  continue
106
120
  optional_key = cron.get("optional")
107
- if optional_key and not optionals.get(optional_key, False):
121
+ if optional_key == "automation":
122
+ optional_enabled = optionals.get(optional_key, automation_default)
123
+ else:
124
+ optional_enabled = optionals.get(optional_key, False)
125
+ if optional_key and not optional_enabled:
108
126
  continue
109
127
  enabled.append(cron)
110
128
  return enabled
@@ -875,6 +893,95 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
875
893
  )
876
894
 
877
895
 
896
+ def check_client_backend_preferences() -> DoctorCheck:
897
+ schedule = {}
898
+ try:
899
+ if SCHEDULE_FILE.is_file():
900
+ schedule = _load_json(SCHEDULE_FILE)
901
+ except Exception:
902
+ schedule = {}
903
+
904
+ prefs = normalize_client_preferences(schedule)
905
+ detected = detect_installed_clients()
906
+
907
+ default_terminal = prefs["default_terminal_client"]
908
+ automation_enabled = bool(prefs["automation_enabled"])
909
+ automation_backend = prefs["automation_backend"]
910
+ default_profile = resolve_client_runtime_profile(default_terminal, preferences=prefs)
911
+ automation_profile = (
912
+ resolve_client_runtime_profile(automation_backend, preferences=prefs)
913
+ if automation_enabled and automation_backend != "none"
914
+ else {"model": "", "reasoning_effort": ""}
915
+ )
916
+
917
+ evidence: list[str] = []
918
+ repair_plan: list[str] = []
919
+ severity = "info"
920
+ status = "healthy"
921
+
922
+ default_info = detected.get(default_terminal, {})
923
+ if not default_info.get("installed"):
924
+ status = "degraded"
925
+ severity = "warn"
926
+ evidence.append(f"default terminal client `{default_terminal}` is selected but not installed")
927
+ repair_plan.append(f"Install {default_terminal} or switch the default terminal client in schedule.json")
928
+
929
+ for client_key, enabled in prefs.get("interactive_clients", {}).items():
930
+ if not enabled:
931
+ continue
932
+ info = detected.get(client_key, {})
933
+ if not info.get("installed"):
934
+ status = "degraded"
935
+ severity = "warn"
936
+ evidence.append(f"interactive client `{client_key}` is enabled but not installed")
937
+
938
+ if automation_enabled:
939
+ backend_info = detected.get(automation_backend, {})
940
+ if automation_backend == "none":
941
+ status = "degraded"
942
+ severity = "warn"
943
+ evidence.append("automation is enabled but no automation backend is configured")
944
+ elif not backend_info.get("installed"):
945
+ status = "degraded"
946
+ severity = "warn"
947
+ evidence.append(f"automation backend `{automation_backend}` is enabled but not installed")
948
+ repair_plan.append(f"Install {automation_backend} or disable automation in schedule.json")
949
+
950
+ if not repair_plan and status != "healthy":
951
+ repair_plan.append("Run `nexo update` or `nexo clients sync` after installing the selected client/backend")
952
+
953
+ def _profile_label(client_key: str, profile: dict[str, str]) -> str:
954
+ bits = [client_key]
955
+ if profile.get("model"):
956
+ bits.append(profile["model"])
957
+ if profile.get("reasoning_effort"):
958
+ bits.append(profile["reasoning_effort"])
959
+ return "/".join(bits)
960
+
961
+ terminal_label = f"chat={_profile_label(default_terminal, default_profile)}"
962
+ automation_label = (
963
+ f"automation={_profile_label(automation_backend, automation_profile)}"
964
+ if automation_enabled and automation_backend != "none"
965
+ else "automation=none"
966
+ )
967
+ return DoctorCheck(
968
+ id="runtime.clients",
969
+ tier="runtime",
970
+ status=status,
971
+ severity=severity,
972
+ summary=f"Client/backend preferences OK ({terminal_label}, {automation_label})" if status == "healthy" else f"Client/backend preferences need attention ({terminal_label}, {automation_label})",
973
+ evidence=evidence or [
974
+ f"default terminal client: {_profile_label(default_terminal, default_profile)}",
975
+ f"automation backend: {_profile_label(automation_backend, automation_profile) if automation_enabled and automation_backend != 'none' else 'none'}",
976
+ ],
977
+ repair_plan=repair_plan,
978
+ escalation_prompt=(
979
+ "The configured interactive client or automation backend is missing. "
980
+ "Align installed clients with schedule.json so `nexo chat` and background automation use the intended tools."
981
+ ) if status != "healthy" else "",
982
+ )
983
+
984
+
878
985
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
879
986
  """Run all runtime-tier checks. Read-only by default."""
880
987
  return [
@@ -882,6 +989,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
882
989
  check_watchdog_status(),
883
990
  check_stale_sessions(),
884
991
  check_cron_freshness(),
992
+ check_client_backend_preferences(),
885
993
  check_launchagent_integrity(fix=fix),
886
994
  check_personal_script_registry(fix=fix),
887
995
  check_skill_health(fix=fix),
@@ -588,11 +588,15 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
588
588
  try:
589
589
  _emit_progress(progress_fn, "Refreshing shared client configs...")
590
590
  from client_sync import sync_all_clients
591
+ from client_preferences import normalize_client_preferences
591
592
 
593
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
594
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
592
595
  client_sync_result = sync_all_clients(
593
596
  nexo_home=NEXO_HOME,
594
597
  runtime_root=SRC_DIR,
595
598
  operator_name=os.environ.get("NEXO_NAME", ""),
599
+ preferences=normalize_client_preferences(schedule_payload),
596
600
  )
597
601
  if client_sync_result.get("ok"):
598
602
  steps_done.append("client-sync")
@@ -619,7 +623,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
619
623
  if "hook-sync" in steps_done:
620
624
  lines.append(" Hooks: synced to NEXO_HOME")
621
625
  if "client-sync" in steps_done:
622
- lines.append(" Clients: Claude Code/Desktop/Codex synced")
626
+ lines.append(" Clients: configured client targets synced")
623
627
  lines.append("")
624
628
  lines.append("MCP server restart needed to load new code.")
625
629
  return "\n".join(lines)
@@ -71,6 +71,29 @@ def _schedule_defaults() -> dict:
71
71
  return {
72
72
  "timezone": "UTC",
73
73
  "auto_update": True,
74
+ "interactive_clients": {
75
+ "claude_code": True,
76
+ "codex": False,
77
+ "claude_desktop": False,
78
+ },
79
+ "default_terminal_client": "claude_code",
80
+ "automation_enabled": True,
81
+ "automation_backend": "claude_code",
82
+ "client_runtime_profiles": {
83
+ "claude_code": {
84
+ "model": "opus",
85
+ "reasoning_effort": "",
86
+ },
87
+ "codex": {
88
+ "model": "gpt-5.4",
89
+ "reasoning_effort": "xhigh",
90
+ },
91
+ },
92
+ "client_install_preferences": {
93
+ "claude_code": "ask",
94
+ "codex": "ask",
95
+ "claude_desktop": "manual",
96
+ },
74
97
  POWER_POLICY_KEY: POWER_POLICY_UNSET,
75
98
  POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
76
99
  FULL_DISK_ACCESS_STATUS_KEY: FULL_DISK_ACCESS_UNSET,
@@ -2,37 +2,42 @@
2
2
  """Context checker for NEXO operations - prevents duplicate actions.
3
3
 
4
4
  Mechanical checks (email sent, file exists, action done) run in Python.
5
- When the 'smart' command is used, passes context to Claude CLI for
6
- intelligent duplicate/conflict detection that goes beyond file checks.
5
+ When the 'smart' command is used, NEXO asks the configured automation backend
6
+ for semantic duplicate/conflict detection that goes beyond file checks.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
9
13
  import os
10
14
  import sys
11
- import json
12
- import hashlib
13
- import subprocess
14
15
  from datetime import datetime
15
16
  from pathlib import Path
16
17
 
17
18
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
20
+ if str(NEXO_CODE) not in sys.path:
21
+ sys.path.insert(0, str(NEXO_CODE))
22
+
23
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
18
24
 
19
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
20
25
 
21
26
  class ContextChecker:
22
27
  def __init__(self):
23
- self.state_dir = NEXO_HOME / 'state'
28
+ self.state_dir = NEXO_HOME / "state"
24
29
  self.state_dir.mkdir(exist_ok=True)
25
-
30
+
26
31
  def check_email_sent(self, to_addr, subject, since_hours=72):
27
32
  """Check if email was already sent to address with subject."""
28
- sent_path = Path.home() / 'mail' / '.nexo-sent' / '.Sent' # Configure for your mail setup
33
+ sent_path = Path.home() / "mail" / ".nexo-sent" / ".Sent"
29
34
  if not sent_path.exists():
30
35
  return False
31
36
 
32
37
  subject_lower = subject.lower()
33
38
  to_lower = to_addr.lower()
34
39
  cutoff = datetime.now().timestamp() - (since_hours * 3600)
35
- cur_dir = sent_path / 'cur'
40
+ cur_dir = sent_path / "cur"
36
41
  if not cur_dir.exists():
37
42
  return False
38
43
 
@@ -40,7 +45,7 @@ class ContextChecker:
40
45
  try:
41
46
  if msg_file.stat().st_mtime < cutoff:
42
47
  continue
43
- content = msg_file.read_text(errors='ignore')
48
+ content = msg_file.read_text(errors="ignore")
44
49
  except (OSError, UnicodeDecodeError):
45
50
  continue
46
51
 
@@ -49,16 +54,16 @@ class ContextChecker:
49
54
  if subject_lower in content_lower:
50
55
  return True
51
56
  return False
52
-
57
+
53
58
  def check_file_exists(self, pattern, search_dirs=None):
54
59
  """Check if file matching pattern exists in common locations."""
55
60
  if search_dirs is None:
56
61
  search_dirs = [
57
- '/var/www/vhosts',
62
+ "/var/www/vhosts",
58
63
  str(NEXO_HOME),
59
- '/opt'
64
+ "/opt",
60
65
  ]
61
-
66
+
62
67
  for base_dir in search_dirs:
63
68
  if not os.path.exists(base_dir):
64
69
  continue
@@ -73,83 +78,92 @@ class ContextChecker:
73
78
  except OSError:
74
79
  continue
75
80
  return []
76
-
81
+
77
82
  def check_action_done(self, action_type, identifier, ttl_days=7):
78
83
  """Check if action was already performed recently."""
79
- action_file = self.state_dir / 'actions.json'
80
-
81
- # Load existing actions
84
+ action_file = self.state_dir / "actions.json"
82
85
  actions = {}
83
86
  if action_file.exists():
84
- with open(action_file) as f:
85
- actions = json.load(f)
86
-
87
- # Create action key
87
+ with open(action_file) as fh:
88
+ actions = json.load(fh)
89
+
88
90
  key = hashlib.md5(f"{action_type}:{identifier}".encode()).hexdigest()
89
-
90
- # Check if exists and not expired
91
91
  if key in actions:
92
- action_time = datetime.fromisoformat(actions[key]['timestamp'])
92
+ action_time = datetime.fromisoformat(actions[key]["timestamp"])
93
93
  age_days = (datetime.now() - action_time).days
94
94
  if age_days < ttl_days:
95
95
  return True, actions[key]
96
-
97
96
  return False, None
98
-
97
+
99
98
  def mark_action_done(self, action_type, identifier, metadata=None):
100
99
  """Mark action as completed."""
101
- action_file = self.state_dir / 'actions.json'
102
-
103
- # Load existing actions
100
+ action_file = self.state_dir / "actions.json"
104
101
  actions = {}
105
102
  if action_file.exists():
106
- with open(action_file) as f:
107
- actions = json.load(f)
108
-
109
- # Add new action
103
+ with open(action_file) as fh:
104
+ actions = json.load(fh)
105
+
110
106
  key = hashlib.md5(f"{action_type}:{identifier}".encode()).hexdigest()
111
107
  actions[key] = {
112
- 'type': action_type,
113
- 'identifier': identifier,
114
- 'timestamp': datetime.now().isoformat(),
115
- 'metadata': metadata or {}
108
+ "type": action_type,
109
+ "identifier": identifier,
110
+ "timestamp": datetime.now().isoformat(),
111
+ "metadata": metadata or {},
116
112
  }
117
-
118
- # Save
119
- with open(action_file, 'w') as f:
120
- json.dump(actions, f, indent=2)
121
-
113
+ with open(action_file, "w") as fh:
114
+ json.dump(actions, fh, indent=2)
122
115
  return key
123
116
 
124
- def smart_check(action_description: str, context: str = "") -> dict:
125
- """Use Claude CLI to intelligently check if an action would be redundant.
126
117
 
127
- Goes beyond simple file/hash checks understands intent and context
128
- to detect semantic duplicates (e.g., "send welcome email" vs
129
- "email onboarding message" to same person).
130
- """
118
+ def _extract_json(text: str) -> dict | None:
119
+ text = (text or "").strip()
120
+ if not text:
121
+ return None
122
+ if text.startswith("```"):
123
+ lines = text.splitlines()
124
+ end = len(lines)
125
+ for idx in range(len(lines) - 1, 0, -1):
126
+ if lines[idx].strip() == "```":
127
+ end = idx
128
+ break
129
+ text = "\n".join(lines[1:end]).strip()
130
+ brace_start = text.find("{")
131
+ if brace_start < 0:
132
+ return None
133
+ depth = 0
134
+ for idx in range(brace_start, len(text)):
135
+ if text[idx] == "{":
136
+ depth += 1
137
+ elif text[idx] == "}":
138
+ depth -= 1
139
+ if depth == 0:
140
+ try:
141
+ return json.loads(text[brace_start:idx + 1])
142
+ except json.JSONDecodeError:
143
+ return None
144
+ return None
145
+
146
+
147
+ def smart_check(action_description: str, context: str = "") -> dict:
148
+ """Use the automation backend to check if an action would be redundant."""
131
149
  checker = ContextChecker()
132
150
 
133
- # Gather mechanical context first
134
- state_file = checker.state_dir / 'actions.json'
151
+ state_file = checker.state_dir / "actions.json"
135
152
  recent_actions = {}
136
153
  if state_file.exists():
137
154
  try:
138
155
  all_actions = json.loads(state_file.read_text())
139
156
  cutoff = datetime.now().timestamp() - (7 * 86400)
140
- for k, v in all_actions.items():
157
+ for key, value in all_actions.items():
141
158
  try:
142
- ts = datetime.fromisoformat(v['timestamp']).timestamp()
159
+ ts = datetime.fromisoformat(value["timestamp"]).timestamp()
143
160
  if ts > cutoff:
144
- recent_actions[k] = v
161
+ recent_actions[key] = value
145
162
  except (ValueError, KeyError):
146
163
  pass
147
164
  except Exception:
148
165
  pass
149
166
 
150
- if not CLAUDE_CLI.exists():
151
- return {"redundant": False, "reason": "CLI unavailable, cannot smart-check"}
152
-
153
167
  prompt = f"""You are a context deduplication engine for NEXO operations.
154
168
 
155
169
  PROPOSED ACTION:
@@ -159,9 +173,9 @@ ADDITIONAL CONTEXT:
159
173
  {context or "None"}
160
174
 
161
175
  RECENT ACTIONS (last 7 days):
162
- {json.dumps(list(recent_actions.values()), indent=1, default=str)}
176
+ {json.dumps(list(recent_actions.values()), indent=1, default=str, ensure_ascii=False)}
163
177
 
164
- Respond with ONLY valid JSON (no markdown):
178
+ Respond with ONLY valid JSON:
165
179
  {{
166
180
  "redundant": true/false,
167
181
  "confidence": 0.0-1.0,
@@ -172,35 +186,28 @@ Respond with ONLY valid JSON (no markdown):
172
186
  Rules:
173
187
  - Same recipient + same intent within 72h = redundant
174
188
  - Same file modification with same content = redundant
175
- - Similar but different scope (e.g., different recipients) = NOT redundant
189
+ - Similar but different scope (e.g. different recipients) = NOT redundant
176
190
  - When in doubt, say not redundant (false negatives are cheaper than false positives)"""
177
- )
178
- if auth_check.returncode != 0:
179
- # CLI not authenticated, skip gracefully
180
- return {"redundant": False, "reason": "CLI not authenticated — skipped analysis", "suggestion": "N/A"}
181
-
182
- env = os.environ.copy()
183
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
184
- env.pop("CLAUDECODE", None)
185
- env.pop("CLAUDE_CODE", None)
186
191
 
187
192
  try:
188
- result = subprocess.run(
189
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus", "--output-format", "text",
190
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
191
- capture_output=True, text=True, timeout=21600, env=env
193
+ result = run_automation_prompt(
194
+ prompt,
195
+ model="opus",
196
+ timeout=300,
197
+ output_format="text",
198
+ append_system_prompt="Return exactly one valid JSON object.",
199
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
192
200
  )
193
201
  if result.returncode == 0:
194
- text = result.stdout.strip()
195
- if "```json" in text:
196
- text = text.split("```json")[1].split("```")[0]
197
- elif "```" in text:
198
- text = text.split("```")[1].split("```")[0]
199
- return json.loads(text.strip())
202
+ parsed = _extract_json(result.stdout)
203
+ if parsed:
204
+ return parsed
205
+ except AutomationBackendUnavailableError as exc:
206
+ return {"redundant": False, "reason": f"Automation backend unavailable — {exc}"}
200
207
  except Exception:
201
208
  pass
202
209
 
203
- return {"redundant": False, "reason": "CLI check failed, defaulting to not redundant"}
210
+ return {"redundant": False, "reason": "Automation check failed, defaulting to not redundant"}
204
211
 
205
212
 
206
213
  def main():
@@ -211,13 +218,13 @@ def main():
211
218
  print(" email <to> <subject> - Check if email was sent")
212
219
  print(" file <pattern> - Check if file exists")
213
220
  print(" action <type> <id> - Check if action was done")
214
- print(" smart <description> [ctx] - Intelligent duplicate check via CLI")
221
+ print(" smart <description> [ctx] - Intelligent duplicate check via automation backend")
215
222
  sys.exit(1)
216
223
 
217
224
  checker = ContextChecker()
218
225
  command = sys.argv[1]
219
226
 
220
- if command == 'email':
227
+ if command == "email":
221
228
  if len(sys.argv) < 4:
222
229
  print("Usage: check-context.py email <to> <subject>")
223
230
  sys.exit(1)
@@ -225,16 +232,15 @@ def main():
225
232
  print("EXISTS" if exists else "NOT_FOUND")
226
233
  sys.exit(0 if not exists else 1)
227
234
 
228
- elif command == 'file':
235
+ if command == "file":
229
236
  files = checker.check_file_exists(sys.argv[2])
230
237
  if files:
231
238
  print("\n".join(files))
232
239
  sys.exit(1)
233
- else:
234
- print("NOT_FOUND")
235
- sys.exit(0)
240
+ print("NOT_FOUND")
241
+ sys.exit(0)
236
242
 
237
- elif command == 'action':
243
+ if command == "action":
238
244
  if len(sys.argv) < 4:
239
245
  print("Usage: check-context.py action <type> <id>")
240
246
  sys.exit(1)
@@ -242,23 +248,19 @@ def main():
242
248
  if done:
243
249
  print(f"DONE: {data}")
244
250
  sys.exit(1)
245
- else:
246
- print("NOT_DONE")
247
- sys.exit(0)
251
+ print("NOT_DONE")
252
+ sys.exit(0)
248
253
 
249
- elif command == 'smart':
250
- if len(sys.argv) < 3:
251
- print("Usage: check-context.py smart <description> [context]")
252
- sys.exit(1)
254
+ if command == "smart":
253
255
  description = sys.argv[2]
254
256
  context = sys.argv[3] if len(sys.argv) > 3 else ""
255
257
  result = smart_check(description, context)
256
- print(json.dumps(result, indent=2))
258
+ print(json.dumps(result, indent=2, ensure_ascii=False))
257
259
  sys.exit(1 if result.get("redundant") else 0)
258
260
 
259
- else:
260
- print(f"Unknown command: {command}")
261
- sys.exit(1)
261
+ print(f"Unknown command: {command}")
262
+ sys.exit(1)
263
+
262
264
 
263
- if __name__ == '__main__':
265
+ if __name__ == "__main__":
264
266
  main()