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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -12
- package/bin/nexo-brain.js +483 -56
- package/package.json +4 -1
- package/src/agent_runner.py +322 -0
- package/src/auto_update.py +12 -3
- package/src/cli.py +22 -10
- package/src/client_preferences.py +394 -0
- package/src/client_sync.py +78 -0
- package/src/cron_recovery.py +8 -1
- package/src/crons/manifest.json +6 -0
- package/src/crons/sync.py +14 -1
- package/src/doctor/providers/runtime.py +109 -1
- package/src/plugins/schedule.py +69 -12
- package/src/plugins/update.py +5 -1
- package/src/runtime_power.py +23 -0
- package/src/script_registry.py +62 -1
- package/src/scripts/check-context.py +102 -100
- package/src/scripts/deep-sleep/extract.py +29 -54
- package/src/scripts/deep-sleep/synthesize.py +14 -38
- package/src/scripts/nexo-agent-run.py +73 -0
- package/src/scripts/nexo-catchup.py +15 -19
- package/src/scripts/nexo-daily-self-audit.py +17 -14
- package/src/scripts/nexo-evolution-run.py +25 -55
- package/src/scripts/nexo-immune.py +17 -15
- package/src/scripts/nexo-learning-validator.py +90 -58
- package/src/scripts/nexo-postmortem-consolidator.py +15 -14
- package/src/scripts/nexo-sleep.py +20 -14
- package/src/scripts/nexo-synthesis.py +19 -12
- package/src/scripts/nexo-update.sh +28 -2
- package/src/scripts/nexo-watchdog.sh +34 -10
- package/templates/nexo_helper.py +45 -0
- package/templates/plugin-template.py +4 -0
- package/templates/script-template.py +13 -2
- package/templates/skill-script-template.py +8 -0
|
@@ -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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 (
|
|
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 (
|
|
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 =
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
# ──
|
|
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
|
-
# ──
|
|
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
|
|
206
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
903
|
+
# Verify the configured automation backend is available before calling
|
|
934
904
|
if not verify_claude_cli():
|
|
935
|
-
log("
|
|
905
|
+
log("Automation backend not available or not authenticated. Skipping evolution run.")
|
|
936
906
|
return
|
|
937
907
|
|
|
938
|
-
# Call
|
|
939
|
-
log("Calling
|
|
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"
|
|
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
|
|
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 =
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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-
|
|
3
|
+
NEXO Learning Validator — Cross-check findings against existing learnings.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 =
|
|
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(
|
|
218
|
-
for
|
|
219
|
-
cat =
|
|
220
|
-
print(f" #{
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|