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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -11
- 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/update.py +5 -1
- package/src/runtime_power.py +23 -0
- 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
|
@@ -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
|
|
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),
|
package/src/plugins/update.py
CHANGED
|
@@ -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:
|
|
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)
|
package/src/runtime_power.py
CHANGED
|
@@ -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,
|
|
6
|
-
|
|
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 /
|
|
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() /
|
|
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 /
|
|
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=
|
|
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
|
-
|
|
62
|
+
"/var/www/vhosts",
|
|
58
63
|
str(NEXO_HOME),
|
|
59
|
-
|
|
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 /
|
|
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
|
|
85
|
-
actions = json.load(
|
|
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][
|
|
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 /
|
|
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
|
|
107
|
-
actions = json.load(
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
"type": action_type,
|
|
109
|
+
"identifier": identifier,
|
|
110
|
+
"timestamp": datetime.now().isoformat(),
|
|
111
|
+
"metadata": metadata or {},
|
|
116
112
|
}
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
157
|
+
for key, value in all_actions.items():
|
|
141
158
|
try:
|
|
142
|
-
ts = datetime.fromisoformat(
|
|
159
|
+
ts = datetime.fromisoformat(value["timestamp"]).timestamp()
|
|
143
160
|
if ts > cutoff:
|
|
144
|
-
recent_actions[
|
|
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
|
|
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
|
|
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 =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
if
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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": "
|
|
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
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
sys.exit(0)
|
|
240
|
+
print("NOT_FOUND")
|
|
241
|
+
sys.exit(0)
|
|
236
242
|
|
|
237
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
sys.exit(0)
|
|
251
|
+
print("NOT_DONE")
|
|
252
|
+
sys.exit(0)
|
|
248
253
|
|
|
249
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
print(f"Unknown command: {command}")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
262
264
|
|
|
263
|
-
if __name__ ==
|
|
265
|
+
if __name__ == "__main__":
|
|
264
266
|
main()
|