nexo-brain 2.6.6 → 2.6.9

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.6",
4
- "description": "Local cognitive runtime for Claude Code persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
3
+ "version": "2.6.9",
4
+ "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
7
7
  "email": "info@nexo-brain.com",
package/bin/nexo-brain.js CHANGED
@@ -32,6 +32,7 @@ const LAUNCH_AGENTS = path.join(
32
32
  "LaunchAgents"
33
33
  );
34
34
  const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
35
+ const PUBLIC_CONTRIBUTION_UPSTREAM = "wazionapps/nexo";
35
36
 
36
37
  function isEphemeralInstall(nexoHome) {
37
38
  const homeDir = require("os").homedir();
@@ -433,6 +434,22 @@ function getDefaultSchedule(timezone) {
433
434
  full_disk_access_status: "unset",
434
435
  full_disk_access_status_version: 1,
435
436
  full_disk_access_reasons: [],
437
+ public_contribution: {
438
+ enabled: false,
439
+ mode: "unset",
440
+ consent_version: 1,
441
+ github_user: "",
442
+ upstream_repo: PUBLIC_CONTRIBUTION_UPSTREAM,
443
+ fork_repo: "",
444
+ machine_id: crypto.createHash("sha1").update(require("os").hostname()).digest("hex").slice(0, 12),
445
+ active_pr_url: "",
446
+ active_pr_number: null,
447
+ active_branch: "",
448
+ status: "unset",
449
+ cooldown_until: "",
450
+ last_run_at: "",
451
+ last_result: "",
452
+ },
436
453
  processes: {
437
454
  "cognitive-decay": { hour: 3, minute: 0 },
438
455
  "postmortem": { hour: 23, minute: 30 },
@@ -445,6 +462,23 @@ function getDefaultSchedule(timezone) {
445
462
  };
446
463
  }
447
464
 
465
+ function normalizePublicContributionConfig(config = {}) {
466
+ const base = getDefaultSchedule().public_contribution;
467
+ const merged = { ...base, ...(config || {}) };
468
+ merged.enabled = Boolean(merged.enabled);
469
+ merged.mode = String(merged.mode || "unset").toLowerCase();
470
+ merged.status = String(merged.status || "unset").toLowerCase();
471
+ merged.github_user = String(merged.github_user || "").trim();
472
+ merged.fork_repo = String(merged.fork_repo || "").trim();
473
+ merged.upstream_repo = String(merged.upstream_repo || PUBLIC_CONTRIBUTION_UPSTREAM).trim() || PUBLIC_CONTRIBUTION_UPSTREAM;
474
+ merged.active_pr_url = String(merged.active_pr_url || "").trim();
475
+ merged.active_branch = String(merged.active_branch || "").trim();
476
+ merged.cooldown_until = String(merged.cooldown_until || "").trim();
477
+ merged.last_run_at = String(merged.last_run_at || "").trim();
478
+ merged.last_result = String(merged.last_result || "").trim();
479
+ return merged;
480
+ }
481
+
448
482
  async function maybeConfigurePowerPolicy(schedule, useDefaults) {
449
483
  const current = String((schedule && schedule.power_policy) || "unset").toLowerCase();
450
484
  if (current && current !== "unset") {
@@ -477,6 +511,84 @@ async function maybeConfigurePowerPolicy(schedule, useDefaults) {
477
511
  return schedule;
478
512
  }
479
513
 
514
+ function ghLogin() {
515
+ const login = run("gh api user --jq .login 2>/dev/null");
516
+ return (login || "").trim();
517
+ }
518
+
519
+ function ensureFork(login) {
520
+ if (!login) return { ok: false, message: "Missing GitHub login.", forkRepo: "" };
521
+ const forkRepo = `${login}/nexo`;
522
+ const existing = run(`gh repo view "${forkRepo}" --json nameWithOwner 2>/dev/null`);
523
+ if (existing) return { ok: true, message: "", forkRepo };
524
+ const created = run(`gh repo fork "${PUBLIC_CONTRIBUTION_UPSTREAM}" --clone=false --remote=false 2>/dev/null`);
525
+ if (created !== null) return { ok: true, message: "", forkRepo };
526
+ return { ok: false, message: `Could not ensure fork ${forkRepo}.`, forkRepo: "" };
527
+ }
528
+
529
+ async function maybeConfigurePublicContribution(schedule, useDefaults) {
530
+ const current = normalizePublicContributionConfig((schedule && schedule.public_contribution) || {});
531
+ if (current.mode && current.mode !== "unset") {
532
+ schedule.public_contribution = current;
533
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
534
+ return schedule;
535
+ }
536
+
537
+ if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
538
+ schedule.public_contribution = current;
539
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
540
+ return schedule;
541
+ }
542
+
543
+ console.log("");
544
+ log("Optional public contribution mode:");
545
+ log("If enabled, this machine may prepare core NEXO improvements from an isolated checkout and open a Draft PR to the public repository.");
546
+ log("NEXO never auto-merges, and it pauses public evolution on this machine while that Draft PR stays open.");
547
+ log("Public contribution must never publish personal scripts, runtime data, local prompts, logs, or secrets.");
548
+ const answer = (await ask(" Enable public contribution via Draft PRs on this machine? [y/N/later]: ")).trim().toLowerCase();
549
+ if (answer === "y" || answer === "yes") {
550
+ const login = ghLogin();
551
+ if (!login) {
552
+ current.enabled = false;
553
+ current.mode = "pending_auth";
554
+ current.status = "pending_auth";
555
+ current.github_user = "";
556
+ current.fork_repo = "";
557
+ log("GitHub CLI authentication is missing. Contributor mode is pending until 'gh auth login' succeeds.");
558
+ } else {
559
+ const fork = ensureFork(login);
560
+ if (!fork.ok) {
561
+ current.enabled = false;
562
+ current.mode = "pending_auth";
563
+ current.status = "pending_auth";
564
+ current.github_user = login;
565
+ current.fork_repo = "";
566
+ log(fork.message || "Could not ensure a GitHub fork.");
567
+ } else {
568
+ current.enabled = true;
569
+ current.mode = "draft_prs";
570
+ current.status = "active";
571
+ current.github_user = login;
572
+ current.fork_repo = fork.forkRepo;
573
+ }
574
+ }
575
+ } else if (answer === "later" || answer === "l" || answer === "") {
576
+ current.enabled = false;
577
+ current.mode = "unset";
578
+ current.status = "unset";
579
+ } else {
580
+ current.enabled = false;
581
+ current.mode = "off";
582
+ current.status = "off";
583
+ current.github_user = "";
584
+ current.fork_repo = "";
585
+ }
586
+
587
+ schedule.public_contribution = current;
588
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
589
+ return schedule;
590
+ }
591
+
480
592
  /**
481
593
  * Resolve the venv python path for an existing NEXO_HOME installation.
482
594
  */
@@ -980,6 +1092,7 @@ async function main() {
980
1092
  // Regenerate all core LaunchAgents / systemd timers
981
1093
  let migSchedule = loadOrCreateSchedule(NEXO_HOME);
982
1094
  migSchedule = await maybeConfigurePowerPolicy(migSchedule, useDefaults);
1095
+ migSchedule = await maybeConfigurePublicContribution(migSchedule, useDefaults);
983
1096
  const migPython = findVenvPython(NEXO_HOME) || "python3";
984
1097
  migSchedule = await maybeConfigureFullDiskAccess(migSchedule, useDefaults, migPython);
985
1098
  let migOptionals = {};
@@ -1616,6 +1729,7 @@ async function main() {
1616
1729
  );
1617
1730
 
1618
1731
  // Copy source files
1732
+ log("Copying core runtime files...");
1619
1733
  const srcDir = path.join(__dirname, "..", "src");
1620
1734
  const pluginsSrcDir = path.join(srcDir, "plugins");
1621
1735
  const scriptsSrcDir = path.join(srcDir, "scripts");
@@ -1694,6 +1808,7 @@ async function main() {
1694
1808
  fs.writeFileSync(runtimeCliPath, runtimeCli);
1695
1809
  fs.chmodSync(runtimeCliPath, 0o755);
1696
1810
 
1811
+ log("Copying core packages...");
1697
1812
  // Core packages (directories with __init__.py)
1698
1813
  ["db", "cognitive", "doctor"].forEach(pkg => {
1699
1814
  const pkgSrc = path.join(srcDir, pkg);
@@ -1702,6 +1817,7 @@ async function main() {
1702
1817
  }
1703
1818
  });
1704
1819
 
1820
+ log("Copying plugins, scripts, and templates...");
1705
1821
  // Plugins (all .py files in plugins/)
1706
1822
  fs.mkdirSync(path.join(NEXO_HOME, "plugins"), { recursive: true });
1707
1823
  if (fs.existsSync(pluginsSrcDir)) {
@@ -2254,6 +2370,7 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
2254
2370
  log("Setting up automated processes...");
2255
2371
  let schedule = loadOrCreateSchedule(NEXO_HOME);
2256
2372
  schedule = await maybeConfigurePowerPolicy(schedule, useDefaults);
2373
+ schedule = await maybeConfigurePublicContribution(schedule, useDefaults);
2257
2374
  schedule = await maybeConfigureFullDiskAccess(schedule, useDefaults, python);
2258
2375
  const enabledOptionals = { dashboard: doDashboard };
2259
2376
  if (isEphemeralInstall(NEXO_HOME)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.6",
3
+ "version": "2.6.9",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
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": {
@@ -17,7 +17,7 @@ os.environ["NEXO_SKIP_FS_INDEX"] = "1" # Skip FTS rebuild on import
17
17
 
18
18
  from db import (
19
19
  init_db, get_db, get_diary_draft, delete_diary_draft,
20
- get_orphan_sessions, write_session_diary, now_epoch,
20
+ get_orphan_sessions, read_checkpoint, write_session_diary, now_epoch,
21
21
  SESSION_STALE_SECONDS,
22
22
  )
23
23
 
@@ -67,9 +67,18 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
67
67
  context_hint = draft.get("last_context_hint", "")
68
68
  hb_count = draft.get("heartbeat_count", 0)
69
69
 
70
+ checkpoint = read_checkpoint(sid) or {}
70
71
  summary_parts = []
71
72
  if draft.get("summary_draft"):
72
73
  summary_parts.append(draft["summary_draft"])
74
+ if task and task not in " ".join(summary_parts):
75
+ summary_parts.append(f"Final task: {task}")
76
+ if context_hint:
77
+ summary_parts.append(f"Latest context: {context_hint[:300]}")
78
+ if checkpoint.get("current_goal"):
79
+ summary_parts.append(f"Current goal: {str(checkpoint['current_goal'])[:300]}")
80
+ if checkpoint.get("next_step"):
81
+ summary_parts.append(f"Next step was: {str(checkpoint['next_step'])[:240]}")
73
82
 
74
83
  tool_summary = get_tool_log_summary(sid)
75
84
  if tool_summary:
@@ -98,6 +107,10 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
98
107
  context_next = f"Last topic: {context_hint}"
99
108
  if tasks:
100
109
  context_next += f" | Tasks: {', '.join(tasks[-5:])}"
110
+ if checkpoint.get("reasoning_thread"):
111
+ context_next += f" | Reasoning: {str(checkpoint['reasoning_thread'])[:240]}"
112
+ if checkpoint.get("active_files"):
113
+ context_next += f" | Active files: {str(checkpoint['active_files'])[:180]}"
101
114
 
102
115
  write_session_diary(
103
116
  session_id=sid,
@@ -131,12 +144,19 @@ def main():
131
144
  if draft:
132
145
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
133
146
  else:
147
+ checkpoint = read_checkpoint(sid) or {}
148
+ tool_summary = get_tool_log_summary(sid)
149
+ summary_parts = [f"Auto-closed session. Task: {session.get('task', 'unknown')}"]
150
+ if checkpoint.get("current_goal"):
151
+ summary_parts.append(f"Current goal: {str(checkpoint['current_goal'])[:300]}")
152
+ if tool_summary:
153
+ summary_parts.append(tool_summary)
134
154
  write_session_diary(
135
155
  session_id=sid,
136
156
  decisions="No decisions logged",
137
- summary=f"Auto-closed session. Task: {session.get('task', 'unknown')}",
138
- context_next="",
139
- mental_state="[auto-close] No draft available. Minimal diary.",
157
+ summary=" | ".join(summary_parts),
158
+ context_next=str(checkpoint.get("next_step") or ""),
159
+ mental_state="[auto-close] No draft available. Diary reconstructed from task/checkpoint/tool logs.",
140
160
  self_critique="[auto-close] Session terminated without diary or draft.",
141
161
  source="auto-close",
142
162
  )
@@ -1236,7 +1236,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
1236
1236
  shutil.copy2(str(item), str(target))
1237
1237
 
1238
1238
 
1239
- def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME) -> dict:
1239
+ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
1240
1240
  import shutil
1241
1241
 
1242
1242
  packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
@@ -1253,6 +1253,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1253
1253
  copied_packages = 0
1254
1254
  copied_files = 0
1255
1255
 
1256
+ _emit_progress(progress_fn, "Copying core packages...")
1256
1257
  for pkg in packages:
1257
1258
  pkg_src = src_dir / pkg
1258
1259
  pkg_dest = dest / pkg
@@ -1266,12 +1267,14 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1266
1267
  )
1267
1268
  copied_packages += 1
1268
1269
 
1270
+ _emit_progress(progress_fn, "Copying core modules...")
1269
1271
  for name in flat_files:
1270
1272
  src_file = src_dir / name
1271
1273
  if src_file.is_file():
1272
1274
  shutil.copy2(str(src_file), str(dest / name))
1273
1275
  copied_files += 1
1274
1276
 
1277
+ _emit_progress(progress_fn, "Copying plugin modules...")
1275
1278
  plugins_src = src_dir / "plugins"
1276
1279
  plugins_dest = dest / "plugins"
1277
1280
  if plugins_src.is_dir():
@@ -1280,6 +1283,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1280
1283
  if item.is_file() and item.suffix == ".py":
1281
1284
  shutil.copy2(str(item), str(plugins_dest / item.name))
1282
1285
 
1286
+ _emit_progress(progress_fn, "Copying scripts...")
1283
1287
  scripts_src = src_dir / "scripts"
1284
1288
  scripts_dest = dest / "scripts"
1285
1289
  if scripts_src.is_dir():
@@ -1297,6 +1301,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1297
1301
  if item.suffix == ".sh":
1298
1302
  dst.chmod(0o755)
1299
1303
 
1304
+ _emit_progress(progress_fn, "Copying templates and version metadata...")
1300
1305
  templates_src = repo_dir / "templates"
1301
1306
  templates_dest = dest / "templates"
1302
1307
  if templates_src.is_dir():
@@ -1317,6 +1322,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1317
1322
  except Exception:
1318
1323
  pass
1319
1324
 
1325
+ _emit_progress(progress_fn, "Copying core skills and runtime wrapper...")
1320
1326
  skills_src = src_dir / "skills"
1321
1327
  skills_dest = dest / "skills-core"
1322
1328
  if skills_src.is_dir():
@@ -1365,10 +1371,11 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
1365
1371
  return False
1366
1372
 
1367
1373
 
1368
- def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1374
+ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bool, list[str]]:
1369
1375
  actions: list[str] = []
1370
1376
  env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(dest)}
1371
1377
  try:
1378
+ _emit_progress(progress_fn, "Initializing database and reconciling personal schedules...")
1372
1379
  init_result = subprocess.run(
1373
1380
  [
1374
1381
  sys.executable,
@@ -1394,6 +1401,7 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1394
1401
  except Exception as e:
1395
1402
  return False, [f"runtime init error: {e}"]
1396
1403
 
1404
+ _emit_progress(progress_fn, "Reconciling Python dependencies...")
1397
1405
  if _reinstall_runtime_pip_deps(dest):
1398
1406
  actions.append("pip-deps")
1399
1407
  else:
@@ -1402,6 +1410,7 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1402
1410
  sync_path = dest / "crons" / "sync.py"
1403
1411
  if sync_path.is_file():
1404
1412
  try:
1413
+ _emit_progress(progress_fn, "Syncing core cron definitions...")
1405
1414
  sync_result = subprocess.run(
1406
1415
  [sys.executable, str(sync_path)],
1407
1416
  cwd=str(dest),
@@ -1418,10 +1427,12 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1418
1427
 
1419
1428
  from runtime_power import apply_power_policy
1420
1429
 
1430
+ _emit_progress(progress_fn, "Refreshing runtime power helper...")
1421
1431
  power_result = apply_power_policy()
1422
1432
  if power_result.get("ok"):
1423
1433
  actions.append(f"power:{power_result.get('action')}")
1424
1434
 
1435
+ _emit_progress(progress_fn, "Verifying runtime imports...")
1425
1436
  verify = subprocess.run(
1426
1437
  [sys.executable, "-c", "import server"],
1427
1438
  cwd=str(dest),
@@ -1460,11 +1471,20 @@ def _write_update_summary(summary: dict):
1460
1471
  _log(f"Failed to write update summary: {e}")
1461
1472
 
1462
1473
 
1463
- def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True) -> dict:
1474
+ def _emit_progress(progress_fn, message: str) -> None:
1475
+ if callable(progress_fn):
1476
+ try:
1477
+ progress_fn(message)
1478
+ except Exception:
1479
+ pass
1480
+
1481
+
1482
+ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True, progress_fn=None) -> dict:
1464
1483
  src_dir, repo_dir = _resolve_sync_source()
1465
1484
  if src_dir is None or repo_dir is None:
1466
1485
  return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
1467
1486
 
1487
+ _emit_progress(progress_fn, "Checking recorded source repository...")
1468
1488
  source_status = _source_repo_status(repo_dir)
1469
1489
  pulled = False
1470
1490
  old_head = source_status.get("local_head")
@@ -1474,17 +1494,21 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
1474
1494
  elif source_status.get("diverged"):
1475
1495
  _log("Source repo diverged; syncing local tree without remote pull.")
1476
1496
  elif source_status.get("behind"):
1497
+ _emit_progress(progress_fn, "Pulling latest source changes...")
1477
1498
  rc, _, pull_err = _git_in_repo(repo_dir, "pull", "--ff-only", timeout=60)
1478
1499
  if rc != 0:
1479
1500
  return {"ok": False, "mode": "sync", "error": pull_err or "git pull failed"}
1480
1501
  pulled = True
1481
1502
 
1503
+ _emit_progress(progress_fn, "Creating runtime backups...")
1482
1504
  db_backup_dir = _backup_dbs()
1483
1505
  tree_backup_dir = _backup_runtime_tree(NEXO_HOME)
1484
1506
  sync_result = {"ok": False, "mode": "sync", "pulled_source": pulled, "backup_dir": db_backup_dir, "tree_backup": tree_backup_dir}
1485
1507
  try:
1486
- copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME)
1487
- ok, actions = _run_runtime_post_sync(NEXO_HOME)
1508
+ _emit_progress(progress_fn, "Syncing runtime files...")
1509
+ copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME, progress_fn=progress_fn)
1510
+ _emit_progress(progress_fn, "Reconciling runtime state...")
1511
+ ok, actions = _run_runtime_post_sync(NEXO_HOME, progress_fn=progress_fn)
1488
1512
  if not ok:
1489
1513
  raise RuntimeError("; ".join(actions))
1490
1514
  sync_result.update({
@@ -1496,7 +1520,9 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
1496
1520
  "source": copy_stats["source"],
1497
1521
  "repo": copy_stats["repo"],
1498
1522
  })
1523
+ _emit_progress(progress_fn, "Runtime update completed.")
1499
1524
  except Exception as e:
1525
+ _emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
1500
1526
  _restore_runtime_tree(tree_backup_dir, NEXO_HOME)
1501
1527
  if db_backup_dir:
1502
1528
  _restore_dbs(db_backup_dir)
package/src/cli.py CHANGED
@@ -22,6 +22,7 @@ Entry points:
22
22
  nexo skills approve ID [--execution-level ...] [--approved-by ...] [--json]
23
23
  nexo skills featured [--limit N] [--json]
24
24
  nexo skills evolution [--json]
25
+ nexo contributor status|on|off [--json]
25
26
  nexo doctor [--tier boot|runtime|deep|all] [--json] [--fix]
26
27
  """
27
28
  from __future__ import annotations
@@ -458,8 +459,18 @@ def _update(args):
458
459
  ensure_full_disk_access_choice,
459
460
  format_full_disk_access_label,
460
461
  )
462
+ from public_contribution import (
463
+ ensure_public_contribution_choice,
464
+ format_public_contribution_label,
465
+ )
461
466
 
462
467
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
468
+ progress_messages: list[str] = []
469
+
470
+ def progress(message: str) -> None:
471
+ progress_messages.append(message)
472
+ if not args.json:
473
+ print(f"[NEXO] {message}", flush=True)
463
474
 
464
475
  dest = NEXO_HOME
465
476
  src_dir, repo_dir = _resolve_sync_source()
@@ -475,20 +486,25 @@ def _update(args):
475
486
  )
476
487
  return 1
477
488
 
478
- result = handle_update()
489
+ result = handle_update(progress_fn=progress)
479
490
  choice = ensure_power_policy_choice(interactive=interactive, reason="update")
480
491
  power_result = apply_power_policy(choice.get("policy"))
481
492
  fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
493
+ contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
482
494
  if args.json:
483
495
  print(json.dumps({
484
496
  "mode": "packaged",
485
497
  "message": result,
498
+ "progress": progress_messages,
486
499
  "power_policy": choice.get("policy"),
487
500
  "power_action": power_result.get("action"),
488
501
  "power_details": power_result.get("details"),
489
502
  "full_disk_access_status": fda_choice.get("status"),
490
503
  "full_disk_access_reasons": fda_choice.get("reasons"),
491
504
  "full_disk_access_message": fda_choice.get("message"),
505
+ "public_contribution_mode": contrib_choice.get("mode"),
506
+ "public_contribution_status": contrib_choice.get("status"),
507
+ "public_contribution_message": contrib_choice.get("message"),
492
508
  }, indent=2, ensure_ascii=False))
493
509
  else:
494
510
  print(result)
@@ -500,21 +516,31 @@ def _update(args):
500
516
  print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
501
517
  if fda_choice.get("message"):
502
518
  print(f"Full Disk Access: {fda_choice.get('message')}")
519
+ if contrib_choice.get("prompted"):
520
+ print(f"Contributor mode: {format_public_contribution_label(contrib_choice)}")
521
+ if contrib_choice.get("message"):
522
+ print(f"Contributor mode: {contrib_choice.get('message')}")
503
523
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
504
524
 
505
525
  choice = ensure_power_policy_choice(interactive=interactive, reason="update")
506
526
  power_result = apply_power_policy(choice.get("policy"))
507
527
  fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
508
- result = manual_sync_update(interactive=interactive, allow_source_pull=True)
528
+ contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
529
+ result = manual_sync_update(interactive=interactive, allow_source_pull=True, progress_fn=progress)
509
530
  result["power_policy"] = choice.get("policy")
510
531
  result["power_action"] = power_result.get("action")
511
532
  result["power_details"] = power_result.get("details")
512
533
  result["full_disk_access_status"] = fda_choice.get("status")
513
534
  result["full_disk_access_reasons"] = fda_choice.get("reasons")
535
+ result["public_contribution_mode"] = contrib_choice.get("mode")
536
+ result["public_contribution_status"] = contrib_choice.get("status")
537
+ result["progress"] = progress_messages
514
538
  if power_result.get("message"):
515
539
  result["power_message"] = power_result.get("message")
516
540
  if fda_choice.get("message"):
517
541
  result["full_disk_access_message"] = fda_choice.get("message")
542
+ if contrib_choice.get("message"):
543
+ result["public_contribution_message"] = contrib_choice.get("message")
518
544
  if args.json:
519
545
  print(json.dumps(result, indent=2, ensure_ascii=False))
520
546
  else:
@@ -534,11 +560,80 @@ def _update(args):
534
560
  print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
535
561
  if fda_choice.get("message"):
536
562
  print(f" Full Disk Access: {fda_choice.get('message')}")
563
+ if contrib_choice.get("prompted"):
564
+ print(f" Contributor mode: {format_public_contribution_label(contrib_choice)}")
565
+ if contrib_choice.get("message"):
566
+ print(f" Contributor mode: {contrib_choice.get('message')}")
537
567
  else:
538
568
  print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
539
569
  return 0 if result.get("ok") else 1
540
570
 
541
571
 
572
+ def _contributor_status(args):
573
+ from public_contribution import (
574
+ format_public_contribution_label,
575
+ load_public_contribution_config,
576
+ refresh_public_contribution_state,
577
+ )
578
+
579
+ config = refresh_public_contribution_state(load_public_contribution_config())
580
+ payload = {
581
+ "enabled": bool(config.get("enabled")),
582
+ "mode": config.get("mode"),
583
+ "status": config.get("status"),
584
+ "label": format_public_contribution_label(config),
585
+ "github_user": config.get("github_user"),
586
+ "fork_repo": config.get("fork_repo"),
587
+ "active_pr_url": config.get("active_pr_url"),
588
+ "active_branch": config.get("active_branch"),
589
+ "cooldown_until": config.get("cooldown_until"),
590
+ "last_result": config.get("last_result"),
591
+ }
592
+ if args.json:
593
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
594
+ else:
595
+ print(f"Contributor mode: {payload['label']}")
596
+ if payload["github_user"]:
597
+ print(f" GitHub user: {payload['github_user']}")
598
+ if payload["fork_repo"]:
599
+ print(f" Fork: {payload['fork_repo']}")
600
+ if payload["active_pr_url"]:
601
+ print(f" Active Draft PR: {payload['active_pr_url']}")
602
+ if payload["cooldown_until"]:
603
+ print(f" Cooldown until: {payload['cooldown_until']}")
604
+ if payload["last_result"]:
605
+ print(f" Last result: {payload['last_result']}")
606
+ return 0
607
+
608
+
609
+ def _contributor_on(args):
610
+ from public_contribution import ensure_public_contribution_choice, format_public_contribution_label
611
+
612
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
613
+ if not interactive:
614
+ print("Contributor mode requires an interactive terminal to confirm GitHub Draft PR consent.", file=sys.stderr)
615
+ return 1
616
+ config = ensure_public_contribution_choice(interactive=True, reason="contributor", force_prompt=True)
617
+ if args.json:
618
+ print(json.dumps(config, indent=2, ensure_ascii=False))
619
+ else:
620
+ print(f"Contributor mode: {format_public_contribution_label(config)}")
621
+ if config.get("message"):
622
+ print(config.get("message"))
623
+ return 0 if config.get("mode") == "draft_prs" else 1
624
+
625
+
626
+ def _contributor_off(args):
627
+ from public_contribution import disable_public_contribution, format_public_contribution_label
628
+
629
+ config = disable_public_contribution()
630
+ if args.json:
631
+ print(json.dumps(config, indent=2, ensure_ascii=False))
632
+ else:
633
+ print(f"Contributor mode: {format_public_contribution_label(config)}")
634
+ return 0
635
+
636
+
542
637
  def _service_control(service_name: str, action: str) -> int:
543
638
  """Control a LaunchAgent/systemd service: on, off, status."""
544
639
  import platform as plat
@@ -767,6 +862,7 @@ Commands:
767
862
  Personal scripts
768
863
  nexo skills list|apply|sync|approve Executable skills
769
864
  nexo update Update installed runtime
865
+ nexo contributor status|on|off Public Draft PR contribution mode
770
866
  nexo dashboard on|off|status Web dashboard control
771
867
 
772
868
  Run 'nexo <command> --help' for details.
@@ -861,6 +957,11 @@ def main():
861
957
  doctor_parser.add_argument("--json", action="store_true", help="JSON output")
862
958
  doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
863
959
 
960
+ # -- contributor --
961
+ contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
962
+ contributor_parser.add_argument("action", choices=["status", "on", "off"], help="Manage contributor mode")
963
+ contributor_parser.add_argument("--json", action="store_true", help="JSON output")
964
+
864
965
  # -- skills --
865
966
  skills_parser = sub.add_parser("skills", help="Skills v2 runtime")
866
967
  skills_sub = skills_parser.add_subparsers(dest="skills_command")
@@ -946,6 +1047,15 @@ def main():
946
1047
  return _update(args)
947
1048
  elif args.command == "doctor":
948
1049
  return _doctor(args)
1050
+ elif args.command == "contributor":
1051
+ if args.action == "status":
1052
+ return _contributor_status(args)
1053
+ elif args.action == "on":
1054
+ return _contributor_on(args)
1055
+ elif args.action == "off":
1056
+ return _contributor_off(args)
1057
+ contributor_parser.print_help()
1058
+ return 0
949
1059
  elif args.command == "skills":
950
1060
  if args.skills_command == "list":
951
1061
  return _skills_list(args)
@@ -573,7 +573,7 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
573
573
  """Read session diary entries.
574
574
 
575
575
  - session_id: returns entries for that specific session
576
- - last_day: returns ALL entries from the most recent day (multi-terminal aware)
576
+ - last_day: returns the recent continuity window (~36h), including the previous evening
577
577
  - last_n: returns last N entries (default)
578
578
  - domain: filter by project context (nexo, other)
579
579
  - include_automated: if False (default), excludes automated sessions (auto-close,
@@ -605,21 +605,11 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
605
605
  (session_id,) + domain_params
606
606
  ).fetchall()
607
607
  elif last_day:
608
- # Get all entries from the most recent calendar day (human sessions only)
609
- if domain:
610
- latest = conn.execute(
611
- f"SELECT date(created_at) as day FROM session_diary WHERE domain = ?{source_clause} ORDER BY created_at DESC LIMIT 1",
612
- (domain,)
613
- ).fetchone()
614
- else:
615
- latest = conn.execute(
616
- f"SELECT date(created_at) as day FROM session_diary WHERE 1=1{source_clause} ORDER BY created_at DESC LIMIT 1"
617
- ).fetchone()
618
- if not latest:
619
- return []
620
608
  rows = conn.execute(
621
- f"SELECT * FROM session_diary WHERE date(created_at) = ?{domain_clause}{source_clause} ORDER BY created_at DESC",
622
- (latest['day'],) + domain_params
609
+ f"SELECT * FROM session_diary "
610
+ f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
611
+ f"ORDER BY created_at DESC",
612
+ domain_params
623
613
  ).fetchall()
624
614
  else:
625
615
  rows = conn.execute(
@@ -770,4 +760,3 @@ def recall(query: str, days: int = 30) -> list[dict]:
770
760
  results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
771
761
  return results[:20]
772
762
 
773
-