nexo-brain 7.1.1 → 7.1.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.
Files changed (176) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/bin/nexo-brain.js +198 -92
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +10 -8
  6. package/src/auto_close_sessions.py +19 -2
  7. package/src/auto_update.py +232 -28
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +181 -1
  11. package/src/cli_email.py +104 -73
  12. package/src/client_sync.py +22 -1
  13. package/src/cognitive/_core.py +5 -3
  14. package/src/core_prompts.py +50 -0
  15. package/src/cron_recovery.py +81 -7
  16. package/src/crons/manifest.json +57 -0
  17. package/src/crons/sync.py +95 -26
  18. package/src/dashboard/app.py +59 -0
  19. package/src/dashboard/templates/base.html +2 -0
  20. package/src/dashboard/templates/feature-disabled.html +27 -0
  21. package/src/db/_email_accounts.py +67 -18
  22. package/src/db/_fts.py +5 -5
  23. package/src/db/_personal_scripts.py +1 -1
  24. package/src/db/_skills.py +3 -3
  25. package/src/doctor/providers/runtime.py +35 -20
  26. package/src/email_config.py +18 -9
  27. package/src/enforcement_classifier.py +3 -12
  28. package/src/evolution_cycle.py +37 -149
  29. package/src/guardian_telemetry.py +3 -2
  30. package/src/hook_guardrails.py +61 -0
  31. package/src/hooks/capture-tool-logs.sh +11 -3
  32. package/src/hooks/daily-briefing-check.sh +7 -2
  33. package/src/hooks/heartbeat-enforcement.py +14 -1
  34. package/src/hooks/heartbeat-posttool.sh +2 -0
  35. package/src/hooks/heartbeat-user-msg.sh +2 -0
  36. package/src/hooks/inbox-hook.sh +6 -2
  37. package/src/hooks/post-compact.sh +12 -4
  38. package/src/hooks/pre-compact.sh +12 -4
  39. package/src/migrate_embeddings.py +5 -3
  40. package/src/nexo_migrate.py +3 -1
  41. package/src/plugin_loader.py +14 -5
  42. package/src/plugins/adaptive_mode.py +4 -1
  43. package/src/plugins/backup.py +32 -20
  44. package/src/plugins/evolution.py +2 -0
  45. package/src/plugins/memory_export.py +6 -1
  46. package/src/plugins/personal_plugins.py +17 -7
  47. package/src/plugins/personal_scripts.py +64 -3
  48. package/src/presets/entities_universal.json +67 -4
  49. package/src/product_mode.py +201 -0
  50. package/src/r14_correction_learning.py +5 -20
  51. package/src/r15_project_context.py +4 -10
  52. package/src/r16_declared_done.py +3 -16
  53. package/src/r17_promise_debt.py +3 -16
  54. package/src/r18_followup_autocomplete.py +5 -7
  55. package/src/r19_project_grep.py +5 -8
  56. package/src/r20_constant_change.py +5 -15
  57. package/src/r21_legacy_path.py +5 -7
  58. package/src/r22_personal_script.py +4 -8
  59. package/src/r23_ssh_without_atlas.py +4 -11
  60. package/src/r23b_deploy_vhost.py +7 -6
  61. package/src/r23c_cwd_mismatch.py +7 -6
  62. package/src/r23d_chown_chmod_recursive.py +6 -6
  63. package/src/r23e_force_push_main.py +5 -6
  64. package/src/r23f_db_no_where.py +5 -6
  65. package/src/r23g_secrets_in_output.py +5 -5
  66. package/src/r23h_shebang_mismatch.py +6 -5
  67. package/src/r23i_auto_deploy_ignored.py +5 -6
  68. package/src/r23j_global_install.py +5 -6
  69. package/src/r23k_script_duplicates_skill.py +7 -6
  70. package/src/r23l_resource_collision.py +7 -6
  71. package/src/r23m_message_duplicate.py +6 -5
  72. package/src/r24_stale_memory.py +4 -9
  73. package/src/r25_nora_maria_read_only.py +5 -10
  74. package/src/r34_identity_coherence.py +6 -13
  75. package/src/r_catalog.py +3 -7
  76. package/src/resonance_map.py +13 -13
  77. package/src/runtime_power.py +29 -80
  78. package/src/script_registry.py +236 -6
  79. package/src/scripts/check-context.py +8 -25
  80. package/src/scripts/deep-sleep/extract.py +6 -10
  81. package/src/scripts/nexo-auto-update.py +27 -4
  82. package/src/scripts/nexo-catchup.py +9 -19
  83. package/src/scripts/nexo-cognitive-decay.py +26 -3
  84. package/src/scripts/nexo-daily-self-audit.py +50 -51
  85. package/src/scripts/nexo-email-migrate-config.py +30 -11
  86. package/src/scripts/nexo-email-monitor.py +97 -238
  87. package/src/scripts/nexo-followup-runner.py +70 -133
  88. package/src/scripts/nexo-hook-record.py +1 -1
  89. package/src/scripts/nexo-immune.py +6 -31
  90. package/src/scripts/nexo-impact-scorer.py +27 -4
  91. package/src/scripts/nexo-learning-housekeep.py +26 -3
  92. package/src/scripts/nexo-learning-validator.py +34 -32
  93. package/src/scripts/nexo-migrate.py +28 -12
  94. package/src/scripts/nexo-morning-agent.py +9 -23
  95. package/src/scripts/nexo-outcome-checker.py +27 -4
  96. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  97. package/src/scripts/nexo-pre-commit.py +28 -0
  98. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  99. package/src/scripts/nexo-reflection.py +33 -3
  100. package/src/scripts/nexo-runtime-preflight.py +27 -2
  101. package/src/scripts/nexo-send-reply.py +10 -8
  102. package/src/scripts/nexo-sleep.py +11 -25
  103. package/src/scripts/nexo-synthesis.py +7 -40
  104. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  105. package/src/scripts/nexo-watchdog.sh +23 -17
  106. package/src/scripts/phase_guardian_analysis.py +27 -4
  107. package/src/server.py +14 -3
  108. package/src/storage_router.py +8 -6
  109. package/src/tools_drive.py +5 -13
  110. package/src/tools_guardian.py +3 -4
  111. package/src/tools_menu.py +2 -2
  112. package/src/tools_reminders_crud.py +17 -0
  113. package/src/tools_sessions.py +1 -4
  114. package/src/user_context.py +3 -6
  115. package/src/user_data_portability.py +31 -23
  116. package/templates/CLAUDE.md.template +11 -3
  117. package/templates/CODEX.AGENTS.md.template +11 -3
  118. package/templates/core-prompts/catchup-assessment.md +19 -0
  119. package/templates/core-prompts/check-context.md +24 -0
  120. package/templates/core-prompts/daily-self-audit.md +42 -0
  121. package/templates/core-prompts/daily-synthesis.md +40 -0
  122. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  123. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  124. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  125. package/templates/core-prompts/email-monitor.md +202 -0
  126. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  127. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  128. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  129. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  130. package/templates/core-prompts/evolution-weekly.md +71 -0
  131. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  132. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  133. package/templates/core-prompts/followup-runner.md +74 -0
  134. package/templates/core-prompts/immune-triage.md +31 -0
  135. package/templates/core-prompts/interactive-startup.md +1 -0
  136. package/templates/core-prompts/json-object-only.md +1 -0
  137. package/templates/core-prompts/learning-validator.md +25 -0
  138. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  139. package/templates/core-prompts/morning-agent.md +23 -0
  140. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  141. package/templates/core-prompts/r-catalog.md +1 -0
  142. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  143. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  144. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  145. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  146. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  147. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  148. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  149. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  150. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  151. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  152. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  153. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  154. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  155. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  156. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  157. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  158. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  159. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  160. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  161. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  162. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  164. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  165. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  166. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  167. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  168. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  169. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  170. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  171. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  172. package/templates/core-prompts/sleep.md +25 -0
  173. package/templates/email-template.md +55 -0
  174. package/templates/nexo_helper.py +31 -13
  175. package/templates/plugin-template.py +3 -3
  176. package/templates/skill-template.md +2 -1
@@ -90,6 +90,19 @@
90
90
  "run_on_boot": true,
91
91
  "run_on_wake": true
92
92
  },
93
+ {
94
+ "id": "backup",
95
+ "script": "scripts/nexo-backup.sh",
96
+ "type": "shell",
97
+ "interval_seconds": 3600,
98
+ "description": "Hourly validated runtime backups",
99
+ "core": true,
100
+ "recovery_policy": "restart",
101
+ "idempotent": true,
102
+ "max_catchup_age": 14400,
103
+ "run_on_boot": true,
104
+ "run_on_wake": false
105
+ },
93
106
  {
94
107
  "id": "self-audit",
95
108
  "script": "scripts/nexo-daily-self-audit.py",
@@ -204,6 +217,50 @@
204
217
  "run_on_boot": true,
205
218
  "run_on_wake": true
206
219
  },
220
+ {
221
+ "id": "tcc-approve",
222
+ "script": "scripts/nexo-tcc-approve.sh",
223
+ "type": "shell",
224
+ "run_at_load": true,
225
+ "watch_paths": ["~/.local/share/claude/versions"],
226
+ "platforms": ["darwin"],
227
+ "description": "Grant macOS TCC permissions to newly installed Claude binaries",
228
+ "core": true,
229
+ "recovery_policy": "none",
230
+ "idempotent": true,
231
+ "max_catchup_age": 0,
232
+ "run_on_boot": true,
233
+ "run_on_wake": false
234
+ },
235
+ {
236
+ "id": "prevent-sleep",
237
+ "script": "scripts/nexo-prevent-sleep.sh",
238
+ "type": "shell",
239
+ "keep_alive": true,
240
+ "proc_grep": "caffeinate",
241
+ "requires_power_policy": "always_on",
242
+ "description": "Keep the machine awake while the always-on power policy is enabled",
243
+ "core": true,
244
+ "recovery_policy": "restart",
245
+ "idempotent": true,
246
+ "max_catchup_age": 0,
247
+ "run_on_boot": true,
248
+ "run_on_wake": true
249
+ },
250
+ {
251
+ "id": "dashboard",
252
+ "script": "scripts/nexo-dashboard.sh",
253
+ "type": "shell",
254
+ "keep_alive": true,
255
+ "optional": "dashboard",
256
+ "description": "Keep the NEXO dashboard web UI available on localhost:6174",
257
+ "core": true,
258
+ "recovery_policy": "restart",
259
+ "idempotent": true,
260
+ "max_catchup_age": 0,
261
+ "run_on_boot": true,
262
+ "run_on_wake": true
263
+ },
207
264
  {
208
265
  "id": "auto-update",
209
266
  "script": "scripts/nexo-auto-update.py",
package/src/crons/sync.py CHANGED
@@ -32,7 +32,7 @@ if str(_runtime_root) not in sys.path:
32
32
  sys.path.insert(0, str(_runtime_root))
33
33
 
34
34
  import paths
35
- from cron_recovery import resolve_declared_schedule, should_run_at_load
35
+ from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
36
36
  try:
37
37
  from runtime_power import resolve_launchagent_path
38
38
  except ImportError:
@@ -77,6 +77,13 @@ def _runtime_scripts_dir() -> Path:
77
77
  return new
78
78
 
79
79
 
80
+ def _runtime_code_dir() -> Path:
81
+ packaged = RUNTIME_ROOT / "core"
82
+ if packaged.exists() or not (RUNTIME_ROOT / "server.py").exists():
83
+ return packaged
84
+ return RUNTIME_ROOT
85
+
86
+
80
87
  def _runtime_crons_dir() -> Path:
81
88
  new = RUNTIME_ROOT / "runtime" / "crons"
82
89
  legacy = RUNTIME_ROOT / "crons"
@@ -104,6 +111,12 @@ def _sync_watchdog_hash_registry():
104
111
  continue
105
112
  file_path, expected_hash = line.split("|", 1)
106
113
  if file_path:
114
+ candidate = Path(file_path)
115
+ try:
116
+ if candidate.resolve(strict=False) == watchdog_path.resolve(strict=False):
117
+ continue
118
+ except Exception:
119
+ pass
107
120
  entries[file_path] = expected_hash
108
121
  import hashlib
109
122
  entries[str(watchdog_path)] = hashlib.sha256(watchdog_path.read_bytes()).hexdigest()
@@ -144,6 +157,10 @@ def load_manifest() -> list[dict]:
144
157
  from automation_controls import apply_core_automation_overrides
145
158
  except Exception:
146
159
  apply_core_automation_overrides = None
160
+ try:
161
+ from product_mode import filter_blocked_crons
162
+ except Exception:
163
+ filter_blocked_crons = None
147
164
 
148
165
  with open(MANIFEST) as f:
149
166
  data = json.load(f)
@@ -156,24 +173,30 @@ def load_manifest() -> list[dict]:
156
173
  except Exception as e:
157
174
  log(f"WARNING: could not read optionals.json: {e}")
158
175
 
159
- automation_default = True
176
+ schedule_data: dict = {}
160
177
  if SCHEDULE_FILE.is_file():
161
178
  try:
162
- schedule_data = json.loads(SCHEDULE_FILE.read_text())
163
- automation_default = bool(schedule_data.get("automation_enabled", True))
179
+ loaded = json.loads(SCHEDULE_FILE.read_text())
180
+ if isinstance(loaded, dict):
181
+ schedule_data = loaded
164
182
  except Exception:
165
183
  pass
166
184
 
167
185
  filtered = []
168
186
  for cron in crons:
169
- optional_key = cron.get("optional")
170
- if optional_key == "automation":
171
- enabled = enabled_optionals.get(optional_key, automation_default)
172
- else:
173
- enabled = enabled_optionals.get(optional_key, False)
174
- if optional_key and not enabled:
187
+ if not is_cron_enabled(
188
+ cron,
189
+ optionals=enabled_optionals,
190
+ schedule_data=schedule_data,
191
+ system=platform.system(),
192
+ ):
175
193
  continue
176
194
  filtered.append(cron)
195
+ if callable(filter_blocked_crons):
196
+ try:
197
+ filtered = filter_blocked_crons(filtered)
198
+ except Exception as e:
199
+ log(f"WARNING: could not filter product-blocked crons: {e}")
177
200
  if callable(apply_core_automation_overrides):
178
201
  try:
179
202
  return apply_core_automation_overrides(filtered)
@@ -320,7 +343,7 @@ def build_plist(cron: dict) -> dict:
320
343
  "PATH": resolve_launchagent_path(),
321
344
  "HOME": str(Path.home()),
322
345
  "NEXO_HOME": str(NEXO_HOME),
323
- "NEXO_CODE": str(RUNTIME_ROOT),
346
+ "NEXO_CODE": str(_runtime_code_dir()),
324
347
  "NEXO_SOURCE_CODE": str(SOURCE_ROOT),
325
348
  "NEXO_MANAGED_CORE_CRON": "1",
326
349
  "PYTHONUNBUFFERED": "1",
@@ -334,6 +357,11 @@ def build_plist(cron: dict) -> dict:
334
357
  else:
335
358
  if should_run_at_load(cron):
336
359
  plist["RunAtLoad"] = True
360
+ if cron.get("watch_paths"):
361
+ plist["WatchPaths"] = [
362
+ str(Path(str(path)).expanduser()) if str(path).startswith("~") else str(path)
363
+ for path in cron.get("watch_paths", [])
364
+ ]
337
365
  if "interval_seconds" in cron and not cron.get("keep_alive"):
338
366
  plist["StartInterval"] = cron["interval_seconds"]
339
367
  elif "schedule" in cron and not cron.get("keep_alive"):
@@ -380,6 +408,8 @@ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
380
408
  return True
381
409
  if existing.get("KeepAlive") != new_plist.get("KeepAlive"):
382
410
  return True
411
+ if existing.get("WatchPaths") != new_plist.get("WatchPaths"):
412
+ return True
383
413
  if existing.get("EnvironmentVariables") != new_plist.get("EnvironmentVariables"):
384
414
  return True
385
415
  return False
@@ -418,6 +448,36 @@ def _plist_is_personal(existing: dict) -> bool:
418
448
  return env.get(PERSONAL_CRON_MANAGED_ENV) == "1" or bool(env.get(PERSONAL_CRON_ID_ENV))
419
449
 
420
450
 
451
+ def _core_launchagent_roots() -> tuple[Path, ...]:
452
+ roots: list[Path] = []
453
+ for root in (
454
+ SOURCE_ROOT / "scripts",
455
+ RUNTIME_ROOT / "core" / "scripts",
456
+ RUNTIME_ROOT / "scripts",
457
+ paths.core_scripts_dir(),
458
+ ):
459
+ normalized = root.expanduser()
460
+ if normalized not in roots:
461
+ roots.append(normalized)
462
+ return tuple(roots)
463
+
464
+
465
+ def _program_arguments_point_to_core(existing: dict) -> bool:
466
+ args = existing.get("ProgramArguments", []) or []
467
+ for arg in args:
468
+ try:
469
+ candidate = Path(str(arg)).expanduser()
470
+ except Exception:
471
+ continue
472
+ for root in _core_launchagent_roots():
473
+ try:
474
+ candidate.relative_to(root)
475
+ return True
476
+ except ValueError:
477
+ continue
478
+ return False
479
+
480
+
421
481
  def _plist_is_core(existing: dict) -> bool:
422
482
  """Return True when a LaunchAgent should be treated as a core cron."""
423
483
  env = existing.get("EnvironmentVariables", {}) or {}
@@ -427,6 +487,9 @@ def _plist_is_core(existing: dict) -> bool:
427
487
  if env.get(CORE_CRON_MANAGED_ENV) == "1":
428
488
  return True
429
489
 
490
+ if _program_arguments_point_to_core(existing):
491
+ return True
492
+
430
493
  args = existing.get("ProgramArguments", [])
431
494
  arg_blob = " ".join(str(a) for a in args)
432
495
  return (
@@ -536,20 +599,25 @@ def sync_linux(dry_run: bool = False):
536
599
  stdout_log = LOG_DIR / f"{cron_id}-stdout.log"
537
600
  stderr_log = LOG_DIR / f"{cron_id}-stderr.log"
538
601
 
602
+ service_type = "simple" if cron.get("keep_alive") else "oneshot"
603
+ restart_block = "Restart=always\nRestartSec=5\n" if cron.get("keep_alive") else ""
604
+ install_block = "\n[Install]\nWantedBy=default.target\n" if cron.get("keep_alive") else ""
539
605
  service_content = f"""[Unit]
540
606
  Description=NEXO: {cron.get('description', cron_id)}
541
607
 
542
608
  [Service]
543
- Type=oneshot
609
+ Type={service_type}
544
610
  ExecStart={exec_cmd}
545
611
  Environment=NEXO_HOME={NEXO_HOME}
546
- Environment=NEXO_CODE={SOURCE_ROOT}
612
+ Environment=NEXO_CODE={_runtime_code_dir()}
547
613
  Environment=HOME={Path.home()}
548
614
  StandardOutput=append:{stdout_log}
549
615
  StandardError=append:{stderr_log}
550
- """
616
+ {restart_block}{install_block}"""
551
617
 
552
- if cron.get("run_at_load"):
618
+ if cron.get("keep_alive"):
619
+ timer_spec = ""
620
+ elif cron.get("run_at_load"):
553
621
  timer_spec = "OnBootSec=0"
554
622
  elif "interval_seconds" in cron:
555
623
  timer_spec = f"OnUnitActiveSec={cron['interval_seconds']}s\nOnBootSec=60s"
@@ -566,6 +634,15 @@ StandardError=append:{stderr_log}
566
634
  log(f" SKIP {cron_id}: no schedule or interval")
567
635
  continue
568
636
 
637
+ if dry_run:
638
+ log(f" DRY-RUN: would install {cron_id}")
639
+ continue
640
+
641
+ service_path.write_text(service_content)
642
+ if cron.get("keep_alive"):
643
+ log(f" Installed keep_alive service: {cron_id}")
644
+ continue
645
+
569
646
  timer_content = f"""[Unit]
570
647
  Description=NEXO timer: {cron.get('description', cron_id)}
571
648
 
@@ -576,23 +653,15 @@ Persistent=true
576
653
  [Install]
577
654
  WantedBy=timers.target
578
655
  """
579
-
580
- if dry_run:
581
- log(f" DRY-RUN: would install {cron_id}")
582
- continue
583
-
584
- service_path.write_text(service_content)
585
656
  timer_path.write_text(timer_content)
586
657
  log(f" Installed: {cron_id}")
587
658
 
588
659
  if not dry_run:
589
660
  subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
590
661
  for cron in manifest_crons:
591
- subprocess.run(
592
- ["systemctl", "--user", "enable", "--now", f"nexo-{cron['id']}.timer"],
593
- capture_output=True
594
- )
595
- log("systemd timers enabled.")
662
+ unit = f"nexo-{cron['id']}.service" if cron.get("keep_alive") else f"nexo-{cron['id']}.timer"
663
+ subprocess.run(["systemctl", "--user", "enable", "--now", unit], capture_output=True)
664
+ log("systemd units enabled.")
596
665
 
597
666
  log("Sync complete.")
598
667
 
@@ -96,6 +96,60 @@ def _adaptive():
96
96
  return adaptive_mode
97
97
 
98
98
 
99
+ def _dashboard_runtime_flags() -> dict:
100
+ flags = {
101
+ "desktop_managed": False,
102
+ "disabled_features": [],
103
+ "evolution_available": True,
104
+ }
105
+ try:
106
+ from product_mode import desktop_product_requested, load_product_mode
107
+
108
+ mode = load_product_mode()
109
+ disabled = []
110
+ raw_disabled = mode.get("disabled_features") if isinstance(mode, dict) else []
111
+ if isinstance(raw_disabled, (list, tuple, set)):
112
+ disabled = [
113
+ str(item).strip().lower()
114
+ for item in raw_disabled
115
+ if str(item).strip()
116
+ ]
117
+ desktop_managed = bool(desktop_product_requested())
118
+ if desktop_managed and "evolution" not in disabled:
119
+ disabled.append("evolution")
120
+ flags["desktop_managed"] = desktop_managed
121
+ flags["disabled_features"] = disabled
122
+ flags["evolution_available"] = "evolution" not in disabled
123
+ except Exception:
124
+ pass
125
+ return flags
126
+
127
+
128
+ def _feature_disabled_response(feature: str, *, api: bool = False):
129
+ feature_name = str(feature or "").strip().lower()
130
+ if api:
131
+ return JSONResponse(
132
+ {
133
+ "ok": False,
134
+ "feature": feature_name,
135
+ "disabled": True,
136
+ "reason": "Disabled by NEXO Desktop product contract",
137
+ },
138
+ status_code=403,
139
+ )
140
+ title = f"{feature_name.title()} disabled"
141
+ detail = (
142
+ f"{feature_name.title()} is disabled on Desktop-managed installs. "
143
+ "Use the standalone Brain product if you need that subsystem."
144
+ )
145
+ return _render(
146
+ "feature-disabled.html",
147
+ disabled_feature=feature_name,
148
+ disabled_title=title,
149
+ disabled_detail=detail,
150
+ )
151
+
152
+
99
153
  # ---------------------------------------------------------------------------
100
154
  # Pydantic models for request bodies
101
155
  # ---------------------------------------------------------------------------
@@ -431,6 +485,7 @@ def _render(name: str, **ctx) -> HTMLResponse:
431
485
  """Render a Jinja2 template with context."""
432
486
  try:
433
487
  tmpl = jinja_env.get_template(name)
488
+ ctx.setdefault("runtime_flags", _dashboard_runtime_flags())
434
489
  return HTMLResponse(tmpl.render(**ctx))
435
490
  except Exception as exc:
436
491
  import logging
@@ -507,6 +562,8 @@ async def page_plugins():
507
562
  # Advanced
508
563
  @app.get("/evolution", response_class=HTMLResponse)
509
564
  async def page_evolution():
565
+ if not _dashboard_runtime_flags().get("evolution_available", True):
566
+ return _feature_disabled_response("evolution")
510
567
  return _render("evolution.html")
511
568
 
512
569
  @app.get("/claims", response_class=HTMLResponse)
@@ -1646,6 +1703,8 @@ async def api_plugins():
1646
1703
 
1647
1704
  @app.get("/api/evolution")
1648
1705
  async def api_evolution():
1706
+ if not _dashboard_runtime_flags().get("evolution_available", True):
1707
+ return _feature_disabled_response("evolution", api=True)
1649
1708
  db = _db()
1650
1709
  conn = db.get_db()
1651
1710
  logs = [dict(r) for r in conn.execute("SELECT * FROM evolution_log ORDER BY created_at DESC LIMIT 50").fetchall()]
@@ -188,10 +188,12 @@
188
188
  Advanced
189
189
  </button>
190
190
  <div class="nav-group-items">
191
+ {% if runtime_flags.evolution_available %}
191
192
  <a href="/evolution" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="evolution">
192
193
  <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
193
194
  Evolution
194
195
  </a>
196
+ {% endif %}
195
197
  <a href="/claims" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="claims">
196
198
  <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
197
199
  Claims Network
@@ -0,0 +1,27 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ disabled_title or "Feature disabled" }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-3xl mx-auto space-y-6 animate-fade-in">
7
+ <section class="card border border-amber-500/20 bg-amber-500/5 rounded-2xl p-6">
8
+ <div class="flex items-start gap-4">
9
+ <div class="w-11 h-11 rounded-2xl bg-amber-500/15 text-amber-300 flex items-center justify-center text-xl">!</div>
10
+ <div class="space-y-2">
11
+ <p class="text-[11px] font-mono uppercase tracking-[0.28em] text-amber-300/80">
12
+ Product contract
13
+ </p>
14
+ <h1 class="text-2xl font-semibold text-slate-100">
15
+ {{ disabled_title or "Feature disabled" }}
16
+ </h1>
17
+ <p class="text-sm leading-6 text-slate-300">
18
+ {{ disabled_detail or "This feature is currently disabled on this installation." }}
19
+ </p>
20
+ <p class="text-xs text-slate-500">
21
+ Disabled feature: <code>{{ disabled_feature or "unknown" }}</code>
22
+ </p>
23
+ </div>
24
+ </div>
25
+ </section>
26
+ </div>
27
+ {% endblock %}
@@ -13,15 +13,20 @@ import json
13
13
  import time
14
14
  from typing import Any
15
15
 
16
- from db._core import get_db
17
-
18
-
19
16
  DEFAULT_IMAP_PORT = 993
20
17
  DEFAULT_SMTP_PORT = 465
21
18
  VALID_ROLES = ("inbox", "outbox", "both")
22
19
  VALID_ACCOUNT_TYPES = ("agent", "operator")
23
20
 
24
21
 
22
+ def _get_db():
23
+ # Resolve the DB connector lazily so test suites that reload db._core with a
24
+ # temporary NEXO_HOME do not keep talking to a stale shared connection.
25
+ from db._core import get_db
26
+
27
+ return get_db()
28
+
29
+
25
30
  def _row_to_dict(row) -> dict:
26
31
  if row is None:
27
32
  return {}
@@ -95,7 +100,7 @@ def add_email_account(
95
100
  resolved_is_default = bool(existing.get("is_default")) if is_default is None else bool(is_default)
96
101
  if clean_account_type != "operator":
97
102
  resolved_is_default = False
98
- conn = get_db()
103
+ conn = _get_db()
99
104
  now = time.strftime("%Y-%m-%d %H:%M:%S")
100
105
  # Audit H2: when the caller does not pass `metadata` explicitly,
101
106
  # an upsert would otherwise wipe whatever the operator (or another
@@ -173,7 +178,7 @@ def list_email_accounts(
173
178
  raise ValueError(
174
179
  f"account_type must be one of {VALID_ACCOUNT_TYPES}, got {account_type!r}"
175
180
  )
176
- conn = get_db()
181
+ conn = _get_db()
177
182
  clauses: list[str] = []
178
183
  params: list[Any] = []
179
184
  if not include_disabled:
@@ -192,7 +197,7 @@ def list_email_accounts(
192
197
 
193
198
 
194
199
  def get_email_account(label: str) -> dict | None:
195
- conn = get_db()
200
+ conn = _get_db()
196
201
  row = conn.execute(
197
202
  "SELECT * FROM email_accounts WHERE label = ?",
198
203
  (label,),
@@ -200,9 +205,24 @@ def get_email_account(label: str) -> dict | None:
200
205
  return _row_to_dict(row) if row else None
201
206
 
202
207
 
208
+ def get_email_account_by_id(account_id: int | str | None) -> dict | None:
209
+ try:
210
+ clean_id = int(account_id or 0)
211
+ except Exception:
212
+ return None
213
+ if clean_id <= 0:
214
+ return None
215
+ conn = _get_db()
216
+ row = conn.execute(
217
+ "SELECT * FROM email_accounts WHERE id = ?",
218
+ (clean_id,),
219
+ ).fetchone()
220
+ return _row_to_dict(row) if row else None
221
+
222
+
203
223
  def get_primary_email_account() -> dict | None:
204
224
  """Most-recently-updated enabled agent account. Returns None if table empty."""
205
- conn = get_db()
225
+ conn = _get_db()
206
226
  row = conn.execute(
207
227
  "SELECT * FROM email_accounts WHERE enabled = 1 "
208
228
  "AND COALESCE(account_type, 'agent') = 'agent' "
@@ -213,7 +233,7 @@ def get_primary_email_account() -> dict | None:
213
233
 
214
234
  def get_default_operator_email_account() -> dict | None:
215
235
  """Return the explicit default operator mailbox, if any."""
216
- conn = get_db()
236
+ conn = _get_db()
217
237
  row = conn.execute(
218
238
  "SELECT * FROM email_accounts "
219
239
  "WHERE enabled = 1 AND COALESCE(account_type, 'agent') = 'operator' "
@@ -223,20 +243,48 @@ def get_default_operator_email_account() -> dict | None:
223
243
  return _row_to_dict(row) if row else None
224
244
 
225
245
 
226
- def set_email_account_enabled(label: str, enabled: bool) -> bool:
227
- conn = get_db()
228
- cur = conn.execute(
229
- "UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
230
- "WHERE label = ?",
231
- (1 if enabled else 0, label),
232
- )
246
+ def set_email_account_enabled(
247
+ label: str | None = None,
248
+ enabled: bool = True,
249
+ account_id: int | str | None = None,
250
+ ) -> bool:
251
+ conn = _get_db()
252
+ if account_id is not None:
253
+ try:
254
+ clean_id = int(account_id)
255
+ except Exception:
256
+ clean_id = 0
257
+ if clean_id > 0:
258
+ cur = conn.execute(
259
+ "UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
260
+ "WHERE id = ?",
261
+ (1 if enabled else 0, clean_id),
262
+ )
263
+ else:
264
+ cur = conn.execute("SELECT 0 WHERE 1=0")
265
+ else:
266
+ cur = conn.execute(
267
+ "UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
268
+ "WHERE label = ?",
269
+ (1 if enabled else 0, label),
270
+ )
233
271
  conn.commit()
234
272
  return cur.rowcount > 0
235
273
 
236
274
 
237
- def remove_email_account(label: str) -> bool:
238
- conn = get_db()
239
- cur = conn.execute("DELETE FROM email_accounts WHERE label = ?", (label,))
275
+ def remove_email_account(label: str | None = None, account_id: int | str | None = None) -> bool:
276
+ conn = _get_db()
277
+ if account_id is not None:
278
+ try:
279
+ clean_id = int(account_id)
280
+ except Exception:
281
+ clean_id = 0
282
+ if clean_id > 0:
283
+ cur = conn.execute("DELETE FROM email_accounts WHERE id = ?", (clean_id,))
284
+ else:
285
+ cur = conn.execute("SELECT 0 WHERE 1=0")
286
+ else:
287
+ cur = conn.execute("DELETE FROM email_accounts WHERE label = ?", (label,))
240
288
  conn.commit()
241
289
  return cur.rowcount > 0
242
290
 
@@ -245,6 +293,7 @@ __all__ = [
245
293
  "add_email_account",
246
294
  "list_email_accounts",
247
295
  "get_email_account",
296
+ "get_email_account_by_id",
248
297
  "get_primary_email_account",
249
298
  "get_default_operator_email_account",
250
299
  "set_email_account_enabled",
package/src/db/_fts.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """NEXO DB — Fts module."""
2
2
  import os, pathlib, sqlite3, threading, datetime
3
+ import paths
3
4
  from db._core import get_db, now_epoch, DB_PATH
4
5
 
5
6
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
@@ -10,12 +11,12 @@ NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
10
11
  _FTS_MD_DIRS = [
11
12
  os.path.join(NEXO_HOME, "docs"),
12
13
  os.path.join(NEXO_HOME, "projects"),
13
- os.path.join(NEXO_HOME, "memory"),
14
- os.path.join(NEXO_HOME, "operations"),
14
+ str(paths.memory_dir()),
15
+ str(paths.operations_dir()),
15
16
  os.path.join(NEXO_HOME, "learnings"),
16
- os.path.join(NEXO_HOME, "brain"),
17
+ str(paths.brain_dir()),
17
18
  os.path.join(NEXO_HOME, "agents"),
18
- os.path.join(NEXO_HOME, "skills"),
19
+ str(paths.personal_skills_dir()),
19
20
  ]
20
21
  # Code repos: index source files (skip vendor, node_modules, etc.)
21
22
  _FTS_CODE_DIRS = [] # Users can add project dirs via nexo_index_add_dir
@@ -403,4 +404,3 @@ def _migrate_add_index(conn, index_name: str, table: str, column: str):
403
404
  conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table}({column})")
404
405
  conn.commit()
405
406
 
406
-
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  """NEXO DB — Personal scripts registry.
3
3
 
4
- Filesystem remains the source of truth for personal scripts in NEXO_HOME/scripts/.
4
+ Filesystem remains the source of truth for personal scripts in NEXO_HOME/personal/scripts/.
5
5
  SQLite stores operational metadata so NEXO can reason about what scripts exist,
6
6
  what they do, and which schedules/plists are attached to them.
7
7
  """
package/src/db/_skills.py CHANGED
@@ -34,8 +34,8 @@ def _resolve_core_skills_dir() -> Path:
34
34
 
35
35
  In development NEXO_CODE points at repo/src, so core skills live in src/skills.
36
36
  In packaged installs the runtime wrapper points NEXO_CODE at NEXO_HOME, so core
37
- skills must live in a dedicated skills-core/ directory to avoid colliding with
38
- personal skills in NEXO_HOME/skills.
37
+ skills must live in a dedicated core/skills directory to avoid colliding with
38
+ personal skills in NEXO_HOME/personal/skills.
39
39
  """
40
40
  try:
41
41
  if NEXO_CODE.resolve() == NEXO_HOME.resolve():
@@ -1472,7 +1472,7 @@ def collect_skill_improvement_candidates() -> list[dict]:
1472
1472
 
1473
1473
 
1474
1474
  def materialize_personal_skill_definition(skill_data: dict) -> dict:
1475
- """Write a personal skill definition to NEXO_HOME/skills and sync it into DB."""
1475
+ """Write a personal skill definition to NEXO_HOME/personal/skills and sync it into DB."""
1476
1476
  _ensure_skill_dirs()
1477
1477
  skill_id = str(skill_data.get("id", "")).strip()
1478
1478
  name = str(skill_data.get("name", "")).strip()