nexo-brain 2.6.4 → 2.6.6
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 +2 -2
- package/README.md +106 -60
- package/bin/nexo-brain.js +167 -23
- package/package.json +2 -2
- package/src/auto_update.py +21 -5
- package/src/cli.py +34 -3
- package/src/cron_recovery.py +48 -1
- package/src/doctor/providers/runtime.py +5 -0
- package/src/hooks/caffeinate-guard.sh +3 -3
- package/src/runtime_power.py +553 -8
- package/src/scripts/nexo-prevent-sleep.sh +8 -2
- package/src/nexo.db +0 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.6",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO —
|
|
5
|
+
"description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nexo-brain": "./bin/nexo-brain.js",
|
|
8
8
|
"nexo": "./bin/nexo.js"
|
package/src/auto_update.py
CHANGED
|
@@ -1378,8 +1378,8 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
|
|
|
1378
1378
|
"init_db = getattr(db, 'init_db', None); "
|
|
1379
1379
|
"init_db() if callable(init_db) else None; "
|
|
1380
1380
|
"import script_registry; "
|
|
1381
|
-
"
|
|
1382
|
-
"
|
|
1381
|
+
"reconcile_scripts = getattr(script_registry, 'reconcile_personal_scripts', None); "
|
|
1382
|
+
"reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else None"
|
|
1383
1383
|
),
|
|
1384
1384
|
],
|
|
1385
1385
|
cwd=str(dest),
|
|
@@ -1521,14 +1521,27 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1521
1521
|
"claude_md_update": None,
|
|
1522
1522
|
"migrations": [],
|
|
1523
1523
|
"power_policy": None,
|
|
1524
|
+
"power_message": None,
|
|
1525
|
+
"full_disk_access_status": None,
|
|
1526
|
+
"full_disk_access_message": None,
|
|
1524
1527
|
"error": None,
|
|
1525
1528
|
}
|
|
1526
1529
|
|
|
1527
|
-
from runtime_power import
|
|
1530
|
+
from runtime_power import (
|
|
1531
|
+
apply_power_policy,
|
|
1532
|
+
ensure_power_policy_choice,
|
|
1533
|
+
get_power_policy,
|
|
1534
|
+
ensure_full_disk_access_choice,
|
|
1535
|
+
get_full_disk_access_status,
|
|
1536
|
+
)
|
|
1528
1537
|
|
|
1529
1538
|
choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
|
|
1530
1539
|
power_result = apply_power_policy(choice.get("policy"))
|
|
1540
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason=entrypoint)
|
|
1531
1541
|
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1542
|
+
result["power_message"] = power_result.get("message")
|
|
1543
|
+
result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
|
|
1544
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
1532
1545
|
if power_result.get("ok"):
|
|
1533
1546
|
result["actions"].append(f"power:{power_result.get('action')}")
|
|
1534
1547
|
|
|
@@ -1536,7 +1549,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1536
1549
|
if src_dir is not None and repo_dir is not None:
|
|
1537
1550
|
try:
|
|
1538
1551
|
from db import init_db
|
|
1539
|
-
from script_registry import
|
|
1552
|
+
from script_registry import reconcile_personal_scripts
|
|
1540
1553
|
|
|
1541
1554
|
_run_db_migrations()
|
|
1542
1555
|
result["migrations"] = run_file_migrations()
|
|
@@ -1546,7 +1559,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1546
1559
|
_ensure_runtime_cli_wrapper()
|
|
1547
1560
|
_ensure_runtime_cli_in_shell()
|
|
1548
1561
|
init_db()
|
|
1549
|
-
|
|
1562
|
+
reconcile_personal_scripts(dry_run=False)
|
|
1550
1563
|
result["actions"].append("db+personal-sync")
|
|
1551
1564
|
except Exception as e:
|
|
1552
1565
|
result["error"] = str(e)
|
|
@@ -1601,6 +1614,9 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1601
1614
|
result = auto_update_check()
|
|
1602
1615
|
result["entrypoint"] = entrypoint
|
|
1603
1616
|
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1617
|
+
result["power_message"] = power_result.get("message")
|
|
1618
|
+
result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
|
|
1619
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
1604
1620
|
if power_result.get("ok"):
|
|
1605
1621
|
actions = result.setdefault("actions", [])
|
|
1606
1622
|
actions.append(f"power:{power_result.get('action')}")
|
package/src/cli.py
CHANGED
|
@@ -451,7 +451,13 @@ def _update(args):
|
|
|
451
451
|
- Packaged/runtime-only install: delegate to plugins.update handle_update()
|
|
452
452
|
"""
|
|
453
453
|
from auto_update import manual_sync_update, _resolve_sync_source
|
|
454
|
-
from runtime_power import
|
|
454
|
+
from runtime_power import (
|
|
455
|
+
ensure_power_policy_choice,
|
|
456
|
+
apply_power_policy,
|
|
457
|
+
format_power_policy_label,
|
|
458
|
+
ensure_full_disk_access_choice,
|
|
459
|
+
format_full_disk_access_label,
|
|
460
|
+
)
|
|
455
461
|
|
|
456
462
|
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
457
463
|
|
|
@@ -472,24 +478,43 @@ def _update(args):
|
|
|
472
478
|
result = handle_update()
|
|
473
479
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
474
480
|
power_result = apply_power_policy(choice.get("policy"))
|
|
481
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
475
482
|
if args.json:
|
|
476
483
|
print(json.dumps({
|
|
477
484
|
"mode": "packaged",
|
|
478
485
|
"message": result,
|
|
479
486
|
"power_policy": choice.get("policy"),
|
|
480
487
|
"power_action": power_result.get("action"),
|
|
488
|
+
"power_details": power_result.get("details"),
|
|
489
|
+
"full_disk_access_status": fda_choice.get("status"),
|
|
490
|
+
"full_disk_access_reasons": fda_choice.get("reasons"),
|
|
491
|
+
"full_disk_access_message": fda_choice.get("message"),
|
|
481
492
|
}, indent=2, ensure_ascii=False))
|
|
482
493
|
else:
|
|
483
494
|
print(result)
|
|
484
495
|
if choice.get("prompted"):
|
|
485
|
-
print(f"Power policy: {choice.get('policy')}")
|
|
496
|
+
print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
497
|
+
if power_result.get("message"):
|
|
498
|
+
print(f"Power helper: {power_result.get('message')}")
|
|
499
|
+
if fda_choice.get("prompted"):
|
|
500
|
+
print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
501
|
+
if fda_choice.get("message"):
|
|
502
|
+
print(f"Full Disk Access: {fda_choice.get('message')}")
|
|
486
503
|
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
487
504
|
|
|
488
505
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
489
506
|
power_result = apply_power_policy(choice.get("policy"))
|
|
507
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
490
508
|
result = manual_sync_update(interactive=interactive, allow_source_pull=True)
|
|
491
509
|
result["power_policy"] = choice.get("policy")
|
|
492
510
|
result["power_action"] = power_result.get("action")
|
|
511
|
+
result["power_details"] = power_result.get("details")
|
|
512
|
+
result["full_disk_access_status"] = fda_choice.get("status")
|
|
513
|
+
result["full_disk_access_reasons"] = fda_choice.get("reasons")
|
|
514
|
+
if power_result.get("message"):
|
|
515
|
+
result["power_message"] = power_result.get("message")
|
|
516
|
+
if fda_choice.get("message"):
|
|
517
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
493
518
|
if args.json:
|
|
494
519
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
495
520
|
else:
|
|
@@ -502,7 +527,13 @@ def _update(args):
|
|
|
502
527
|
if result.get("pulled_source"):
|
|
503
528
|
print(" Source repo: pulled latest fast-forward before sync")
|
|
504
529
|
if choice.get("prompted"):
|
|
505
|
-
print(f" Power policy: {choice.get('policy')}")
|
|
530
|
+
print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
531
|
+
if power_result.get("message"):
|
|
532
|
+
print(f" Power helper: {power_result.get('message')}")
|
|
533
|
+
if fda_choice.get("prompted"):
|
|
534
|
+
print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
535
|
+
if fda_choice.get("message"):
|
|
536
|
+
print(f" Full Disk Access: {fda_choice.get('message')}")
|
|
506
537
|
else:
|
|
507
538
|
print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
|
|
508
539
|
return 0 if result.get("ok") else 1
|
package/src/cron_recovery.py
CHANGED
|
@@ -272,6 +272,48 @@ def latest_successful_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> d
|
|
|
272
272
|
return result
|
|
273
273
|
|
|
274
274
|
|
|
275
|
+
def active_started_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> dict[str, datetime]:
|
|
276
|
+
"""Return currently open cron_runs keyed by cron_id.
|
|
277
|
+
|
|
278
|
+
A run is considered active if it has started but not yet recorded a final
|
|
279
|
+
exit/ended timestamp. Catchup uses this to avoid relaunching the same cron
|
|
280
|
+
window while another invocation is already in flight.
|
|
281
|
+
"""
|
|
282
|
+
if not cron_ids or not db_path.is_file():
|
|
283
|
+
return {}
|
|
284
|
+
conn = None
|
|
285
|
+
try:
|
|
286
|
+
conn = sqlite3.connect(str(db_path), timeout=2)
|
|
287
|
+
conn.row_factory = sqlite3.Row
|
|
288
|
+
placeholders = ",".join("?" for _ in cron_ids)
|
|
289
|
+
rows = conn.execute(
|
|
290
|
+
f"""
|
|
291
|
+
SELECT c1.cron_id, c1.started_at
|
|
292
|
+
FROM cron_runs c1
|
|
293
|
+
JOIN (
|
|
294
|
+
SELECT cron_id, MAX(id) AS max_id
|
|
295
|
+
FROM cron_runs
|
|
296
|
+
WHERE cron_id IN ({placeholders})
|
|
297
|
+
AND (exit_code IS NULL OR ended_at IS NULL)
|
|
298
|
+
GROUP BY cron_id
|
|
299
|
+
) latest ON latest.max_id = c1.id
|
|
300
|
+
""",
|
|
301
|
+
tuple(cron_ids),
|
|
302
|
+
).fetchall()
|
|
303
|
+
except Exception:
|
|
304
|
+
return {}
|
|
305
|
+
finally:
|
|
306
|
+
with contextlib.suppress(Exception):
|
|
307
|
+
conn.close()
|
|
308
|
+
|
|
309
|
+
result: dict[str, datetime] = {}
|
|
310
|
+
for row in rows:
|
|
311
|
+
parsed = _parse_timestamp(row["started_at"], assume_utc=True)
|
|
312
|
+
if parsed is not None:
|
|
313
|
+
result[row["cron_id"]] = parsed
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
|
|
275
317
|
def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
|
|
276
318
|
state = _load_json(state_file, {})
|
|
277
319
|
if not isinstance(state, dict):
|
|
@@ -315,6 +357,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
315
357
|
crons = load_enabled_crons() + load_managed_personal_crons()
|
|
316
358
|
contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
|
|
317
359
|
successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
|
|
360
|
+
active_runs = active_started_runs(list(contracts), db_path=DB_PATH)
|
|
318
361
|
legacy = legacy_state_runs(state_file=STATE_FILE)
|
|
319
362
|
candidates: list[dict] = []
|
|
320
363
|
|
|
@@ -341,8 +384,10 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
341
384
|
continue
|
|
342
385
|
due_at = now - timedelta(seconds=interval_seconds)
|
|
343
386
|
last_success = successes.get(cron_id) or legacy.get(cron_id)
|
|
387
|
+
active_started = active_runs.get(cron_id)
|
|
344
388
|
age_seconds = max(int((now - due_at).total_seconds()), 0)
|
|
345
|
-
|
|
389
|
+
is_inflight = active_started is not None and active_started >= due_at
|
|
390
|
+
missed = (last_success is None or last_success < due_at) and not is_inflight
|
|
346
391
|
within_window = contract["max_catchup_age"] <= 0 or age_seconds <= contract["max_catchup_age"]
|
|
347
392
|
|
|
348
393
|
candidates.append({
|
|
@@ -354,8 +399,10 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
354
399
|
"schedule": schedule,
|
|
355
400
|
"last_due_at": due_at,
|
|
356
401
|
"last_success_at": last_success,
|
|
402
|
+
"active_started_at": active_started,
|
|
357
403
|
"age_seconds": age_seconds,
|
|
358
404
|
"missed": missed,
|
|
405
|
+
"inflight": is_inflight,
|
|
359
406
|
"within_window": within_window,
|
|
360
407
|
})
|
|
361
408
|
|
|
@@ -747,6 +747,11 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
747
747
|
"and treat recent 'Operation not permitted' against Documents/Desktop/Downloads as a TCC/runtime path issue."
|
|
748
748
|
),
|
|
749
749
|
)
|
|
750
|
+
if tcc_risk:
|
|
751
|
+
check.repair_plan.append(
|
|
752
|
+
"On macOS, grant Full Disk Access manually if protected folders are required; "
|
|
753
|
+
"NEXO can only open the System Settings pane and verify best effort"
|
|
754
|
+
)
|
|
750
755
|
|
|
751
756
|
if fix:
|
|
752
757
|
sync_ok, sync_evidence = _sync_launchagents_from_manifest()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# NEXO Caffeinate Guard — keeps the Mac awake so nocturnal processes run on schedule.
|
|
3
3
|
# Runs as a LaunchAgent with KeepAlive=true. If killed, launchd restarts it.
|
|
4
4
|
#
|
|
5
|
-
# Uses
|
|
6
|
-
#
|
|
5
|
+
# Uses the native macOS caffeinate helper. Closed-lid behavior remains
|
|
6
|
+
# best-effort and depends on the host setup.
|
|
7
7
|
|
|
8
|
-
exec caffeinate -
|
|
8
|
+
exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'
|