nexo-brain 2.6.0 → 2.6.2
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/README.md +2 -2
- package/bin/nexo-brain.js +37 -2
- package/package.json +1 -1
- package/src/auto_update.py +534 -0
- package/src/cli.py +46 -186
- package/src/cron_recovery.py +86 -6
- package/src/crons/sync.py +4 -4
- package/src/nexo.db +0 -0
- package/src/plugins/schedule.py +20 -4
- package/src/plugins/update.py +26 -14
- package/src/runtime_power.py +284 -0
- package/src/script_registry.py +66 -0
- package/src/scripts/nexo-catchup.py +10 -5
- package/src/server.py +6 -2
package/README.md
CHANGED
|
@@ -280,7 +280,7 @@ NEXO Brain doesn't just respond — it runs 14 autonomous processes in the backg
|
|
|
280
280
|
| **postmortem** | 23:30 daily | Session consolidation, extract patterns from day's events |
|
|
281
281
|
| **catchup** | On boot | Runs any missed scheduled processes (Mac was off/asleep) |
|
|
282
282
|
| **tcc-approve** | On boot (macOS) | Auto-approve macOS permissions for Claude Code updates |
|
|
283
|
-
| **prevent-sleep** |
|
|
283
|
+
| **prevent-sleep** | Optional opt-in daemon | Keeps machine awake for nocturnal processes when `power_policy=always_on` (caffeinate/systemd-inhibit) |
|
|
284
284
|
| **evolution** | Weekly (Sun) | Self-improvement proposals — NEXO suggests and applies enhancements |
|
|
285
285
|
| **followup-hygiene** | Weekly (Sun) | Normalizes statuses, flags stale followups, cleans orphans |
|
|
286
286
|
| **learning-housekeep** | 03:15 daily | Dedup learnings, adjust weights by usage, process overdue reviews, reconcile decision outcomes |
|
|
@@ -289,7 +289,7 @@ NEXO Brain doesn't just respond — it runs 14 autonomous processes in the backg
|
|
|
289
289
|
| **watchdog** | Every 30 min | Monitors services, LaunchAgents, and infrastructure health |
|
|
290
290
|
| **auto-close-sessions** | Every 5 min | Cleans stale sessions |
|
|
291
291
|
|
|
292
|
-
Core processes are defined in `src/crons/manifest.json` and auto-synced to your system by `nexo_update`. On macOS they run via LaunchAgents; on Linux via systemd user timers. `tcc-approve`, `prevent-sleep`, and `backup` are platform/personal helpers — not in the manifest but listed above for completeness. Personal scripts (your own automations) are tracked separately in the Personal Scripts Registry and never touched by the core sync. If your Mac was asleep during a scheduled process, the catch-up
|
|
292
|
+
Core processes are defined in `src/crons/manifest.json` and auto-synced to your system by `nexo_update`. On macOS they run via LaunchAgents; on Linux via systemd user timers. `tcc-approve`, `prevent-sleep`, and `backup` are platform/personal helpers — not in the manifest but listed above for completeness. `prevent-sleep` is opt-in via the persisted power policy (`always_on` / `disabled` / `unset`). Personal scripts (your own automations) are tracked separately in the Personal Scripts Registry, can declare their own recovery policy inline, and are never touched by the core sync. If your Mac was asleep during a scheduled process, the catch-up system can now recover both core crons and managed personal schedules according to their recovery contract.
|
|
293
293
|
|
|
294
294
|
## Deep Sleep v2 — Overnight Learning (v2.1.0)
|
|
295
295
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -286,6 +286,8 @@ function getDefaultSchedule(timezone) {
|
|
|
286
286
|
return {
|
|
287
287
|
timezone: timezone || "UTC",
|
|
288
288
|
auto_update: true,
|
|
289
|
+
power_policy: "unset",
|
|
290
|
+
power_policy_version: 1,
|
|
289
291
|
processes: {
|
|
290
292
|
"cognitive-decay": { hour: 3, minute: 0 },
|
|
291
293
|
"postmortem": { hour: 23, minute: 30 },
|
|
@@ -298,6 +300,33 @@ function getDefaultSchedule(timezone) {
|
|
|
298
300
|
};
|
|
299
301
|
}
|
|
300
302
|
|
|
303
|
+
async function maybeConfigurePowerPolicy(schedule, useDefaults) {
|
|
304
|
+
const current = String((schedule && schedule.power_policy) || "unset").toLowerCase();
|
|
305
|
+
if (current && current !== "unset") {
|
|
306
|
+
return schedule;
|
|
307
|
+
}
|
|
308
|
+
if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
309
|
+
schedule.power_policy = "unset";
|
|
310
|
+
schedule.power_policy_version = 1;
|
|
311
|
+
return schedule;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log("");
|
|
315
|
+
log("Optional power policy:");
|
|
316
|
+
log("If enabled, NEXO will try to keep the machine awake for background work.");
|
|
317
|
+
const answer = (await ask(" Keep this machine awake for background work? [y/N/later]: ")).trim().toLowerCase();
|
|
318
|
+
if (answer === "y" || answer === "yes") {
|
|
319
|
+
schedule.power_policy = "always_on";
|
|
320
|
+
} else if (answer === "later" || answer === "l") {
|
|
321
|
+
schedule.power_policy = "unset";
|
|
322
|
+
} else {
|
|
323
|
+
schedule.power_policy = "disabled";
|
|
324
|
+
}
|
|
325
|
+
schedule.power_policy_version = 1;
|
|
326
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
327
|
+
return schedule;
|
|
328
|
+
}
|
|
329
|
+
|
|
301
330
|
/**
|
|
302
331
|
* Resolve the venv python path for an existing NEXO_HOME installation.
|
|
303
332
|
*/
|
|
@@ -370,6 +399,9 @@ function installAllProcesses(platform, pythonPath, nexoHome, schedule, launchAge
|
|
|
370
399
|
const sPath = scriptPath(proc);
|
|
371
400
|
const interp = interpreterPath(proc);
|
|
372
401
|
const s = getSchedule(proc);
|
|
402
|
+
if (proc.name === "prevent-sleep" && (schedule.power_policy || "unset") !== "always_on") {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
373
405
|
|
|
374
406
|
let scheduleBlock = "";
|
|
375
407
|
if (proc.type === "keepAlive") {
|
|
@@ -477,6 +509,7 @@ function installAllProcesses(platform, pythonPath, nexoHome, schedule, launchAge
|
|
|
477
509
|
const sPath = scriptPath(proc);
|
|
478
510
|
const interp = interpreterPath(proc);
|
|
479
511
|
const s = getSchedule(proc);
|
|
512
|
+
if (proc.name === "prevent-sleep" && (schedule.power_policy || "unset") !== "always_on") continue;
|
|
480
513
|
|
|
481
514
|
const serviceType = proc.type === "keepAlive" ? "simple" : "oneshot";
|
|
482
515
|
const restartPolicy = proc.type === "keepAlive" ? "Restart=always\nRestartSec=5" : "";
|
|
@@ -795,7 +828,8 @@ async function main() {
|
|
|
795
828
|
log(" All 8 core hooks registered in Claude Code settings.");
|
|
796
829
|
|
|
797
830
|
// Regenerate all core LaunchAgents / systemd timers
|
|
798
|
-
|
|
831
|
+
let migSchedule = loadOrCreateSchedule(NEXO_HOME);
|
|
832
|
+
migSchedule = await maybeConfigurePowerPolicy(migSchedule, useDefaults);
|
|
799
833
|
const migPython = findVenvPython(NEXO_HOME) || "python3";
|
|
800
834
|
let migOptionals = {};
|
|
801
835
|
try {
|
|
@@ -2067,7 +2101,8 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
|
|
|
2067
2101
|
|
|
2068
2102
|
// Step 7: Create schedule.json (only on fresh install) and install core processes
|
|
2069
2103
|
log("Setting up automated processes...");
|
|
2070
|
-
|
|
2104
|
+
let schedule = loadOrCreateSchedule(NEXO_HOME);
|
|
2105
|
+
schedule = await maybeConfigurePowerPolicy(schedule, useDefaults);
|
|
2071
2106
|
const enabledOptionals = { dashboard: doDashboard };
|
|
2072
2107
|
if (isEphemeralInstall(NEXO_HOME)) {
|
|
2073
2108
|
log("Ephemeral HOME/NEXO_HOME detected — skipping LaunchAgents installation.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO — Cognitive co-operator for Claude Code. Memory, emotional intelligence, overnight learning (Deep Sleep), personal scripts registry, cron management, trust scoring, managed evolution, and adaptive calibration.",
|
|
6
6
|
"bin": {
|
package/src/auto_update.py
CHANGED
|
@@ -22,6 +22,7 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
22
22
|
|
|
23
23
|
# Repo root: go up from src/
|
|
24
24
|
SRC_DIR = Path(__file__).resolve().parent
|
|
25
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(SRC_DIR)))
|
|
25
26
|
REPO_DIR = SRC_DIR.parent
|
|
26
27
|
|
|
27
28
|
LAST_CHECK_FILE = DATA_DIR / "auto_update_last_check.json"
|
|
@@ -1073,3 +1074,536 @@ def auto_update_check() -> dict:
|
|
|
1073
1074
|
result["error"] = error_msg
|
|
1074
1075
|
|
|
1075
1076
|
return result
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
UPDATE_SUMMARY_FILE = NEXO_HOME / "logs" / "update-last-summary.json"
|
|
1080
|
+
UPDATE_HISTORY_FILE = NEXO_HOME / "logs" / "update-history.jsonl"
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _resolve_sync_source() -> tuple[Path | None, Path | None]:
|
|
1084
|
+
dest = NEXO_HOME
|
|
1085
|
+
|
|
1086
|
+
def _runtime_version_source() -> Path | None:
|
|
1087
|
+
version_file = NEXO_HOME / "version.json"
|
|
1088
|
+
if not version_file.is_file():
|
|
1089
|
+
return None
|
|
1090
|
+
try:
|
|
1091
|
+
data = json.loads(version_file.read_text())
|
|
1092
|
+
except Exception:
|
|
1093
|
+
return None
|
|
1094
|
+
source = str(data.get("source", "")).strip()
|
|
1095
|
+
if not source:
|
|
1096
|
+
return None
|
|
1097
|
+
candidate = Path(source).expanduser()
|
|
1098
|
+
if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
|
|
1099
|
+
return candidate
|
|
1100
|
+
return None
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
|
|
1104
|
+
except Exception:
|
|
1105
|
+
same_as_runtime = NEXO_CODE == dest
|
|
1106
|
+
|
|
1107
|
+
if (
|
|
1108
|
+
not same_as_runtime
|
|
1109
|
+
and (NEXO_CODE / "db").is_dir()
|
|
1110
|
+
and (NEXO_CODE.parent / "package.json").is_file()
|
|
1111
|
+
):
|
|
1112
|
+
return NEXO_CODE, NEXO_CODE.parent
|
|
1113
|
+
|
|
1114
|
+
version_source = _runtime_version_source()
|
|
1115
|
+
if version_source:
|
|
1116
|
+
return version_source / "src", version_source
|
|
1117
|
+
return None, None
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _git_in_repo(repo_dir: Path, *args, timeout: int = 10) -> tuple[int, str, str]:
|
|
1121
|
+
result = subprocess.run(
|
|
1122
|
+
["git"] + list(args),
|
|
1123
|
+
cwd=str(repo_dir),
|
|
1124
|
+
capture_output=True,
|
|
1125
|
+
text=True,
|
|
1126
|
+
timeout=timeout,
|
|
1127
|
+
)
|
|
1128
|
+
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _source_repo_status(repo_dir: Path) -> dict:
|
|
1132
|
+
if not (repo_dir / ".git").exists() and not (repo_dir / ".git").is_file():
|
|
1133
|
+
return {"is_git": False, "dirty": False, "behind": False, "diverged": False, "ahead": False}
|
|
1134
|
+
|
|
1135
|
+
rc, dirty_out, dirty_err = _git_in_repo(repo_dir, "status", "--porcelain")
|
|
1136
|
+
dirty = rc == 0 and bool(dirty_out.strip())
|
|
1137
|
+
if rc != 0:
|
|
1138
|
+
return {
|
|
1139
|
+
"is_git": True,
|
|
1140
|
+
"dirty": True,
|
|
1141
|
+
"behind": False,
|
|
1142
|
+
"diverged": False,
|
|
1143
|
+
"ahead": False,
|
|
1144
|
+
"error": dirty_err or "git status failed",
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
rc, _, fetch_err = _git_in_repo(repo_dir, "fetch", "--quiet")
|
|
1148
|
+
if rc != 0:
|
|
1149
|
+
return {
|
|
1150
|
+
"is_git": True,
|
|
1151
|
+
"dirty": dirty,
|
|
1152
|
+
"behind": False,
|
|
1153
|
+
"diverged": False,
|
|
1154
|
+
"ahead": False,
|
|
1155
|
+
"error": fetch_err or "git fetch failed",
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
rc, local_head, _ = _git_in_repo(repo_dir, "rev-parse", "HEAD")
|
|
1159
|
+
rc2, remote_head, remote_err = _git_in_repo(repo_dir, "rev-parse", "@{u}")
|
|
1160
|
+
if rc != 0 or rc2 != 0:
|
|
1161
|
+
return {
|
|
1162
|
+
"is_git": True,
|
|
1163
|
+
"dirty": dirty,
|
|
1164
|
+
"behind": False,
|
|
1165
|
+
"diverged": False,
|
|
1166
|
+
"ahead": False,
|
|
1167
|
+
"error": remote_err or "no upstream configured",
|
|
1168
|
+
}
|
|
1169
|
+
rc, merge_base, merge_err = _git_in_repo(repo_dir, "merge-base", "HEAD", "@{u}")
|
|
1170
|
+
if rc != 0:
|
|
1171
|
+
return {
|
|
1172
|
+
"is_git": True,
|
|
1173
|
+
"dirty": dirty,
|
|
1174
|
+
"behind": False,
|
|
1175
|
+
"diverged": False,
|
|
1176
|
+
"ahead": False,
|
|
1177
|
+
"error": merge_err or "merge-base failed",
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
"is_git": True,
|
|
1181
|
+
"dirty": dirty,
|
|
1182
|
+
"behind": local_head != remote_head and merge_base == local_head,
|
|
1183
|
+
"ahead": local_head != remote_head and merge_base == remote_head,
|
|
1184
|
+
"diverged": merge_base not in {local_head, remote_head},
|
|
1185
|
+
"local_head": local_head,
|
|
1186
|
+
"remote_head": remote_head,
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
|
|
1191
|
+
timestamp = time.strftime("%Y-%m-%d-%H%M%S")
|
|
1192
|
+
backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
|
|
1193
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
1194
|
+
|
|
1195
|
+
code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
|
|
1196
|
+
flat_files = [
|
|
1197
|
+
"server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
|
|
1198
|
+
"maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
|
|
1199
|
+
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1200
|
+
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1201
|
+
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1202
|
+
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
1203
|
+
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
1204
|
+
"cron_recovery.py", "runtime_power.py", "requirements.txt", "package.json", "version.json",
|
|
1205
|
+
]
|
|
1206
|
+
for name in code_dirs:
|
|
1207
|
+
src = dest / name
|
|
1208
|
+
if src.is_dir():
|
|
1209
|
+
import shutil
|
|
1210
|
+
shutil.copytree(str(src), str(backup_dir / name), dirs_exist_ok=True)
|
|
1211
|
+
for name in flat_files:
|
|
1212
|
+
src = dest / name
|
|
1213
|
+
if src.is_file():
|
|
1214
|
+
import shutil
|
|
1215
|
+
shutil.copy2(str(src), str(backup_dir / name))
|
|
1216
|
+
if (dest / "bin").is_dir():
|
|
1217
|
+
import shutil
|
|
1218
|
+
shutil.copytree(str(dest / "bin"), str(backup_dir / "bin"), dirs_exist_ok=True)
|
|
1219
|
+
return str(backup_dir)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
|
|
1223
|
+
import shutil
|
|
1224
|
+
|
|
1225
|
+
bdir = Path(backup_dir)
|
|
1226
|
+
if not bdir.is_dir():
|
|
1227
|
+
return
|
|
1228
|
+
for item in bdir.iterdir():
|
|
1229
|
+
target = dest / item.name
|
|
1230
|
+
if item.is_dir():
|
|
1231
|
+
if target.exists():
|
|
1232
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
1233
|
+
shutil.copytree(str(item), str(target))
|
|
1234
|
+
else:
|
|
1235
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1236
|
+
shutil.copy2(str(item), str(target))
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME) -> dict:
|
|
1240
|
+
import shutil
|
|
1241
|
+
|
|
1242
|
+
packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
|
|
1243
|
+
flat_files = [
|
|
1244
|
+
"server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
|
|
1245
|
+
"maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
|
|
1246
|
+
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1247
|
+
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1248
|
+
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1249
|
+
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
1250
|
+
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
1251
|
+
"cron_recovery.py", "runtime_power.py", "requirements.txt",
|
|
1252
|
+
]
|
|
1253
|
+
copied_packages = 0
|
|
1254
|
+
copied_files = 0
|
|
1255
|
+
|
|
1256
|
+
for pkg in packages:
|
|
1257
|
+
pkg_src = src_dir / pkg
|
|
1258
|
+
pkg_dest = dest / pkg
|
|
1259
|
+
if pkg_src.is_dir():
|
|
1260
|
+
if pkg_dest.exists():
|
|
1261
|
+
shutil.rmtree(str(pkg_dest), ignore_errors=True)
|
|
1262
|
+
shutil.copytree(
|
|
1263
|
+
str(pkg_src),
|
|
1264
|
+
str(pkg_dest),
|
|
1265
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db"),
|
|
1266
|
+
)
|
|
1267
|
+
copied_packages += 1
|
|
1268
|
+
|
|
1269
|
+
for name in flat_files:
|
|
1270
|
+
src_file = src_dir / name
|
|
1271
|
+
if src_file.is_file():
|
|
1272
|
+
shutil.copy2(str(src_file), str(dest / name))
|
|
1273
|
+
copied_files += 1
|
|
1274
|
+
|
|
1275
|
+
plugins_src = src_dir / "plugins"
|
|
1276
|
+
plugins_dest = dest / "plugins"
|
|
1277
|
+
if plugins_src.is_dir():
|
|
1278
|
+
plugins_dest.mkdir(parents=True, exist_ok=True)
|
|
1279
|
+
for item in plugins_src.iterdir():
|
|
1280
|
+
if item.is_file() and item.suffix == ".py":
|
|
1281
|
+
shutil.copy2(str(item), str(plugins_dest / item.name))
|
|
1282
|
+
|
|
1283
|
+
scripts_src = src_dir / "scripts"
|
|
1284
|
+
scripts_dest = dest / "scripts"
|
|
1285
|
+
if scripts_src.is_dir():
|
|
1286
|
+
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
1287
|
+
for item in scripts_src.iterdir():
|
|
1288
|
+
if item.name == "__pycache__" or item.name.startswith("."):
|
|
1289
|
+
continue
|
|
1290
|
+
dst = scripts_dest / item.name
|
|
1291
|
+
if item.is_dir():
|
|
1292
|
+
if dst.exists():
|
|
1293
|
+
shutil.rmtree(str(dst), ignore_errors=True)
|
|
1294
|
+
shutil.copytree(str(item), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
1295
|
+
elif item.is_file():
|
|
1296
|
+
shutil.copy2(str(item), str(dst))
|
|
1297
|
+
if item.suffix == ".sh":
|
|
1298
|
+
dst.chmod(0o755)
|
|
1299
|
+
|
|
1300
|
+
templates_src = repo_dir / "templates"
|
|
1301
|
+
templates_dest = dest / "templates"
|
|
1302
|
+
if templates_src.is_dir():
|
|
1303
|
+
templates_dest.mkdir(parents=True, exist_ok=True)
|
|
1304
|
+
for item in templates_src.iterdir():
|
|
1305
|
+
if item.is_file():
|
|
1306
|
+
shutil.copy2(str(item), str(templates_dest / item.name))
|
|
1307
|
+
|
|
1308
|
+
package_json = repo_dir / "package.json"
|
|
1309
|
+
if package_json.is_file():
|
|
1310
|
+
shutil.copy2(str(package_json), str(dest / "package.json"))
|
|
1311
|
+
try:
|
|
1312
|
+
pkg = json.loads(package_json.read_text())
|
|
1313
|
+
(dest / "version.json").write_text(json.dumps({
|
|
1314
|
+
"version": pkg.get("version", "?"),
|
|
1315
|
+
"source": str(repo_dir),
|
|
1316
|
+
}, indent=2))
|
|
1317
|
+
except Exception:
|
|
1318
|
+
pass
|
|
1319
|
+
|
|
1320
|
+
skills_src = src_dir / "skills"
|
|
1321
|
+
skills_dest = dest / "skills-core"
|
|
1322
|
+
if skills_src.is_dir():
|
|
1323
|
+
if skills_dest.exists():
|
|
1324
|
+
shutil.rmtree(str(skills_dest), ignore_errors=True)
|
|
1325
|
+
shutil.copytree(str(skills_src), str(skills_dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
1326
|
+
|
|
1327
|
+
bin_dir = dest / "bin"
|
|
1328
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1329
|
+
wrapper = bin_dir / "nexo"
|
|
1330
|
+
wrapper.write_text(_runtime_cli_wrapper_text())
|
|
1331
|
+
wrapper.chmod(0o755)
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
"packages": copied_packages,
|
|
1335
|
+
"files": copied_files,
|
|
1336
|
+
"source": str(src_dir),
|
|
1337
|
+
"repo": str(repo_dir),
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
|
|
1342
|
+
req_file = runtime_root / "requirements.txt"
|
|
1343
|
+
if not req_file.exists():
|
|
1344
|
+
return True
|
|
1345
|
+
venv_pip = runtime_root / ".venv" / "bin" / "pip"
|
|
1346
|
+
if not venv_pip.exists():
|
|
1347
|
+
venv_pip = runtime_root / ".venv" / "bin" / "pip3"
|
|
1348
|
+
try:
|
|
1349
|
+
if venv_pip.exists():
|
|
1350
|
+
result = subprocess.run(
|
|
1351
|
+
[str(venv_pip), "install", "--quiet", "-r", str(req_file)],
|
|
1352
|
+
capture_output=True,
|
|
1353
|
+
text=True,
|
|
1354
|
+
timeout=120,
|
|
1355
|
+
)
|
|
1356
|
+
else:
|
|
1357
|
+
result = subprocess.run(
|
|
1358
|
+
[sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
|
|
1359
|
+
capture_output=True,
|
|
1360
|
+
text=True,
|
|
1361
|
+
timeout=120,
|
|
1362
|
+
)
|
|
1363
|
+
return result.returncode == 0
|
|
1364
|
+
except Exception:
|
|
1365
|
+
return False
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
|
|
1369
|
+
actions: list[str] = []
|
|
1370
|
+
env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(dest)}
|
|
1371
|
+
try:
|
|
1372
|
+
init_result = subprocess.run(
|
|
1373
|
+
[
|
|
1374
|
+
sys.executable,
|
|
1375
|
+
"-c",
|
|
1376
|
+
(
|
|
1377
|
+
"import db; "
|
|
1378
|
+
"init_db = getattr(db, 'init_db', None); "
|
|
1379
|
+
"init_db() if callable(init_db) else None; "
|
|
1380
|
+
"import script_registry; "
|
|
1381
|
+
"sync_scripts = getattr(script_registry, 'sync_personal_scripts', None); "
|
|
1382
|
+
"sync_scripts() if callable(sync_scripts) else None"
|
|
1383
|
+
),
|
|
1384
|
+
],
|
|
1385
|
+
cwd=str(dest),
|
|
1386
|
+
capture_output=True,
|
|
1387
|
+
text=True,
|
|
1388
|
+
timeout=60,
|
|
1389
|
+
env=env,
|
|
1390
|
+
)
|
|
1391
|
+
if init_result.returncode != 0:
|
|
1392
|
+
return False, [init_result.stderr.strip() or init_result.stdout.strip() or "runtime init failed"]
|
|
1393
|
+
actions.append("db+personal-sync")
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
return False, [f"runtime init error: {e}"]
|
|
1396
|
+
|
|
1397
|
+
if _reinstall_runtime_pip_deps(dest):
|
|
1398
|
+
actions.append("pip-deps")
|
|
1399
|
+
else:
|
|
1400
|
+
actions.append("pip-deps-warning")
|
|
1401
|
+
|
|
1402
|
+
sync_path = dest / "crons" / "sync.py"
|
|
1403
|
+
if sync_path.is_file():
|
|
1404
|
+
try:
|
|
1405
|
+
sync_result = subprocess.run(
|
|
1406
|
+
[sys.executable, str(sync_path)],
|
|
1407
|
+
cwd=str(dest),
|
|
1408
|
+
capture_output=True,
|
|
1409
|
+
text=True,
|
|
1410
|
+
timeout=30,
|
|
1411
|
+
env=env,
|
|
1412
|
+
)
|
|
1413
|
+
if sync_result.returncode != 0:
|
|
1414
|
+
return False, [sync_result.stderr.strip() or sync_result.stdout.strip() or "cron sync failed"]
|
|
1415
|
+
actions.append("cron-sync")
|
|
1416
|
+
except Exception as e:
|
|
1417
|
+
return False, [f"cron sync error: {e}"]
|
|
1418
|
+
|
|
1419
|
+
from runtime_power import apply_power_policy
|
|
1420
|
+
|
|
1421
|
+
power_result = apply_power_policy()
|
|
1422
|
+
if power_result.get("ok"):
|
|
1423
|
+
actions.append(f"power:{power_result.get('action')}")
|
|
1424
|
+
|
|
1425
|
+
verify = subprocess.run(
|
|
1426
|
+
[sys.executable, "-c", "import server"],
|
|
1427
|
+
cwd=str(dest),
|
|
1428
|
+
capture_output=True,
|
|
1429
|
+
text=True,
|
|
1430
|
+
timeout=20,
|
|
1431
|
+
env=env,
|
|
1432
|
+
)
|
|
1433
|
+
if verify.returncode != 0:
|
|
1434
|
+
return False, [verify.stderr.strip() or verify.stdout.strip() or "import verify failed"]
|
|
1435
|
+
actions.append("verify")
|
|
1436
|
+
return True, actions
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _runtime_busy_reason() -> str | None:
|
|
1440
|
+
try:
|
|
1441
|
+
from db import get_active_sessions
|
|
1442
|
+
active = get_active_sessions()
|
|
1443
|
+
except Exception:
|
|
1444
|
+
return None
|
|
1445
|
+
if active:
|
|
1446
|
+
return f"active sessions: {len(active)}"
|
|
1447
|
+
return None
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _write_update_summary(summary: dict):
|
|
1451
|
+
try:
|
|
1452
|
+
logs_dir = NEXO_HOME / "logs"
|
|
1453
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1454
|
+
payload = dict(summary)
|
|
1455
|
+
payload.setdefault("timestamp", time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
1456
|
+
UPDATE_SUMMARY_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
1457
|
+
with UPDATE_HISTORY_FILE.open("a") as fh:
|
|
1458
|
+
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
1459
|
+
except Exception as e:
|
|
1460
|
+
_log(f"Failed to write update summary: {e}")
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True) -> dict:
|
|
1464
|
+
src_dir, repo_dir = _resolve_sync_source()
|
|
1465
|
+
if src_dir is None or repo_dir is None:
|
|
1466
|
+
return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
|
|
1467
|
+
|
|
1468
|
+
source_status = _source_repo_status(repo_dir)
|
|
1469
|
+
pulled = False
|
|
1470
|
+
old_head = source_status.get("local_head")
|
|
1471
|
+
if allow_source_pull and source_status.get("is_git"):
|
|
1472
|
+
if source_status.get("dirty"):
|
|
1473
|
+
_log("Source repo has local changes; syncing local tree without remote pull.")
|
|
1474
|
+
elif source_status.get("diverged"):
|
|
1475
|
+
_log("Source repo diverged; syncing local tree without remote pull.")
|
|
1476
|
+
elif source_status.get("behind"):
|
|
1477
|
+
rc, _, pull_err = _git_in_repo(repo_dir, "pull", "--ff-only", timeout=60)
|
|
1478
|
+
if rc != 0:
|
|
1479
|
+
return {"ok": False, "mode": "sync", "error": pull_err or "git pull failed"}
|
|
1480
|
+
pulled = True
|
|
1481
|
+
|
|
1482
|
+
db_backup_dir = _backup_dbs()
|
|
1483
|
+
tree_backup_dir = _backup_runtime_tree(NEXO_HOME)
|
|
1484
|
+
sync_result = {"ok": False, "mode": "sync", "pulled_source": pulled, "backup_dir": db_backup_dir, "tree_backup": tree_backup_dir}
|
|
1485
|
+
try:
|
|
1486
|
+
copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME)
|
|
1487
|
+
ok, actions = _run_runtime_post_sync(NEXO_HOME)
|
|
1488
|
+
if not ok:
|
|
1489
|
+
raise RuntimeError("; ".join(actions))
|
|
1490
|
+
sync_result.update({
|
|
1491
|
+
"ok": True,
|
|
1492
|
+
"updated": True,
|
|
1493
|
+
"packages": copy_stats["packages"],
|
|
1494
|
+
"files": copy_stats["files"],
|
|
1495
|
+
"actions": actions,
|
|
1496
|
+
"source": copy_stats["source"],
|
|
1497
|
+
"repo": copy_stats["repo"],
|
|
1498
|
+
})
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
_restore_runtime_tree(tree_backup_dir, NEXO_HOME)
|
|
1501
|
+
if db_backup_dir:
|
|
1502
|
+
_restore_dbs(db_backup_dir)
|
|
1503
|
+
_reinstall_runtime_pip_deps(NEXO_HOME)
|
|
1504
|
+
if pulled and old_head:
|
|
1505
|
+
_git_in_repo(repo_dir, "reset", "--hard", old_head, timeout=60)
|
|
1506
|
+
sync_result.update({"error": str(e), "rolled_back": True})
|
|
1507
|
+
_write_update_summary(sync_result)
|
|
1508
|
+
return sync_result
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
1512
|
+
result = {
|
|
1513
|
+
"entrypoint": entrypoint,
|
|
1514
|
+
"checked": False,
|
|
1515
|
+
"updated": False,
|
|
1516
|
+
"actions": [],
|
|
1517
|
+
"skipped_reason": None,
|
|
1518
|
+
"deferred_reason": None,
|
|
1519
|
+
"git_update": None,
|
|
1520
|
+
"npm_notice": None,
|
|
1521
|
+
"claude_md_update": None,
|
|
1522
|
+
"migrations": [],
|
|
1523
|
+
"power_policy": None,
|
|
1524
|
+
"error": None,
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
from runtime_power import apply_power_policy, ensure_power_policy_choice, get_power_policy
|
|
1528
|
+
|
|
1529
|
+
choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
|
|
1530
|
+
power_result = apply_power_policy(choice.get("policy"))
|
|
1531
|
+
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1532
|
+
if power_result.get("ok"):
|
|
1533
|
+
result["actions"].append(f"power:{power_result.get('action')}")
|
|
1534
|
+
|
|
1535
|
+
src_dir, repo_dir = _resolve_sync_source()
|
|
1536
|
+
if src_dir is not None and repo_dir is not None:
|
|
1537
|
+
try:
|
|
1538
|
+
from db import init_db
|
|
1539
|
+
from script_registry import sync_personal_scripts
|
|
1540
|
+
|
|
1541
|
+
_run_db_migrations()
|
|
1542
|
+
result["migrations"] = run_file_migrations()
|
|
1543
|
+
result["claude_md_update"] = _migrate_claude_md()
|
|
1544
|
+
_sync_watchdog_hash_registry()
|
|
1545
|
+
_warn_protected_runtime_location()
|
|
1546
|
+
_ensure_runtime_cli_wrapper()
|
|
1547
|
+
_ensure_runtime_cli_in_shell()
|
|
1548
|
+
init_db()
|
|
1549
|
+
sync_personal_scripts()
|
|
1550
|
+
result["actions"].append("db+personal-sync")
|
|
1551
|
+
except Exception as e:
|
|
1552
|
+
result["error"] = str(e)
|
|
1553
|
+
_write_update_summary(result)
|
|
1554
|
+
return result
|
|
1555
|
+
|
|
1556
|
+
try:
|
|
1557
|
+
last_check = _read_last_check()
|
|
1558
|
+
now = time.time()
|
|
1559
|
+
schedule_data = json.loads((NEXO_HOME / "config" / "schedule.json").read_text()) if (NEXO_HOME / "config" / "schedule.json").exists() else {}
|
|
1560
|
+
if not schedule_data.get("auto_update", True):
|
|
1561
|
+
result["skipped_reason"] = "auto_update disabled in schedule.json"
|
|
1562
|
+
_write_update_summary(result)
|
|
1563
|
+
return result
|
|
1564
|
+
if now - float(last_check.get("timestamp", 0) or 0) < CHECK_COOLDOWN_SECONDS:
|
|
1565
|
+
result["skipped_reason"] = "cooldown"
|
|
1566
|
+
_write_update_summary(result)
|
|
1567
|
+
return result
|
|
1568
|
+
busy_reason = _runtime_busy_reason()
|
|
1569
|
+
if busy_reason:
|
|
1570
|
+
result["deferred_reason"] = busy_reason
|
|
1571
|
+
_write_last_check({"timestamp": now, "mode": "sync", "deferred_reason": busy_reason})
|
|
1572
|
+
_write_update_summary(result)
|
|
1573
|
+
return result
|
|
1574
|
+
|
|
1575
|
+
source_status = _source_repo_status(repo_dir)
|
|
1576
|
+
if source_status.get("dirty"):
|
|
1577
|
+
result["deferred_reason"] = "source repo has local changes"
|
|
1578
|
+
elif source_status.get("diverged"):
|
|
1579
|
+
result["deferred_reason"] = "source repo diverged from upstream"
|
|
1580
|
+
elif source_status.get("behind"):
|
|
1581
|
+
result["checked"] = True
|
|
1582
|
+
sync_result = manual_sync_update(interactive=False, allow_source_pull=True)
|
|
1583
|
+
result["updated"] = bool(sync_result.get("ok") and sync_result.get("updated"))
|
|
1584
|
+
result["actions"].extend(sync_result.get("actions", []))
|
|
1585
|
+
if sync_result.get("error"):
|
|
1586
|
+
result["error"] = sync_result["error"]
|
|
1587
|
+
else:
|
|
1588
|
+
result["checked"] = True
|
|
1589
|
+
|
|
1590
|
+
_write_last_check({
|
|
1591
|
+
"timestamp": now,
|
|
1592
|
+
"mode": "sync",
|
|
1593
|
+
"updated": result["updated"],
|
|
1594
|
+
"deferred_reason": result["deferred_reason"],
|
|
1595
|
+
})
|
|
1596
|
+
except Exception as e:
|
|
1597
|
+
result["error"] = f"sync startup preflight failed: {e}"
|
|
1598
|
+
_write_update_summary(result)
|
|
1599
|
+
return result
|
|
1600
|
+
|
|
1601
|
+
result = auto_update_check()
|
|
1602
|
+
result["entrypoint"] = entrypoint
|
|
1603
|
+
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1604
|
+
if power_result.get("ok"):
|
|
1605
|
+
actions = result.setdefault("actions", [])
|
|
1606
|
+
actions.append(f"power:{power_result.get('action')}")
|
|
1607
|
+
result["updated"] = bool(result.get("git_update"))
|
|
1608
|
+
_write_update_summary(result)
|
|
1609
|
+
return result
|