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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.4",
3
+ "version": "2.6.6",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
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"
@@ -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
- "sync_scripts = getattr(script_registry, 'sync_personal_scripts', None); "
1382
- "sync_scripts() if callable(sync_scripts) else None"
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 apply_power_policy, ensure_power_policy_choice, get_power_policy
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 sync_personal_scripts
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
- sync_personal_scripts()
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 ensure_power_policy_choice, apply_power_policy
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
@@ -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
- missed = last_success is None or last_success < due_at
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 caffeinate -s (prevent system sleep) with -i (prevent idle sleep).
6
- # The Mac screen can turn off but the system stays awake.
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 -s -i -w $$
8
+ exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'