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 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** | Always (daemon) | Keeps machine awake for nocturnal processes (caffeinate/systemd-inhibit) |
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 script re-runs everything in order when it wakes.
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
- const migSchedule = loadOrCreateSchedule(NEXO_HOME);
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
- const schedule = loadOrCreateSchedule(NEXO_HOME);
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.0",
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": {
@@ -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