nexo-brain 2.6.11 → 2.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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()
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  from __future__ import annotations
3
3
  """
4
- Deep Sleep v2 -- Phase 2: Extract findings from each session using Claude CLI.
4
+ Deep Sleep v2 -- Phase 2: Extract findings from each session using the configured automation backend.
5
5
 
6
6
  For each session in the context file, sends the extract-prompt.md to Claude
7
7
  and collects structured findings. Merges all per-session results into
@@ -12,35 +12,23 @@ Environment variables:
12
12
  """
13
13
  import json
14
14
  import os
15
- import shutil
16
15
  import subprocess
17
16
  import sys
18
17
  from datetime import datetime
19
18
  from pathlib import Path
20
19
 
21
20
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
21
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
22
22
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
23
23
  PROMPT_FILE = Path(__file__).parent / "extract-prompt.md"
24
24
 
25
- # No timeout -- user pays unlimited Claude Code, sessions can take as long as needed
26
- CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
25
+ if str(NEXO_CODE) not in sys.path:
26
+ sys.path.insert(0, str(NEXO_CODE))
27
27
 
28
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
28
29
 
29
- def find_claude_cli() -> str:
30
- """Find the Claude CLI binary."""
31
- # Check common locations
32
- candidates = [
33
- Path.home() / ".local" / "bin" / "claude",
34
- Path("/usr/local/bin/claude"),
35
- ]
36
- for c in candidates:
37
- if c.exists():
38
- return str(c)
39
- # Try PATH
40
- which = shutil.which("claude")
41
- if which:
42
- return which
43
- return "claude" # Fallback, let it fail with a clear error
30
+ # No timeout -- headless automation can take as long as needed
31
+ CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
44
32
 
45
33
 
46
34
  def extract_json_from_response(text: str) -> dict | None:
@@ -88,10 +76,10 @@ def find_session_file(session_id: str, date_dir: Path) -> Path | None:
88
76
  return None
89
77
 
90
78
 
91
- def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path | None, claude_bin: str) -> dict | None:
92
- """Send a session to Claude CLI for extraction analysis.
79
+ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path | None) -> dict | None:
80
+ """Send a session to the automation backend for extraction analysis.
93
81
 
94
- Claude CLI reads the small per-session file + shared context file.
82
+ The backend reads the small per-session file + shared context file.
95
83
  Prompt is short — the heavy lifting is in the Read tool calls.
96
84
  """
97
85
  session_file = find_session_file(session_id, date_dir)
@@ -112,35 +100,23 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
112
100
  prompt += shared_ctx_instruction
113
101
 
114
102
  try:
115
- env = os.environ.copy()
116
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
117
- env.pop("CLAUDECODE", None)
118
- env.pop("CLAUDE_CODE", None)
119
-
120
103
  JSON_SYSTEM_PROMPT = (
121
104
  "You are a JSON-only analyst. Your ENTIRE response must be a single valid JSON object. "
122
105
  "No text before it. No text after it. No markdown fences. No explanations. "
123
106
  "If you want to summarize, put it inside the JSON fields. Start with { and end with }."
124
107
  )
125
108
 
126
- result = subprocess.run(
127
- [
128
- claude_bin,
129
- "-p", prompt,
130
- "--model", "opus",
131
- "--output-format", "text",
132
- "--append-system-prompt", JSON_SYSTEM_PROMPT,
133
- "--allowedTools",
134
- "Read,Grep,Bash"
135
- ],
136
- capture_output=True,
137
- text=True,
109
+ result = run_automation_prompt(
110
+ prompt,
111
+ model="opus",
138
112
  timeout=CLAUDE_TIMEOUT,
139
- env=env
113
+ output_format="text",
114
+ append_system_prompt=JSON_SYSTEM_PROMPT,
115
+ allowed_tools="Read,Grep,Bash",
140
116
  )
141
117
 
142
118
  if result.returncode != 0:
143
- print(f" Claude CLI error (exit {result.returncode}): {result.stderr[:300]}", file=sys.stderr)
119
+ print(f" Automation backend error (exit {result.returncode}): {result.stderr[:300]}", file=sys.stderr)
144
120
  return None
145
121
 
146
122
  # Filter out stop hook contamination (e.g. "Post-mortem completo.")
@@ -160,11 +136,12 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
160
136
  f"Required schema: session_id, findings[], emotional_timeline[], "
161
137
  f"abandoned_projects[], skill_candidates[], productivity_score, protocol_summary"
162
138
  )
163
- convert_result = subprocess.run(
164
- [claude_bin, "-p", convert_prompt, "--model", "sonnet",
165
- "--output-format", "text",
166
- "--append-system-prompt", JSON_SYSTEM_PROMPT],
167
- capture_output=True, text=True, timeout=120, env=env
139
+ convert_result = run_automation_prompt(
140
+ convert_prompt,
141
+ model="sonnet",
142
+ timeout=120,
143
+ output_format="text",
144
+ append_system_prompt=JSON_SYSTEM_PROMPT,
168
145
  )
169
146
  if convert_result.returncode == 0:
170
147
  parsed = extract_json_from_response(convert_result.stdout)
@@ -180,13 +157,12 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
180
157
 
181
158
  return parsed
182
159
 
160
+ except AutomationBackendUnavailableError as exc:
161
+ print(f" Automation backend unavailable: {exc}", file=sys.stderr)
162
+ return None
183
163
  except subprocess.TimeoutExpired:
184
- print(f" Claude CLI timeout ({CLAUDE_TIMEOUT}s)", file=sys.stderr)
164
+ print(f" Automation backend timeout ({CLAUDE_TIMEOUT}s)", file=sys.stderr)
185
165
  return None
186
- except FileNotFoundError:
187
- print(f" Claude CLI not found at: {claude_bin}", file=sys.stderr)
188
- print(" Install: npm install -g @anthropic-ai/claude-code", file=sys.stderr)
189
- sys.exit(1)
190
166
 
191
167
 
192
168
  def main():
@@ -235,9 +211,8 @@ def main():
235
211
  shared_context_file = None
236
212
  print("[extract] No shared context file")
237
213
 
238
- claude_bin = find_claude_cli()
239
214
  print(f"[extract] Phase 2: Analyzing {len(session_files)} sessions for {target_date}")
240
- print(f"[extract] Claude CLI: {claude_bin}")
215
+ print("[extract] Automation backend: schedule-configured")
241
216
 
242
217
  # Checkpoint directory: one JSON per session, survives crashes
243
218
  checkpoint_dir = date_dir / "checkpoints"
@@ -271,7 +246,7 @@ def main():
271
246
  # Retry loop
272
247
  result = None
273
248
  for attempt in range(1, MAX_RETRIES + 1):
274
- result = analyze_session(session_id, date_dir, shared_context_file, claude_bin)
249
+ result = analyze_session(session_id, date_dir, shared_context_file)
275
250
  if result:
276
251
  break
277
252
  if attempt < MAX_RETRIES:
@@ -12,7 +12,6 @@ Environment variables:
12
12
  """
13
13
  import json
14
14
  import os
15
- import shutil
16
15
  import subprocess
17
16
  import sys
18
17
  from datetime import datetime
@@ -26,22 +25,9 @@ PROMPT_FILE = Path(__file__).parent / "synthesize-prompt.md"
26
25
  if str(NEXO_CODE) not in sys.path:
27
26
  sys.path.insert(0, str(NEXO_CODE))
28
27
 
29
- CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
30
-
28
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
31
29
 
32
- def find_claude_cli() -> str:
33
- """Find the Claude CLI binary."""
34
- candidates = [
35
- Path.home() / ".local" / "bin" / "claude",
36
- Path("/usr/local/bin/claude"),
37
- ]
38
- for c in candidates:
39
- if c.exists():
40
- return str(c)
41
- which = shutil.which("claude")
42
- if which:
43
- return which
44
- return "claude"
30
+ CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
45
31
 
46
32
 
47
33
  def extract_json_from_response(text: str) -> dict | None:
@@ -144,34 +130,21 @@ def main():
144
130
  prompt = prompt.replace("{{CONTEXT_FILE}}", str(context_file))
145
131
  prompt = prompt.replace("{{SKILL_RUNTIME_FILE}}", str(runtime_candidates_file))
146
132
 
147
- claude_bin = find_claude_cli()
148
133
  print(f"[synthesize] Phase 3: Synthesizing {total_findings} findings from {target_date}")
149
134
  print(f"[synthesize] Skill runtime candidates: {runtime_candidate_count}")
150
- print(f"[synthesize] Claude CLI: {claude_bin}")
135
+ print("[synthesize] Automation backend: schedule-configured")
151
136
 
152
137
  try:
153
- env = os.environ.copy()
154
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
155
- env.pop("CLAUDECODE", None)
156
- env.pop("CLAUDE_CODE", None)
157
-
158
- result = subprocess.run(
159
- [
160
- claude_bin,
161
- "-p", prompt,
162
- "--model", "opus",
163
- "--output-format", "text",
164
- "--allowedTools",
165
- "Read,Grep,Bash"
166
- ],
167
- capture_output=True,
168
- text=True,
138
+ result = run_automation_prompt(
139
+ prompt,
140
+ model="opus",
169
141
  timeout=CLAUDE_TIMEOUT,
170
- env=env
142
+ output_format="text",
143
+ allowed_tools="Read,Grep,Bash",
171
144
  )
172
145
 
173
146
  if result.returncode != 0:
174
- print(f"[synthesize] Claude CLI error (exit {result.returncode}): {result.stderr[:300]}", file=sys.stderr)
147
+ print(f"[synthesize] Automation backend error (exit {result.returncode}): {result.stderr[:300]}", file=sys.stderr)
175
148
  sys.exit(1)
176
149
 
177
150
  # Filter hook contamination
@@ -218,11 +191,14 @@ def main():
218
191
  print(f" Context packets: {n_packets}")
219
192
  print(f"[synthesize] Output: {output_file}")
220
193
 
194
+ except AutomationBackendUnavailableError as exc:
195
+ print(f"[synthesize] Automation backend unavailable: {exc}", file=sys.stderr)
196
+ sys.exit(1)
221
197
  except subprocess.TimeoutExpired:
222
- print(f"[synthesize] Claude CLI timeout ({CLAUDE_TIMEOUT}s)", file=sys.stderr)
198
+ print(f"[synthesize] Automation backend timeout ({CLAUDE_TIMEOUT}s)", file=sys.stderr)
223
199
  sys.exit(1)
224
200
  except FileNotFoundError:
225
- print(f"[synthesize] Claude CLI not found at: {claude_bin}", file=sys.stderr)
201
+ print("[synthesize] Automation backend binary not found.", file=sys.stderr)
226
202
  sys.exit(1)
227
203
 
228
204
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Small CLI wrapper around the schedule-configured automation backend."""
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ _SCRIPT_DIR = Path(__file__).resolve().parent
13
+ _DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
14
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
15
+ if str(NEXO_CODE) not in sys.path:
16
+ sys.path.insert(0, str(NEXO_CODE))
17
+
18
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
19
+
20
+
21
+ def _read_text(path: str | None) -> str:
22
+ if not path:
23
+ return ""
24
+ return Path(path).expanduser().read_text()
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ parser = argparse.ArgumentParser(description="Run a prompt through the configured NEXO automation backend.")
29
+ parser.add_argument("--prompt", default="", help="Prompt text")
30
+ parser.add_argument("--prompt-file", default="", help="Read prompt text from a file")
31
+ parser.add_argument("--cwd", default="", help="Working directory for the backend")
32
+ parser.add_argument("--model", default="", help="Backend model hint")
33
+ parser.add_argument("--reasoning-effort", default="", help="Backend reasoning effort/profile")
34
+ parser.add_argument("--timeout", type=int, default=21600, help="Timeout in seconds")
35
+ parser.add_argument("--output-format", default="text", help="Requested output format")
36
+ parser.add_argument("--allowed-tools", default="", help="Claude-style allowed tools contract")
37
+ parser.add_argument("--append-system-prompt", default="", help="Extra system prompt text")
38
+ parser.add_argument("--append-system-prompt-file", default="", help="Read extra system prompt from a file")
39
+ args = parser.parse_args(argv)
40
+
41
+ prompt = args.prompt or _read_text(args.prompt_file)
42
+ if not prompt:
43
+ prompt = sys.stdin.read()
44
+ if not prompt.strip():
45
+ print("No prompt provided.", file=sys.stderr)
46
+ return 1
47
+
48
+ append_system_prompt = args.append_system_prompt or _read_text(args.append_system_prompt_file)
49
+
50
+ try:
51
+ result = run_automation_prompt(
52
+ prompt,
53
+ cwd=args.cwd or None,
54
+ model=args.model,
55
+ reasoning_effort=args.reasoning_effort,
56
+ timeout=args.timeout,
57
+ output_format=args.output_format,
58
+ append_system_prompt=append_system_prompt,
59
+ allowed_tools=args.allowed_tools,
60
+ )
61
+ except AutomationBackendUnavailableError as exc:
62
+ print(str(exc), file=sys.stderr)
63
+ return 2
64
+
65
+ if result.stdout:
66
+ print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
67
+ if result.stderr:
68
+ print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n")
69
+ return int(result.returncode)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())