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
|
@@ -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()
|
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
92
|
-
"""Send a session to
|
|
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
|
-
|
|
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 =
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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"
|
|
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 =
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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"
|
|
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(
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
28
|
+
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
31
29
|
|
|
32
|
-
|
|
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(
|
|
135
|
+
print("[synthesize] Automation backend: schedule-configured")
|
|
151
136
|
|
|
152
137
|
try:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
142
|
+
output_format="text",
|
|
143
|
+
allowed_tools="Read,Grep,Bash",
|
|
171
144
|
)
|
|
172
145
|
|
|
173
146
|
if result.returncode != 0:
|
|
174
|
-
print(f"[synthesize]
|
|
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]
|
|
198
|
+
print(f"[synthesize] Automation backend timeout ({CLAUDE_TIMEOUT}s)", file=sys.stderr)
|
|
223
199
|
sys.exit(1)
|
|
224
200
|
except FileNotFoundError:
|
|
225
|
-
print(
|
|
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())
|