nexo-brain 2.5.0 → 2.6.0
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 +33 -0
- package/.mcp.json +12 -0
- package/README.md +48 -23
- package/bin/nexo-brain.js +65 -33
- package/hooks/hooks.json +14 -0
- package/package.json +15 -3
- package/src/auto_update.py +79 -2
- package/src/cli.py +490 -11
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +132 -27
- package/src/db/__init__.py +11 -0
- package/src/db/_personal_scripts.py +548 -0
- package/src/db/_schema.py +44 -1
- package/src/doctor/providers/runtime.py +272 -75
- package/src/evolution_cycle.py +90 -7
- package/src/nexo.db +0 -0
- package/src/plugins/evolution.py +9 -2
- package/src/plugins/personal_scripts.py +117 -0
- package/src/plugins/schedule.py +116 -27
- package/src/script_registry.py +877 -28
- package/src/scripts/nexo-catchup.py +74 -109
- package/src/scripts/nexo-evolution-run.py +178 -67
- package/src/scripts/nexo-watchdog.sh +242 -54
- package/src/tools_learnings.py +8 -0
- package/templates/launchagents/com.nexo.catchup.plist +7 -6
- package/templates/script-template.py +3 -0
- package/templates/script-template.sh +13 -0
- package/src/scripts/nexo-day-orchestrator.sh +0 -139
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
NEXO Catch-Up — Runs at boot/wake to recover any missed scheduled tasks.
|
|
4
|
-
|
|
5
|
-
Tasks are loaded dynamically from crons/manifest.json (single source of truth).
|
|
6
|
-
Only scheduled crons (with hour/minute) are recovered — interval-based crons
|
|
7
|
-
(immune, watchdog, auto-close) restart automatically via launchd/systemd.
|
|
2
|
+
"""NEXO Catch-Up — recover missed core cron windows after boot/wake.
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Uses cron/launchd weekday convention (0=Sunday) converted to Python (0=Monday).
|
|
4
|
+
Recovery is driven by the explicit manifest contract plus cron_runs.
|
|
5
|
+
Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
|
|
12
6
|
"""
|
|
13
7
|
|
|
8
|
+
import fcntl
|
|
14
9
|
import json
|
|
15
10
|
import os
|
|
16
11
|
import subprocess
|
|
17
12
|
import sys
|
|
18
|
-
from datetime import datetime
|
|
13
|
+
from datetime import datetime
|
|
19
14
|
from pathlib import Path
|
|
20
15
|
|
|
16
|
+
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
|
|
18
|
+
_runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
|
|
19
|
+
if str(_runtime_root) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_runtime_root))
|
|
21
|
+
|
|
22
|
+
from cron_recovery import catchup_candidates
|
|
23
|
+
|
|
21
24
|
HOME = Path.home()
|
|
22
25
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
|
|
23
26
|
|
|
@@ -48,8 +51,10 @@ LOG_DIR = NEXO_HOME / "logs"
|
|
|
48
51
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
52
|
LOG_FILE = LOG_DIR / "catchup.log"
|
|
50
53
|
STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
|
|
54
|
+
LOCK_FILE = NEXO_HOME / "operations" / ".catchup.lock"
|
|
51
55
|
|
|
52
56
|
SCRIPTS = NEXO_HOME / "scripts"
|
|
57
|
+
WRAPPER = SCRIPTS / "nexo-cron-wrapper.sh"
|
|
53
58
|
|
|
54
59
|
# Resolve Python: prefer NEXO's venv, then the same Python running this script
|
|
55
60
|
def _resolve_python() -> str:
|
|
@@ -68,52 +73,6 @@ def _resolve_python() -> str:
|
|
|
68
73
|
return sys.executable
|
|
69
74
|
|
|
70
75
|
NEXO_PYTHON = _resolve_python()
|
|
71
|
-
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
|
|
72
|
-
# Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
|
|
73
|
-
_manifest_home = NEXO_HOME / "crons" / "manifest.json"
|
|
74
|
-
_manifest_code = NEXO_CODE / "crons" / "manifest.json"
|
|
75
|
-
MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _load_tasks_from_manifest() -> list[tuple]:
|
|
79
|
-
"""Read scheduled tasks from manifest.json — single source of truth.
|
|
80
|
-
|
|
81
|
-
Only includes crons with a schedule (hour/minute). Excludes interval-based
|
|
82
|
-
crons (immune, watchdog, auto-close) and run_at_load (catchup itself).
|
|
83
|
-
Returns: list of (name, hour, minute, python_or_bash, script, weekday)
|
|
84
|
-
"""
|
|
85
|
-
if not MANIFEST.exists():
|
|
86
|
-
log(f"WARNING: manifest not found at {MANIFEST}, using empty task list")
|
|
87
|
-
return []
|
|
88
|
-
|
|
89
|
-
with open(MANIFEST) as f:
|
|
90
|
-
data = json.load(f)
|
|
91
|
-
|
|
92
|
-
tasks = []
|
|
93
|
-
for cron in data.get("crons", []):
|
|
94
|
-
schedule = cron.get("schedule")
|
|
95
|
-
if not schedule or "hour" not in schedule:
|
|
96
|
-
continue # Skip interval-based and run_at_load crons
|
|
97
|
-
if cron["id"] == "catchup":
|
|
98
|
-
continue # Don't catch up ourselves
|
|
99
|
-
|
|
100
|
-
script = cron["script"]
|
|
101
|
-
script_type = cron.get("type", "python")
|
|
102
|
-
interpreter = NEXO_PYTHON if script_type == "python" else "/bin/bash"
|
|
103
|
-
weekday = schedule.get("weekday")
|
|
104
|
-
|
|
105
|
-
tasks.append((
|
|
106
|
-
cron["id"],
|
|
107
|
-
schedule["hour"],
|
|
108
|
-
schedule["minute"],
|
|
109
|
-
interpreter,
|
|
110
|
-
Path(script).name,
|
|
111
|
-
weekday,
|
|
112
|
-
))
|
|
113
|
-
|
|
114
|
-
# Sort by hour, minute for correct execution order
|
|
115
|
-
tasks.sort(key=lambda t: (t[1], t[2]))
|
|
116
|
-
return tasks
|
|
117
76
|
|
|
118
77
|
|
|
119
78
|
def log(msg: str):
|
|
@@ -138,59 +97,48 @@ def save_state(state: dict):
|
|
|
138
97
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
139
98
|
|
|
140
99
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
# Python datetime.weekday() uses: 0=Monday, 6=Sunday
|
|
150
|
-
# Convert: manifest 0 (Sun) -> python 6, manifest 1 (Mon) -> python 0, etc.
|
|
151
|
-
py_weekday = (weekday - 1) % 7
|
|
152
|
-
days_since = (now.weekday() - py_weekday) % 7
|
|
153
|
-
target = now - timedelta(days=days_since)
|
|
154
|
-
target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
155
|
-
if target > now:
|
|
156
|
-
target -= timedelta(weeks=1)
|
|
157
|
-
return target
|
|
158
|
-
|
|
159
|
-
# Daily task
|
|
160
|
-
if today_at <= now:
|
|
161
|
-
return today_at
|
|
162
|
-
else:
|
|
163
|
-
return today_at - timedelta(days=1)
|
|
164
|
-
|
|
100
|
+
def _resolve_runtime_command(script_type: str) -> str:
|
|
101
|
+
if script_type == "shell":
|
|
102
|
+
return "/bin/bash"
|
|
103
|
+
if script_type == "node":
|
|
104
|
+
return "node"
|
|
105
|
+
if script_type == "php":
|
|
106
|
+
return "php"
|
|
107
|
+
return NEXO_PYTHON
|
|
165
108
|
|
|
166
|
-
def should_run(task_name: str, hour: int, minute: int, state: dict, weekday: int = None) -> bool:
|
|
167
|
-
"""Check if task needs catch-up: last run was before last scheduled time."""
|
|
168
|
-
last_run_str = state.get(task_name)
|
|
169
|
-
last_scheduled = last_scheduled_time(hour, minute, weekday)
|
|
170
|
-
|
|
171
|
-
if not last_run_str:
|
|
172
|
-
# Never ran — should run
|
|
173
|
-
return True
|
|
174
109
|
|
|
110
|
+
def _acquire_lock():
|
|
111
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
handle = LOCK_FILE.open("w")
|
|
175
113
|
try:
|
|
176
|
-
|
|
177
|
-
except
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
114
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
115
|
+
except BlockingIOError:
|
|
116
|
+
handle.close()
|
|
117
|
+
return None
|
|
118
|
+
handle.write(str(os.getpid()))
|
|
119
|
+
handle.flush()
|
|
120
|
+
return handle
|
|
181
121
|
|
|
182
122
|
|
|
183
|
-
def run_task(
|
|
123
|
+
def run_task(candidate: dict, state: dict) -> bool:
|
|
184
124
|
"""Execute a task and update state."""
|
|
185
|
-
|
|
125
|
+
name = candidate["cron_id"]
|
|
126
|
+
script_name = Path(candidate["script"]).name
|
|
127
|
+
script_path = str(SCRIPTS / script_name)
|
|
186
128
|
if not Path(script_path).exists():
|
|
187
129
|
log(f" SKIP {name}: script not found ({script_path})")
|
|
188
130
|
return False
|
|
189
131
|
|
|
190
|
-
|
|
132
|
+
runtime_cmd = _resolve_runtime_command(candidate.get("type", "python"))
|
|
133
|
+
if WRAPPER.exists():
|
|
134
|
+
command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, script_path]
|
|
135
|
+
else:
|
|
136
|
+
command = [runtime_cmd, script_path]
|
|
137
|
+
|
|
138
|
+
log(f" RUNNING {name}: {script_name}")
|
|
191
139
|
try:
|
|
192
140
|
result = subprocess.run(
|
|
193
|
-
|
|
141
|
+
command,
|
|
194
142
|
capture_output=True, text=True, timeout=21600,
|
|
195
143
|
env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
|
|
196
144
|
)
|
|
@@ -205,7 +153,7 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
|
|
|
205
153
|
log(f" stderr: {result.stderr[:300]}")
|
|
206
154
|
return False
|
|
207
155
|
except subprocess.TimeoutExpired:
|
|
208
|
-
log(f" TIMEOUT {name} (
|
|
156
|
+
log(f" TIMEOUT {name} (21600s)")
|
|
209
157
|
return False
|
|
210
158
|
except Exception as e:
|
|
211
159
|
log(f" ERROR {name}: {e}")
|
|
@@ -214,28 +162,45 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
|
|
|
214
162
|
|
|
215
163
|
def main():
|
|
216
164
|
log("=== NEXO Catch-Up starting (boot/wake) ===")
|
|
217
|
-
|
|
165
|
+
lock_handle = _acquire_lock()
|
|
166
|
+
if lock_handle is None:
|
|
167
|
+
log("Catch-Up already running; skipping overlapping invocation.")
|
|
168
|
+
return
|
|
218
169
|
|
|
219
|
-
|
|
220
|
-
tasks =
|
|
170
|
+
state = load_state()
|
|
171
|
+
tasks = catchup_candidates()
|
|
221
172
|
|
|
222
173
|
ran = 0
|
|
223
174
|
skipped = 0
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
175
|
+
skipped_out_of_window = 0
|
|
176
|
+
try:
|
|
177
|
+
for candidate in tasks:
|
|
178
|
+
name = candidate["cron_id"]
|
|
179
|
+
if not candidate.get("missed"):
|
|
180
|
+
skipped += 1
|
|
181
|
+
continue
|
|
182
|
+
if not candidate.get("within_window"):
|
|
183
|
+
skipped_out_of_window += 1
|
|
184
|
+
log(
|
|
185
|
+
f" SKIP {name}: missed window is {candidate['age_seconds']}s old "
|
|
186
|
+
f"(max_catchup_age={candidate['contract']['max_catchup_age']}s)"
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
due_at = candidate["last_due_at"].astimezone().strftime("%Y-%m-%d %H:%M")
|
|
190
|
+
log(f" {name} — missed scheduled run due at {due_at}, catching up...")
|
|
191
|
+
if run_task(candidate, state):
|
|
228
192
|
ran += 1
|
|
229
|
-
|
|
230
|
-
|
|
193
|
+
finally:
|
|
194
|
+
lock_handle.close()
|
|
231
195
|
|
|
232
|
-
if ran == 0:
|
|
196
|
+
if ran == 0 and skipped_out_of_window == 0:
|
|
233
197
|
log("All tasks up to date, nothing to catch up.")
|
|
234
198
|
elif ran >= 3:
|
|
235
199
|
# Many tasks caught up — ask CLI to assess system state
|
|
236
200
|
_cli_post_catchup_assessment(ran, skipped, state)
|
|
237
201
|
else:
|
|
238
|
-
|
|
202
|
+
suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
|
|
203
|
+
log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
|
|
239
204
|
|
|
240
205
|
log("=== Catch-Up complete ===")
|
|
241
206
|
|
|
@@ -34,36 +34,90 @@ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
|
|
|
34
34
|
MAX_CONSECUTIVE_FAILURES = 3
|
|
35
35
|
MAX_SNAPSHOTS = 8
|
|
36
36
|
|
|
37
|
-
# ──
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
str(CLAUDE_DIR / "plugins") + "/",
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
# ── Immutable files — NEVER touch (applies to ALL modes) ────────────────
|
|
55
|
-
IMMUTABLE_FILES = {
|
|
56
|
-
"db.py", "server.py", "plugin_loader.py", "nexo-watchdog.sh",
|
|
57
|
-
"cortex-wrapper.py", "CLAUDE.md", "personality.md",
|
|
58
|
-
"user-profile.md", "evolution_cycle.py",
|
|
59
|
-
# Core cognitive engine — never auto-modified
|
|
60
|
-
"cognitive.py", "knowledge_graph.py", "storage_router.py",
|
|
61
|
-
# Core tools — never auto-modified
|
|
62
|
-
"tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
|
|
63
|
-
"tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
|
|
64
|
-
"tools_task_history.py", "tools_menu.py",
|
|
37
|
+
# ── Immutable files — split by risk tier ────────────────────────────────
|
|
38
|
+
# These remain locked even in managed mode because they can break bootstrap,
|
|
39
|
+
# persistence, or the evolution engine itself.
|
|
40
|
+
GLOBAL_IMMUTABLE_FILES = {
|
|
41
|
+
"db.py",
|
|
42
|
+
"server.py",
|
|
43
|
+
"plugin_loader.py",
|
|
44
|
+
"nexo-watchdog.sh",
|
|
45
|
+
"cortex-wrapper.py",
|
|
46
|
+
"CLAUDE.md",
|
|
47
|
+
"personality.md",
|
|
48
|
+
"user-profile.md",
|
|
49
|
+
"evolution_cycle.py",
|
|
50
|
+
"storage_router.py",
|
|
65
51
|
}
|
|
66
52
|
|
|
53
|
+
# Managed mode may autoevolve behavior/tooling modules, but auto/review keep
|
|
54
|
+
# these guarded to stay conservative for public installs.
|
|
55
|
+
STANDARD_MODE_IMMUTABLE_FILES = {
|
|
56
|
+
"cognitive.py",
|
|
57
|
+
"knowledge_graph.py",
|
|
58
|
+
"tools_sessions.py",
|
|
59
|
+
"tools_coordination.py",
|
|
60
|
+
"tools_reminders.py",
|
|
61
|
+
"tools_reminders_crud.py",
|
|
62
|
+
"tools_learnings.py",
|
|
63
|
+
"tools_credentials.py",
|
|
64
|
+
"tools_task_history.py",
|
|
65
|
+
"tools_menu.py",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _repo_root() -> Path | None:
|
|
70
|
+
candidate = NEXO_CODE.parent
|
|
71
|
+
if (candidate / "package.json").exists():
|
|
72
|
+
return candidate
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _public_safe_prefixes() -> list[str]:
|
|
77
|
+
return [
|
|
78
|
+
str(CLAUDE_DIR / "scripts") + "/",
|
|
79
|
+
str(CLAUDE_DIR / "plugins") + "/",
|
|
80
|
+
str(CLAUDE_DIR / "skills") + "/",
|
|
81
|
+
str(CLAUDE_DIR / "skills-runtime") + "/",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _managed_safe_prefixes() -> list[str]:
|
|
86
|
+
prefixes = [
|
|
87
|
+
str(CLAUDE_DIR / "scripts") + "/",
|
|
88
|
+
str(CLAUDE_DIR / "plugins") + "/",
|
|
89
|
+
str(CLAUDE_DIR / "brain") + "/",
|
|
90
|
+
str(CLAUDE_DIR / "coordination") + "/",
|
|
91
|
+
str(CLAUDE_DIR / "logs") + "/",
|
|
92
|
+
str(CLAUDE_DIR / "skills") + "/",
|
|
93
|
+
str(CLAUDE_DIR / "skills-core") + "/",
|
|
94
|
+
str(CLAUDE_DIR / "skills-runtime") + "/",
|
|
95
|
+
str(NEXO_CODE) + "/",
|
|
96
|
+
]
|
|
97
|
+
repo_root = _repo_root()
|
|
98
|
+
if repo_root:
|
|
99
|
+
for rel in ("bin", "docs", "templates", "tests"):
|
|
100
|
+
prefixes.append(str(repo_root / rel) + "/")
|
|
101
|
+
return prefixes
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _normalize_mode(mode: str) -> str:
|
|
105
|
+
value = str(mode or "auto").strip().lower()
|
|
106
|
+
aliases = {
|
|
107
|
+
"owner": "managed",
|
|
108
|
+
"core": "managed",
|
|
109
|
+
"hybrid": "managed",
|
|
110
|
+
"manual": "review",
|
|
111
|
+
}
|
|
112
|
+
return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _immutable_files_for_mode(mode: str) -> set[str]:
|
|
116
|
+
normalized = _normalize_mode(mode)
|
|
117
|
+
if normalized == "managed":
|
|
118
|
+
return set(GLOBAL_IMMUTABLE_FILES)
|
|
119
|
+
return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
|
|
120
|
+
|
|
67
121
|
# ── Claude CLI path ──────────────────────────────────────────────────────
|
|
68
122
|
def _resolve_claude_cli() -> Path:
|
|
69
123
|
"""Find claude CLI: saved path > PATH > common locations."""
|
|
@@ -162,16 +216,18 @@ def call_claude_cli(prompt: str) -> str:
|
|
|
162
216
|
# ── File safety validation ───────────────────────────────────────────────
|
|
163
217
|
def is_safe_path(filepath: str, mode: str = "auto") -> bool:
|
|
164
218
|
"""Check if a file path is within safe zones and not immutable.
|
|
165
|
-
mode='auto' (public): restricted to
|
|
166
|
-
mode='
|
|
219
|
+
mode='auto' (public): restricted to personal automation surfaces.
|
|
220
|
+
mode='managed' (owner): broader repo/core surfaces with rollback.
|
|
221
|
+
mode='review': broader zones for proposal validation, but no execution.
|
|
167
222
|
"""
|
|
168
223
|
expanded = str(Path(filepath).expanduser().resolve())
|
|
169
224
|
filename = Path(expanded).name
|
|
225
|
+
mode = _normalize_mode(mode)
|
|
170
226
|
|
|
171
|
-
if filename in
|
|
227
|
+
if filename in _immutable_files_for_mode(mode):
|
|
172
228
|
return False
|
|
173
229
|
|
|
174
|
-
prefixes =
|
|
230
|
+
prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()
|
|
175
231
|
for prefix in prefixes:
|
|
176
232
|
resolved_prefix = str(Path(prefix).expanduser().resolve())
|
|
177
233
|
if expanded.startswith(resolved_prefix):
|
|
@@ -218,13 +274,13 @@ def validate_syntax(filepath: str) -> tuple[bool, str]:
|
|
|
218
274
|
|
|
219
275
|
|
|
220
276
|
# ── Apply a single change operation ──────────────────────────────────────
|
|
221
|
-
def apply_change(change: dict) -> tuple[bool, str]:
|
|
277
|
+
def apply_change(change: dict, mode: str = "auto") -> tuple[bool, str]:
|
|
222
278
|
"""Apply a single file change operation. Returns (success, message)."""
|
|
223
279
|
filepath = str(Path(change["file"]).expanduser())
|
|
224
280
|
operation = change.get("operation", "")
|
|
225
281
|
content = change.get("content", "")
|
|
226
282
|
|
|
227
|
-
if not is_safe_path(filepath):
|
|
283
|
+
if not is_safe_path(filepath, mode=mode):
|
|
228
284
|
return False, f"BLOCKED: {filepath} is outside safe zones or immutable"
|
|
229
285
|
|
|
230
286
|
try:
|
|
@@ -269,7 +325,7 @@ def apply_change(change: dict) -> tuple[bool, str]:
|
|
|
269
325
|
|
|
270
326
|
|
|
271
327
|
# ── Execute AUTO proposals ───────────────────────────────────────────────
|
|
272
|
-
def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection) -> dict:
|
|
328
|
+
def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection, mode: str = "auto") -> dict:
|
|
273
329
|
"""Execute an AUTO proposal with snapshot/apply/validate/rollback."""
|
|
274
330
|
changes = proposal.get("changes", [])
|
|
275
331
|
if not changes:
|
|
@@ -278,7 +334,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
|
|
|
278
334
|
# Validate all paths first
|
|
279
335
|
for change in changes:
|
|
280
336
|
filepath = str(Path(change["file"]).expanduser())
|
|
281
|
-
if not is_safe_path(filepath):
|
|
337
|
+
if not is_safe_path(filepath, mode=mode):
|
|
282
338
|
return {"status": "blocked", "reason": f"Unsafe path: {filepath}"}
|
|
283
339
|
|
|
284
340
|
# Collect files to snapshot (existing files only)
|
|
@@ -299,7 +355,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
|
|
|
299
355
|
all_results = []
|
|
300
356
|
try:
|
|
301
357
|
for change in changes:
|
|
302
|
-
success, msg = apply_change(change)
|
|
358
|
+
success, msg = apply_change(change, mode=mode)
|
|
303
359
|
all_results.append(msg)
|
|
304
360
|
log(f" {msg}")
|
|
305
361
|
if not success:
|
|
@@ -343,57 +399,102 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
|
|
|
343
399
|
log(f" Removed created file: {filepath}")
|
|
344
400
|
|
|
345
401
|
return {
|
|
346
|
-
"status": "
|
|
402
|
+
"status": "rolled_back",
|
|
347
403
|
"snapshot_ref": snapshot_ref,
|
|
348
404
|
"files_changed": [],
|
|
349
405
|
"test_result": f"ROLLBACK: {e}; " + "; ".join(all_results),
|
|
350
406
|
}
|
|
351
407
|
|
|
352
408
|
|
|
353
|
-
# ──
|
|
354
|
-
def
|
|
355
|
-
|
|
356
|
-
|
|
409
|
+
# ── Followups for managed/review modes ──────────────────────────────────
|
|
410
|
+
def _insert_followup(conn: sqlite3.Connection, followup_id: str, description: str,
|
|
411
|
+
verification: str, due_date: str | None = None):
|
|
412
|
+
now_epoch = datetime.now().timestamp()
|
|
413
|
+
conn.execute(
|
|
414
|
+
"INSERT OR REPLACE INTO followups (id, description, date, status, verification, created_at, updated_at) "
|
|
415
|
+
"VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
|
|
416
|
+
(followup_id, description, due_date, verification, now_epoch, now_epoch)
|
|
417
|
+
)
|
|
418
|
+
conn.commit()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _create_cycle_followup(conn: sqlite3.Connection, cycle_num: int,
|
|
422
|
+
items: list[dict], analysis: str, mode: str):
|
|
423
|
+
"""Create a followup summarizing pending proposals or owner review items."""
|
|
357
424
|
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
|
358
425
|
followup_id = f"NF-EVO-C{cycle_num}"
|
|
359
426
|
|
|
360
427
|
public_items = [i for i in items if i.get("scope") == "public"]
|
|
361
428
|
local_items = [i for i in items if i.get("scope") != "public"]
|
|
362
429
|
|
|
363
|
-
|
|
430
|
+
title = "proposals to review" if mode == "review" else "items needing attention"
|
|
431
|
+
lines = [f"Evolution Cycle #{cycle_num} — {len(items)} {title}."]
|
|
364
432
|
lines.append(f"Analysis: {analysis[:200]}")
|
|
365
433
|
lines.append("")
|
|
366
434
|
|
|
367
435
|
if public_items:
|
|
368
436
|
lines.append(f"FOR EVERYONE ({len(public_items)}):")
|
|
369
437
|
for i, item in enumerate(public_items, 1):
|
|
370
|
-
|
|
438
|
+
status = item.get("status", "proposed").upper()
|
|
439
|
+
lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
|
|
371
440
|
lines.append(f" Why: {item['reasoning'][:100]}")
|
|
441
|
+
if item.get("detail"):
|
|
442
|
+
lines.append(f" Detail: {item['detail'][:160]}")
|
|
372
443
|
lines.append("")
|
|
373
444
|
|
|
374
445
|
if local_items:
|
|
375
446
|
lines.append(f"FOR YOU ONLY ({len(local_items)}):")
|
|
376
447
|
for i, item in enumerate(local_items, 1):
|
|
377
|
-
|
|
448
|
+
status = item.get("status", "proposed").upper()
|
|
449
|
+
lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
|
|
378
450
|
lines.append(f" Why: {item['reasoning'][:100]}")
|
|
451
|
+
if item.get("detail"):
|
|
452
|
+
lines.append(f" Detail: {item['detail'][:160]}")
|
|
379
453
|
|
|
380
454
|
description = "\n".join(lines)
|
|
381
455
|
|
|
382
456
|
try:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
now_epoch, now_epoch)
|
|
457
|
+
_insert_followup(
|
|
458
|
+
conn,
|
|
459
|
+
followup_id,
|
|
460
|
+
description,
|
|
461
|
+
f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}",
|
|
462
|
+
due_date=tomorrow,
|
|
390
463
|
)
|
|
391
|
-
conn.commit()
|
|
392
464
|
log(f" Followup {followup_id} created for {tomorrow}")
|
|
393
465
|
except Exception as e:
|
|
394
466
|
log(f" WARN: Failed to create followup: {e}")
|
|
395
467
|
|
|
396
468
|
|
|
469
|
+
def _create_failure_followup(conn: sqlite3.Connection, cycle_num: int, log_id: int,
|
|
470
|
+
proposal: dict, result: dict):
|
|
471
|
+
"""Create an incident-style followup for a failed or blocked AUTO proposal."""
|
|
472
|
+
followup_id = f"NF-EVO-L{log_id}"
|
|
473
|
+
lines = [
|
|
474
|
+
f"Evolution AUTO proposal failed in cycle #{cycle_num}.",
|
|
475
|
+
f"Action: {proposal.get('action', '')[:200]}",
|
|
476
|
+
f"Dimension: {proposal.get('dimension', 'other')}",
|
|
477
|
+
f"Status: {result.get('status', 'failed')}",
|
|
478
|
+
f"Reason: {(result.get('reason') or result.get('test_result') or 'unknown')[:400]}",
|
|
479
|
+
]
|
|
480
|
+
snapshot_ref = result.get("snapshot_ref")
|
|
481
|
+
if snapshot_ref:
|
|
482
|
+
lines.append(f"Snapshot: {snapshot_ref}")
|
|
483
|
+
description = "\n".join(lines)
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
_insert_followup(
|
|
487
|
+
conn,
|
|
488
|
+
followup_id,
|
|
489
|
+
description,
|
|
490
|
+
f"SELECT * FROM evolution_log WHERE id={log_id}",
|
|
491
|
+
due_date=(date.today() + timedelta(days=1)).isoformat(),
|
|
492
|
+
)
|
|
493
|
+
log(f" Failure followup {followup_id} created")
|
|
494
|
+
except Exception as e:
|
|
495
|
+
log(f" WARN: Failed to create failure followup: {e}")
|
|
496
|
+
|
|
497
|
+
|
|
397
498
|
# ── Main run ─────────────────────────────────────────────────────────────
|
|
398
499
|
def run():
|
|
399
500
|
log("=" * 60)
|
|
@@ -482,14 +583,12 @@ def run():
|
|
|
482
583
|
max_auto = max_auto_changes(objective.get("total_evolutions", 0))
|
|
483
584
|
auto_count = 0
|
|
484
585
|
auto_applied = 0
|
|
485
|
-
evolution_mode = objective.get("evolution_mode", "auto")
|
|
586
|
+
evolution_mode = _normalize_mode(objective.get("evolution_mode", "auto"))
|
|
486
587
|
|
|
487
588
|
conn = sqlite3.connect(str(NEXO_DB), timeout=10)
|
|
488
589
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
489
590
|
|
|
490
|
-
|
|
491
|
-
# In "auto" mode: execute AUTO proposals, log PROPOSE as proposed
|
|
492
|
-
review_items = []
|
|
591
|
+
followup_items = []
|
|
493
592
|
|
|
494
593
|
for p in proposals:
|
|
495
594
|
classification = p.get("classification", "propose")
|
|
@@ -499,30 +598,29 @@ def run():
|
|
|
499
598
|
scope = p.get("scope", "local") # "public" or "local"
|
|
500
599
|
|
|
501
600
|
if evolution_mode == "review":
|
|
502
|
-
# Owner mode: nothing executes, everything queued for review
|
|
503
601
|
log(f" QUEUED [{scope}]: {action[:80]}")
|
|
504
602
|
conn.execute(
|
|
505
603
|
"INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
|
|
506
604
|
"reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
|
|
507
605
|
(cycle_num, dimension, action, classification, reasoning, "pending_review")
|
|
508
606
|
)
|
|
509
|
-
|
|
607
|
+
followup_items.append({
|
|
510
608
|
"dimension": dimension,
|
|
511
609
|
"action": action,
|
|
512
610
|
"reasoning": reasoning,
|
|
513
611
|
"scope": scope,
|
|
514
612
|
"classification": classification,
|
|
613
|
+
"status": "pending_review",
|
|
515
614
|
})
|
|
516
615
|
|
|
517
616
|
elif classification == "auto" and auto_count < max_auto:
|
|
518
|
-
# Public mode: execute AUTO proposals
|
|
519
617
|
auto_count += 1
|
|
520
618
|
log(f" AUTO #{auto_count}/{max_auto}: {action[:80]}")
|
|
521
619
|
|
|
522
|
-
result = execute_auto_proposal(p, cycle_num, conn)
|
|
620
|
+
result = execute_auto_proposal(p, cycle_num, conn, mode=evolution_mode)
|
|
523
621
|
status = result["status"]
|
|
524
622
|
|
|
525
|
-
conn.execute(
|
|
623
|
+
cur = conn.execute(
|
|
526
624
|
"INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
|
|
527
625
|
"reasoning, status, files_changed, snapshot_ref, test_result) "
|
|
528
626
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
@@ -531,16 +629,20 @@ def run():
|
|
|
531
629
|
result.get("snapshot_ref", ""),
|
|
532
630
|
result.get("test_result", ""))
|
|
533
631
|
)
|
|
632
|
+
log_id = cur.lastrowid
|
|
534
633
|
|
|
535
634
|
if status == "applied":
|
|
536
635
|
auto_applied += 1
|
|
537
636
|
log(f" APPLIED successfully")
|
|
538
637
|
elif status == "blocked":
|
|
539
|
-
|
|
638
|
+
detail = result.get("reason") or result.get("test_result", "")
|
|
639
|
+
log(f" BLOCKED: {detail[:100]}")
|
|
640
|
+
_create_failure_followup(conn, cycle_num, log_id, p, result)
|
|
540
641
|
elif status == "skipped":
|
|
541
642
|
log(f" SKIPPED: {result.get('reason', '')}")
|
|
542
643
|
else:
|
|
543
|
-
log(f"
|
|
644
|
+
log(f" ROLLED BACK: {result.get('test_result', '')[:100]}")
|
|
645
|
+
_create_failure_followup(conn, cycle_num, log_id, p, result)
|
|
544
646
|
|
|
545
647
|
else:
|
|
546
648
|
# PROPOSE or over auto limit
|
|
@@ -555,12 +657,20 @@ def run():
|
|
|
555
657
|
"reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
|
|
556
658
|
(cycle_num, dimension, action, classification, reasoning, "proposed")
|
|
557
659
|
)
|
|
660
|
+
if evolution_mode in {"review", "managed"}:
|
|
661
|
+
followup_items.append({
|
|
662
|
+
"dimension": dimension,
|
|
663
|
+
"action": action,
|
|
664
|
+
"reasoning": reasoning,
|
|
665
|
+
"scope": scope,
|
|
666
|
+
"classification": classification,
|
|
667
|
+
"status": "proposed",
|
|
668
|
+
})
|
|
558
669
|
|
|
559
670
|
conn.commit()
|
|
560
671
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
_create_review_followup(conn, cycle_num, review_items, response.get("analysis", ""))
|
|
672
|
+
if evolution_mode in {"review", "managed"} and followup_items:
|
|
673
|
+
_create_cycle_followup(conn, cycle_num, followup_items, response.get("analysis", ""), evolution_mode)
|
|
564
674
|
|
|
565
675
|
# Update metrics
|
|
566
676
|
scores = response.get("dimension_scores", {})
|
|
@@ -591,6 +701,7 @@ def run():
|
|
|
591
701
|
objective.setdefault("history", []).insert(0, {
|
|
592
702
|
"cycle": cycle_num,
|
|
593
703
|
"date": str(date.today()),
|
|
704
|
+
"mode": evolution_mode,
|
|
594
705
|
"proposals": len(proposals),
|
|
595
706
|
"auto_count": auto_count,
|
|
596
707
|
"auto_applied": auto_applied,
|