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.
@@ -19,6 +19,7 @@ _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
19
19
  if str(_runtime_root) not in sys.path:
20
20
  sys.path.insert(0, str(_runtime_root))
21
21
 
22
+ from agent_runner import AutomationBackendUnavailableError, probe_automation_backend, run_automation_prompt
22
23
  from cron_recovery import catchup_candidates
23
24
 
24
25
  HOME = Path.home()
@@ -237,16 +238,12 @@ def main():
237
238
 
238
239
  def _cli_post_catchup_assessment(ran: int, skipped: int, state: dict):
239
240
  """When 3+ tasks were missed, use CLI to assess if there are concerns."""
240
- if not CLAUDE_CLI.exists():
241
- log(f"Caught up {ran} tasks, {skipped} already current. (CLI unavailable for assessment)")
242
- return
243
- auth_check = subprocess.run(
244
- [str(CLAUDE_CLI), "-p", "reply OK", "--output-format", "text"],
245
- capture_output=True, text=True, timeout=30
246
- )
247
- if auth_check.returncode != 0:
248
- # CLI not authenticated, skip gracefully
249
- print(f"[{datetime.now().strftime('%H:%M:%S')}] Claude CLI not authenticated. Skipping CLI analysis.")
241
+ probe = probe_automation_backend(timeout=30)
242
+ if not probe.get("ok"):
243
+ print(
244
+ f"[{datetime.now().strftime('%H:%M:%S')}] "
245
+ f"Automation backend unavailable. Skipping CLI analysis. ({probe.get('reason') or probe.get('stderr') or 'not ready'})"
246
+ )
250
247
  return
251
248
 
252
249
  assessment_file = LOG_DIR / "catchup-assessment.md"
@@ -273,21 +270,20 @@ Format:
273
270
  - Recommendation: ..."""
274
271
 
275
272
  log(f"Caught up {ran} tasks — running CLI assessment...")
276
- env = os.environ.copy()
277
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
278
- env.pop("CLAUDECODE", None)
279
- env.pop("CLAUDE_CODE", None)
280
-
281
273
  try:
282
- result = subprocess.run(
283
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus", "--output-format", "text",
284
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
285
- capture_output=True, text=True, timeout=21600, env=env
274
+ result = run_automation_prompt(
275
+ prompt,
276
+ model="opus",
277
+ timeout=21600,
278
+ output_format="text",
279
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
286
280
  )
287
281
  if result.returncode == 0:
288
282
  log(f"Assessment written to {assessment_file}")
289
283
  else:
290
284
  log(f"CLI assessment exited {result.returncode}")
285
+ except AutomationBackendUnavailableError as e:
286
+ log(f"CLI assessment skipped: {e}")
291
287
  except subprocess.TimeoutExpired:
292
288
  log("CLI assessment timed out (90s)")
293
289
  except Exception as e:
@@ -6,7 +6,7 @@ Stage A — Mechanical checks (Python pure, unchanged):
6
6
  18 checks: overdue reminders, disk space, DB size, stale sessions, guard stats,
7
7
  cognitive health, snapshot drift, etc. All pure queries, no intelligence needed.
8
8
 
9
- Stage B — Interpretation (Claude CLI opus):
9
+ Stage B — Interpretation (automation backend):
10
10
  Takes the raw findings from Stage A and UNDERSTANDS them:
11
11
  - Groups related findings
12
12
  - Identifies root causes
@@ -30,6 +30,10 @@ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
30
30
  _script_dir = Path(__file__).resolve().parent
31
31
  _repo_src = _script_dir.parent # src/scripts/ -> src/
32
32
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
33
+ if str(NEXO_CODE) not in sys.path:
34
+ sys.path.insert(0, str(NEXO_CODE))
35
+
36
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
33
37
 
34
38
  LOG_DIR = NEXO_HOME / "logs"
35
39
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -432,7 +436,7 @@ def check_cognitive_health():
432
436
 
433
437
 
434
438
  # ═══════════════════════════════════════════════════════════════════════════════
435
- # Stage B: Interpretation (Claude CLI opus) — NEW in v2
439
+ # Stage B: Interpretation (automation backend) — NEW in v2
436
440
  # ═══════════════════════════════════════════════════════════════════════════════
437
441
 
438
442
  def interpret_findings(raw_findings: list) -> bool:
@@ -479,20 +483,16 @@ via sqlite3 nexo.db 'UPDATE...'" is useful.
479
483
 
480
484
  Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
481
485
 
482
- Execute without asking."""
483
-
484
- log("Stage B: Invoking Claude CLI (opus) for interpretation...")
485
- env = os.environ.copy()
486
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
487
- env.pop("CLAUDECODE", None)
488
- env.pop("CLAUDE_CODE", None)
486
+ Execute without asking."""
489
487
 
488
+ log("Stage B: Invoking automation backend for interpretation...")
490
489
  try:
491
- result = subprocess.run(
492
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
493
- "--output-format", "text",
494
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
495
- capture_output=True, text=True, timeout=21600, env=env
490
+ result = run_automation_prompt(
491
+ prompt,
492
+ model="opus",
493
+ timeout=21600,
494
+ output_format="text",
495
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
496
496
  )
497
497
 
498
498
  if result.returncode != 0:
@@ -502,6 +502,9 @@ Execute without asking."""
502
502
  log(f"Stage B: Interpretation complete ({len(result.stdout or '')} chars)")
503
503
  return True
504
504
 
505
+ except AutomationBackendUnavailableError as e:
506
+ log(f"Stage B: automation backend unavailable: {e}")
507
+ return False
505
508
  except subprocess.TimeoutExpired:
506
509
  log("Stage B: CLI timed out")
507
510
  return False
@@ -122,7 +122,7 @@ def _immutable_files_for_mode(mode: str) -> set[str]:
122
122
  return set(GLOBAL_IMMUTABLE_FILES)
123
123
  return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
124
124
 
125
- # ── Claude CLI path ──────────────────────────────────────────────────────
125
+ # ── Automation backend pathing ───────────────────────────────────────────
126
126
  def _resolve_claude_cli() -> Path:
127
127
  """Find claude CLI: saved path > PATH > common locations."""
128
128
  import shutil as _shutil
@@ -169,6 +169,7 @@ def log(msg: str):
169
169
 
170
170
  # ── Import from evolution_cycle.py (lives in NEXO_CODE, i.e. src/) ──────
171
171
  sys.path.insert(0, str(NEXO_CODE))
172
+ from agent_runner import probe_automation_backend, run_automation_prompt
172
173
  from evolution_cycle import (
173
174
  load_objective, save_objective, get_week_data, build_evolution_prompt,
174
175
  dry_run_restore_test, max_auto_changes, create_snapshot, build_public_contribution_prompt
@@ -197,39 +198,23 @@ def set_consecutive_failures(count: int):
197
198
  save_objective(obj)
198
199
 
199
200
 
200
- # ── Claude CLI call ──────────────────────────────────────────────────────
201
+ # ── Automation backend call ──────────────────────────────────────────────
201
202
  CLI_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
202
203
 
203
204
 
204
205
  def verify_claude_cli() -> bool:
205
- """Check Claude CLI is available and authenticated."""
206
- if not CLAUDE_CLI.exists():
207
- return False
208
- try:
209
- result = subprocess.run(
210
- [str(CLAUDE_CLI), "-p", "reply OK", "--output-format", "text"],
211
- capture_output=True, text=True, timeout=30
212
- )
213
- return result.returncode == 0
214
- except Exception:
215
- return False
206
+ """Check the configured automation backend is available and authenticated."""
207
+ return bool(probe_automation_backend(timeout=30).get("ok"))
216
208
 
217
209
 
218
210
  def call_claude_cli(prompt: str) -> str:
219
- """Call claude -p prompt --model opus via subprocess. Returns stdout text."""
220
- env = os.environ.copy()
221
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
222
- env.pop("CLAUDECODE", None)
223
- env.pop("CLAUDE_CODE", None)
224
-
225
- result = subprocess.run(
226
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
227
- "--output-format", "text",
228
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
229
- capture_output=True,
230
- text=True,
211
+ """Call the configured automation backend for the managed evolution prompt."""
212
+ result = run_automation_prompt(
213
+ prompt,
214
+ model="opus",
231
215
  timeout=CLI_TIMEOUT,
232
- env=env,
216
+ output_format="text",
217
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
233
218
  )
234
219
  if result.returncode != 0:
235
220
  raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
@@ -237,30 +222,15 @@ def call_claude_cli(prompt: str) -> str:
237
222
 
238
223
 
239
224
  def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
240
- """Run Claude CLI in an isolated public repo checkout."""
241
- env = os.environ.copy()
242
- env["NEXO_HEADLESS"] = "1"
243
- env["NEXO_PUBLIC_CONTRIBUTION"] = "1"
244
- env.pop("CLAUDECODE", None)
245
- env.pop("CLAUDE_CODE", None)
246
-
247
- result = subprocess.run(
248
- [
249
- str(CLAUDE_CLI),
250
- "-p",
251
- prompt,
252
- "--model",
253
- "opus",
254
- "--output-format",
255
- "text",
256
- "--allowedTools",
257
- "Read,Write,Edit,Glob,Grep,Bash",
258
- ],
259
- cwd=str(cwd),
260
- capture_output=True,
261
- text=True,
225
+ """Run the configured automation backend in an isolated public repo checkout."""
226
+ result = run_automation_prompt(
227
+ prompt,
228
+ cwd=cwd,
229
+ env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
230
+ model="opus",
262
231
  timeout=CLI_TIMEOUT,
263
- env=env,
232
+ output_format="text",
233
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
264
234
  )
265
235
  if result.returncode != 0:
266
236
  raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
@@ -514,7 +484,7 @@ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
514
484
  return
515
485
 
516
486
  if not verify_claude_cli():
517
- log("Claude CLI not available or not authenticated. Skipping public contribution run.")
487
+ log("Automation backend not available or not authenticated. Skipping public contribution run.")
518
488
  mark_public_contribution_result(result="skipped:claude_cli_unavailable", config=config)
519
489
  return
520
490
 
@@ -930,17 +900,17 @@ def run():
930
900
  prompt = build_evolution_prompt(week_data, objective)
931
901
  log(f"Prompt built: {len(prompt)} chars")
932
902
 
933
- # Verify Claude CLI is authenticated before calling
903
+ # Verify the configured automation backend is available before calling
934
904
  if not verify_claude_cli():
935
- log("Claude CLI not available or not authenticated. Skipping evolution run.")
905
+ log("Automation backend not available or not authenticated. Skipping evolution run.")
936
906
  return
937
907
 
938
- # Call Opus via claude -p
939
- log("Calling claude -p --model opus...")
908
+ # Call the configured automation backend with the legacy opus task profile
909
+ log("Calling automation backend with the opus task profile...")
940
910
  try:
941
911
  raw_response = call_claude_cli(prompt)
942
912
  except Exception as e:
943
- log(f"claude CLI call failed: {e}")
913
+ log(f"Automation backend call failed: {e}")
944
914
  set_consecutive_failures(failures + 1)
945
915
  return
946
916
 
@@ -24,6 +24,14 @@ from datetime import datetime, date, timedelta
24
24
  from pathlib import Path
25
25
 
26
26
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
+ _script_dir = Path(__file__).resolve().parent
28
+ _repo_src = _script_dir.parent
29
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
30
+ if str(NEXO_CODE) not in sys.path:
31
+ sys.path.insert(0, str(NEXO_CODE))
32
+
33
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
34
+
27
35
  from urllib.request import Request, urlopen
28
36
  from urllib.error import URLError, HTTPError
29
37
 
@@ -855,11 +863,7 @@ def _run_checks(lock_fd):
855
863
 
856
864
 
857
865
  def _run_cli_triage(all_results: dict, repairs: list, counts: dict):
858
- """Pass all findings to Claude CLI for intelligent triage and recommendations."""
859
- if not CLAUDE_CLI.exists():
860
- print("[SKIP] Claude CLI not found, skipping triage")
861
- return
862
-
866
+ """Pass all findings to the configured automation backend for intelligent triage and recommendations."""
863
867
  triage_file = COORD_DIR / "immune-triage.md"
864
868
  findings_json = json.dumps({
865
869
  "timestamp": NOW.strftime("%Y-%m-%d %H:%M"),
@@ -901,22 +905,20 @@ Raw findings:
901
905
  Write the report. Be concise — max 40 lines."""
902
906
 
903
907
  print("\n[TRIAGE] Running CLI interpretation...")
904
- env = os.environ.copy()
905
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
906
- env.pop("CLAUDECODE", None)
907
- env.pop("CLAUDE_CODE", None)
908
-
909
908
  try:
910
- result = subprocess.run(
911
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
912
- "--output-format", "text",
913
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
914
- capture_output=True, text=True, timeout=21600, env=env
909
+ result = run_automation_prompt(
910
+ prompt,
911
+ model="opus",
912
+ timeout=21600,
913
+ output_format="text",
914
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
915
915
  )
916
916
  if result.returncode == 0:
917
917
  print(f"[TRIAGE] Report written to {triage_file}")
918
918
  else:
919
919
  print(f"[TRIAGE] CLI exited {result.returncode}: {result.stderr[:200]}")
920
+ except AutomationBackendUnavailableError as e:
921
+ print(f"[TRIAGE] Skipping triage: {e}")
920
922
  except subprocess.TimeoutExpired:
921
923
  print("[TRIAGE] CLI timed out (120s)")
922
924
  except Exception as e:
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- NEXO Learning Validator — Cross-checks findings against existing learnings.
3
+ NEXO Learning Validator — Cross-check findings against existing learnings.
4
4
 
5
- Wrapper collects the finding + all learnings from SQLite, then passes
6
- to Claude CLI (opus) to make an intelligent determination of whether
7
- the finding is known, related, or genuinely new.
5
+ The wrapper collects the finding + current learnings from SQLite, then asks the
6
+ configured automation backend whether the finding is already known, related, or
7
+ genuinely new. If the backend is unavailable, it falls back to mechanical
8
+ similarity matching.
8
9
 
9
10
  Usage as CLI:
10
11
  python3 nexo-learning-validator.py "finding text to validate"
@@ -21,27 +22,36 @@ Exit codes:
21
22
  1 = Finding is KNOWN (matches existing learning)
22
23
  """
23
24
 
25
+ from __future__ import annotations
26
+
24
27
  import json
25
28
  import os
26
29
  import sqlite3
27
- import subprocess
28
30
  import sys
29
31
  from pathlib import Path
30
32
 
31
33
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
34
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
35
+ if str(NEXO_CODE) not in sys.path:
36
+ sys.path.insert(0, str(NEXO_CODE))
37
+
38
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
39
+
32
40
 
33
41
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
34
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
42
+ JSON_ONLY_SYSTEM_PROMPT = (
43
+ "Return exactly one valid JSON object. No markdown fences. No prose outside JSON."
44
+ )
35
45
 
36
46
 
37
- def get_all_learnings(category: str = None) -> list[dict]:
47
+ def get_all_learnings(category: str | None = None) -> list[dict]:
38
48
  """Fetch all learnings from nexo.db."""
39
49
  conn = sqlite3.connect(str(NEXO_DB), timeout=10)
40
50
  conn.row_factory = sqlite3.Row
41
51
  if category:
42
52
  rows = conn.execute(
43
53
  "SELECT id, category, title, content FROM learnings WHERE category = ?",
44
- (category,)
54
+ (category,),
45
55
  ).fetchall()
46
56
  else:
47
57
  rows = conn.execute(
@@ -51,9 +61,38 @@ def get_all_learnings(category: str = None) -> list[dict]:
51
61
  return [dict(r) for r in rows]
52
62
 
53
63
 
54
- def validate_finding(finding: str, category: str = None) -> dict:
64
+ def _extract_json(text: str) -> dict | None:
65
+ text = (text or "").strip()
66
+ if not text:
67
+ return None
68
+ if text.startswith("```"):
69
+ lines = text.splitlines()
70
+ end = len(lines)
71
+ for idx in range(len(lines) - 1, 0, -1):
72
+ if lines[idx].strip() == "```":
73
+ end = idx
74
+ break
75
+ text = "\n".join(lines[1:end]).strip()
76
+ brace_start = text.find("{")
77
+ if brace_start < 0:
78
+ return None
79
+ depth = 0
80
+ for idx in range(brace_start, len(text)):
81
+ if text[idx] == "{":
82
+ depth += 1
83
+ elif text[idx] == "}":
84
+ depth -= 1
85
+ if depth == 0:
86
+ try:
87
+ return json.loads(text[brace_start:idx + 1])
88
+ except json.JSONDecodeError:
89
+ return None
90
+ return None
91
+
92
+
93
+ def validate_finding(finding: str, category: str | None = None) -> dict:
55
94
  """
56
- Validate a finding against existing learnings using Claude CLI.
95
+ Validate a finding against existing learnings.
57
96
 
58
97
  Returns:
59
98
  {
@@ -70,18 +109,18 @@ def validate_finding(finding: str, category: str = None) -> dict:
70
109
  "known": False,
71
110
  "confidence": 0,
72
111
  "matching_learnings": [],
73
- "recommendation": "No learnings in DB — finding is new by default"
112
+ "recommendation": "No learnings in DB — finding is new by default",
74
113
  }
75
114
 
76
- # Build compact learnings reference for CLI
77
- learnings_ref = []
78
- for l in learnings:
79
- learnings_ref.append({
115
+ learnings_ref = [
116
+ {
80
117
  "id": l["id"],
81
118
  "cat": l["category"],
82
119
  "title": l["title"],
83
120
  "content": (l["content"] or "")[:300],
84
- })
121
+ }
122
+ for l in learnings
123
+ ]
85
124
 
86
125
  prompt = f"""You are a finding deduplication engine. Compare a new finding against existing learnings and determine if it's already known.
87
126
 
@@ -89,9 +128,9 @@ NEW FINDING:
89
128
  {finding}
90
129
 
91
130
  EXISTING LEARNINGS ({len(learnings_ref)} total):
92
- {json.dumps(learnings_ref, indent=1)}
131
+ {json.dumps(learnings_ref, indent=1, ensure_ascii=False)}
93
132
 
94
- Respond with ONLY valid JSON (no markdown, no code fences):
133
+ Respond with ONLY valid JSON:
95
134
  {{
96
135
  "known": true/false,
97
136
  "confidence": 0.0-1.0,
@@ -109,33 +148,27 @@ Rules:
109
148
  - If the finding describes the SAME bug/issue/pattern as a learning, it's known even if worded differently
110
149
  - Be strict: different symptoms of different bugs are NOT the same even if they mention the same file"""
111
150
 
112
- # Try CLI first, fall back to mechanical similarity
113
- if CLAUDE_CLI.exists():
114
- try:
115
- env = os.environ.copy()
116
- env["NEXO_HEADLESS"] = "1"
117
- env.pop("CLAUDECODE", None)
118
- env.pop("CLAUDE_CODE", None)
119
- learnings_text = "\n".join(
120
- f"[#{l.get('id','')}] {l.get('title','')}: {l.get('content','')[:200]}"
121
- for l in learnings[:20]
122
- )
123
- prompt = f"{VALIDATE_PROMPT}\n\nFinding:\n{finding}\n\nExisting learnings:\n{learnings_text}"
124
- result = subprocess.run(
125
- [str(CLAUDE_CLI), "-p", prompt, "--model", "sonnet", "--output-format", "text"],
126
- capture_output=True, text=True, timeout=60, env=env
127
- )
128
- if result.returncode == 0 and result.stdout.strip():
129
- parsed = json.loads(result.stdout.strip())
130
- return parsed
131
- except Exception:
132
- pass
133
- # Fallback: mechanical SequenceMatcher (original logic)
151
+ try:
152
+ result = run_automation_prompt(
153
+ prompt,
154
+ model="sonnet",
155
+ timeout=60,
156
+ output_format="text",
157
+ append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
158
+ )
159
+ parsed = _extract_json(result.stdout)
160
+ if result.returncode == 0 and parsed:
161
+ return parsed
162
+ except AutomationBackendUnavailableError:
163
+ pass
164
+ except Exception:
165
+ pass
166
+
134
167
  return _mechanical_validate(finding, learnings)
135
168
 
136
169
 
137
170
  def _mechanical_validate(finding: str, learnings: list[dict]) -> dict:
138
- """Fallback validation using SequenceMatcher when CLI is unavailable."""
171
+ """Fallback validation using SequenceMatcher when backend is unavailable."""
139
172
  from difflib import SequenceMatcher
140
173
 
141
174
  threshold = 0.45
@@ -170,28 +203,27 @@ def _mechanical_validate(finding: str, learnings: list[dict]) -> dict:
170
203
  if best >= 0.7:
171
204
  return {"known": True, "confidence": best, "matching_learnings": top,
172
205
  "recommendation": f"KNOWN issue (learning #{top[0]['id']})"}
173
- elif best >= 0.55:
206
+ if best >= 0.55:
174
207
  return {"known": True, "confidence": best, "matching_learnings": top,
175
208
  "recommendation": f"LIKELY KNOWN (learning #{top[0]['id']})"}
176
- else:
177
- return {"known": False, "confidence": best, "matching_learnings": top,
178
- "recommendation": "POSSIBLY RELATED but different enough to report"}
209
+ return {"known": False, "confidence": best, "matching_learnings": top,
210
+ "recommendation": "POSSIBLY RELATED but different enough to report"}
179
211
 
180
212
 
181
213
  def _extract_keywords(text: str) -> set:
182
214
  """Extract meaningful keywords from text."""
183
215
  stop_words = {
184
- 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
185
- 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
186
- 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare',
187
- 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',
188
- 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either',
189
- 'error', 'critical', 'warning', 'bug', 'issue', 'problem', 'fix',
190
- 'el', 'la', 'los', 'las', 'un', 'una', 'de', 'en', 'que', 'por',
216
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
217
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
218
+ "should", "may", "might", "must", "shall", "can", "need", "dare",
219
+ "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
220
+ "and", "but", "or", "nor", "not", "so", "yet", "both", "either",
221
+ "error", "critical", "warning", "bug", "issue", "problem", "fix",
222
+ "el", "la", "los", "las", "un", "una", "de", "en", "que", "por",
191
223
  }
192
224
  words = set()
193
225
  for word in text.lower().split():
194
- clean = ''.join(c for c in word if c.isalnum() or c == '_')
226
+ clean = "".join(c for c in word if c.isalnum() or c == "_")
195
227
  if clean and len(clean) > 2 and clean not in stop_words:
196
228
  words.add(clean)
197
229
  return words
@@ -208,16 +240,16 @@ def main():
208
240
  result = validate_finding(args.finding, args.category)
209
241
 
210
242
  if args.json:
211
- print(json.dumps(result, indent=2))
243
+ print(json.dumps(result, indent=2, ensure_ascii=False))
212
244
  else:
213
245
  status = "KNOWN" if result["known"] else "NEW"
214
246
  print(f"Status: {status} (confidence: {result['confidence']:.0%})")
215
247
  print(f"Recommendation: {result['recommendation']}")
216
248
  if result["matching_learnings"]:
217
- print(f"Related learnings:")
218
- for m in result["matching_learnings"]:
219
- cat = m.get('category', '?')
220
- print(f" #{m['id']} [{cat}] {m['title']} ({m['similarity']:.0%})")
249
+ print("Related learnings:")
250
+ for match in result["matching_learnings"]:
251
+ cat = match.get("category", "?")
252
+ print(f" #{match['id']} [{cat}] {match['title']} ({match['similarity']:.0%})")
221
253
 
222
254
  sys.exit(1 if result["known"] else 0)
223
255
 
@@ -6,12 +6,12 @@ Before: 595 lines of word-overlap at 50% to detect "patterns".
6
6
  Now: Collects data, passes them to CLI which UNDERSTANDS what it reads.
7
7
 
8
8
  Runs daily at 23:30 via LaunchAgent. Reads session diaries from today,
9
- passes them to Claude CLI (opus) which decides what deserves permanent memory.
9
+ passes them to the configured automation backend, which decides what deserves permanent memory.
10
10
 
11
11
  Stage 1 — Data collection (Pure Python):
12
12
  Query session diaries, existing feedbacks, history.
13
13
 
14
- Stage 2 — Intelligence (Claude CLI opus):
14
+ Stage 2 — Intelligence (automation backend):
15
15
  Read diaries, understand patterns, decide what to promote.
16
16
 
17
17
  Stage 3 — Sensory Register + Force analysis (Pure Python):
@@ -36,6 +36,8 @@ _repo_src = _script_dir.parent # src/scripts/ -> src/
36
36
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
37
37
  sys.path.insert(0, str(NEXO_CODE))
38
38
 
39
+ from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
40
+
39
41
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
40
42
  # Memory directory — adjust to match your project's memory location
41
43
  MEMORY_DIR = NEXO_HOME / "memory"
@@ -126,7 +128,7 @@ def collect_data() -> dict:
126
128
  return data
127
129
 
128
130
 
129
- # ─── Stage 2: Intelligence (Claude CLI opus) ────────────────────────────────
131
+ # ─── Stage 2: Intelligence (automation backend) ─────────────────────────────
130
132
 
131
133
  def consolidate_with_cli(data: dict) -> bool:
132
134
  """The brain consolidates — CLI decides what to promote."""
@@ -206,18 +208,14 @@ INSTRUCTIONS:
206
208
 
207
209
  Execute without asking."""
208
210
 
209
- log(f"Stage 2: Invoking Claude CLI (opus) with {len(diaries_with_critique)} critiques...")
210
- env = os.environ.copy()
211
- env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
212
- env.pop("CLAUDECODE", None)
213
- env.pop("CLAUDE_CODE", None)
214
-
211
+ log(f"Stage 2: Invoking automation backend with {len(diaries_with_critique)} critiques...")
215
212
  try:
216
- result = subprocess.run(
217
- [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
218
- "--output-format", "text",
219
- "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"],
220
- capture_output=True, text=True, timeout=21600, env=env
213
+ result = run_automation_prompt(
214
+ prompt,
215
+ model="opus",
216
+ timeout=21600,
217
+ output_format="text",
218
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
221
219
  )
222
220
 
223
221
  if result.returncode != 0:
@@ -230,6 +228,9 @@ Execute without asking."""
230
228
  log(f"Stage 2 output tail: {result.stdout[-500:]}")
231
229
  return True
232
230
 
231
+ except AutomationBackendUnavailableError as e:
232
+ log(f"Stage 2: automation backend unavailable: {e}")
233
+ return False
233
234
  except subprocess.TimeoutExpired:
234
235
  log("Stage 2: CLI timed out (300s)")
235
236
  return False