nexo-brain 7.1.0 → 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 +305 -42
  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
package/src/cli_email.py CHANGED
@@ -41,7 +41,7 @@ def _prompt_int(msg: str, default: int) -> int:
41
41
  try:
42
42
  return int(raw)
43
43
  except ValueError:
44
- print(f" ✗ '{raw}' no es un número. Prueba otra vez.")
44
+ print(f" ✗ '{raw}' is not a valid number. Try again.")
45
45
 
46
46
 
47
47
  def _prompt_yes_no(msg: str, default: bool = True) -> bool:
@@ -54,12 +54,12 @@ def _prompt_yes_no(msg: str, default: bool = True) -> bool:
54
54
  return True
55
55
  if raw in ("n", "no"):
56
56
  return False
57
- print(" ✗ Responde y o n.")
57
+ print(" ✗ Answer with y or n.")
58
58
 
59
59
 
60
60
  def _mask_password(pw: str) -> str:
61
61
  if not pw:
62
- return "(vacío)"
62
+ return "(empty)"
63
63
  if len(pw) <= 4:
64
64
  return "•" * len(pw)
65
65
  return pw[0] + "•" * (len(pw) - 2) + pw[-1]
@@ -103,61 +103,61 @@ def _delete_credential(service: str, key: str) -> None:
103
103
  def cmd_email_setup(args) -> int:
104
104
  """Interactive wizard for the primary agent mailbox."""
105
105
  print("━" * 60)
106
- print("NEXO · Asistente de configuración de email")
106
+ print("NEXO · Email setup wizard")
107
107
  print("━" * 60)
108
- print("Te voy a preguntar los datos de la cuenta de correo del agente")
109
- print("que NEXO usará para leer y contestar. Si te equivocas, vuelve")
110
- print("a ejecutar `nexo email setup` en cualquier momento.\n")
108
+ print("I will ask for the mailbox details NEXO should use to")
109
+ print("read and reply. If you make a mistake, just run")
110
+ print("`nexo email setup` again at any time.\n")
111
111
 
112
112
  from db import init_db
113
113
  from db._email_accounts import add_email_account, get_email_account
114
114
 
115
115
  init_db()
116
116
 
117
- label = _prompt("Etiqueta de la cuenta (ej: 'primary', 'wazion')", "primary")
117
+ label = _prompt("Account label (example: 'primary', 'wazion')", "primary")
118
118
 
119
119
  existing = get_email_account(label)
120
120
  if existing:
121
121
  if not _prompt_yes_no(
122
- f"Ya existe una cuenta '{label}' ({existing.get('email')}). ¿La sobrescribo?",
122
+ f"An account named '{label}' already exists ({existing.get('email')}). Overwrite it?",
123
123
  default=False,
124
124
  ):
125
- print("Cancelado.")
125
+ print("Cancelled.")
126
126
  return 1
127
127
 
128
- email = _prompt("Dirección email (ej: nexo@tudominio.com)")
128
+ email = _prompt("Email address (example: nexo@yourdomain.com)")
129
129
  if not email or "@" not in email:
130
- print(f" ✗ '{email}' no parece un email válido.")
130
+ print(f" ✗ '{email}' does not look like a valid email address.")
131
131
  return 1
132
132
 
133
- imap_host = _prompt("Servidor IMAP (entrada)", "imap.gmail.com")
134
- imap_port = _prompt_int("Puerto IMAP", 993)
135
- smtp_host = _prompt("Servidor SMTP (salida)", imap_host.replace("imap", "smtp"))
136
- smtp_port = _prompt_int("Puerto SMTP", 465)
133
+ imap_host = _prompt("IMAP server (incoming mail)", "imap.gmail.com")
134
+ imap_port = _prompt_int("IMAP port", 993)
135
+ smtp_host = _prompt("SMTP server (outgoing mail)", imap_host.replace("imap", "smtp"))
136
+ smtp_port = _prompt_int("SMTP port", 465)
137
137
 
138
138
  try:
139
- pwd = getpass.getpass("Contraseña (no se mostrará): ")
139
+ pwd = getpass.getpass("Password (hidden input): ")
140
140
  except (EOFError, KeyboardInterrupt):
141
- print("\n(cancelado)")
141
+ print("\n(cancelled)")
142
142
  return 1
143
143
  if not pwd:
144
- print(" ✗ Necesito una contraseña.")
144
+ print(" ✗ A password is required.")
145
145
  return 1
146
146
 
147
147
  operator_email = _prompt(
148
- "Email donde NEXO te enviará el briefing matinal (tu email personal)",
148
+ "Operator email for the daily briefing",
149
149
  email,
150
150
  )
151
151
 
152
152
  trusted_raw = _prompt(
153
- "Dominios de confianza separados por coma (puedes dejar vacío)",
153
+ "Trusted domains (comma-separated, optional)",
154
154
  "",
155
155
  )
156
156
  trusted = [d.strip() for d in trusted_raw.split(",") if d.strip()]
157
- sent_folder = _prompt("Carpeta IMAP de enviados", "INBOX.Sent").strip() or "INBOX.Sent"
157
+ sent_folder = _prompt("IMAP sent folder", "INBOX.Sent").strip() or "INBOX.Sent"
158
158
 
159
159
  role = _prompt(
160
- "Rol de la cuenta: inbox (solo leer) / outbox (solo enviar) / both",
160
+ "Account role: inbox (read only) / outbox (send only) / both",
161
161
  "both",
162
162
  )
163
163
  if role not in ("inbox", "outbox", "both"):
@@ -188,20 +188,20 @@ def cmd_email_setup(args) -> int:
188
188
  )
189
189
 
190
190
  print()
191
- print("✓ Cuenta guardada:")
191
+ print("✓ Account saved:")
192
192
  print(f" label: {account.get('label')}")
193
193
  print(f" email: {account.get('email')}")
194
194
  print(f" IMAP: {account.get('imap_host')}:{account.get('imap_port')}")
195
195
  print(f" SMTP: {account.get('smtp_host')}:{account.get('smtp_port')}")
196
196
  print(f" operator_email: {account.get('operator_email')}")
197
- print(f" trusted: {account.get('trusted_domains') or '(ninguno)'}")
197
+ print(f" trusted: {account.get('trusted_domains') or '(none)'}")
198
198
  print(f" role: {account.get('role')}")
199
199
  print(f" sent_folder: {_sent_folder_from_account(account)}")
200
- print(f" password: {_mask_password(pwd)} (guardada en credentials)")
200
+ print(f" password: {_mask_password(pwd)} (stored in credentials)")
201
201
  print()
202
- if _prompt_yes_no("¿Pruebo la conexión ahora?", default=True):
202
+ if _prompt_yes_no("Test the connection now?", default=True):
203
203
  return cmd_email_test(type("Args", (), {"label": label})())
204
- print("Puedes probarla cuando quieras con: nexo email test " + label)
204
+ print("You can test it later with: nexo email test " + label)
205
205
  return 0
206
206
 
207
207
 
@@ -218,11 +218,18 @@ def _account_to_public_dict(account: dict) -> dict:
218
218
  stored so the UI can show a 'no password yet' marker."""
219
219
  if not account:
220
220
  return {}
221
+ metadata = account.get("metadata") or {}
222
+ if not isinstance(metadata, dict):
223
+ metadata = {}
224
+ legacy_migrated = bool(metadata.get("migrated_from_legacy_email_config"))
221
225
  return {
226
+ "id": account.get("id"),
222
227
  "label": account.get("label"),
223
228
  "email": account.get("email"),
224
229
  "account_type": account.get("account_type", "agent"),
225
230
  "description": account.get("description", ""),
231
+ "description_source": "legacy_migration" if legacy_migrated else "",
232
+ "legacy_migrated": legacy_migrated,
226
233
  "imap_host": account.get("imap_host"),
227
234
  "imap_port": account.get("imap_port"),
228
235
  "smtp_host": account.get("smtp_host"),
@@ -240,6 +247,22 @@ def _account_to_public_dict(account: dict) -> dict:
240
247
  }
241
248
 
242
249
 
250
+ def _selector_from_args(args) -> tuple[int | None, str]:
251
+ raw_id = getattr(args, "account_id", None)
252
+ label = str(getattr(args, "label", None) or getattr(args, "label_pos", None) or "").strip()
253
+ try:
254
+ account_id = int(raw_id) if raw_id not in (None, "") else None
255
+ except Exception:
256
+ account_id = None
257
+ if account_id is not None and account_id <= 0:
258
+ account_id = None
259
+ return account_id, label
260
+
261
+
262
+ def _selector_usage(command: str) -> str:
263
+ return f"usage: nexo email {command} <label> [--id ACCOUNT_ID]"
264
+
265
+
243
266
  def cmd_email_list(args) -> int:
244
267
  from db import init_db
245
268
  from db._email_accounts import list_email_accounts
@@ -253,7 +276,7 @@ def cmd_email_list(args) -> int:
253
276
  })
254
277
  return 0
255
278
  if not accounts:
256
- print("(sin cuentas configuradascorre `nexo email setup`)")
279
+ print("(no accounts configuredrun `nexo email setup`)")
257
280
  return 0
258
281
  print(f"{'LABEL':<18} {'TYPE':<9} {'EMAIL':<34} {'PERMS':<7} {'DEF':<4} IMAP")
259
282
  for a in accounts:
@@ -315,7 +338,7 @@ def cmd_email_add(args) -> int:
315
338
  print(f"✗ {msg}")
316
339
  return 1
317
340
  if "@" not in email:
318
- msg = f"'{email}' no parece un email válido."
341
+ msg = f"'{email}' does not look like a valid email address."
319
342
  if json_mode:
320
343
  _emit_json({"ok": False, "message": msg})
321
344
  else:
@@ -443,16 +466,15 @@ def cmd_email_add(args) -> int:
443
466
  if json_mode:
444
467
  _emit_json({"ok": True, "account": public})
445
468
  else:
446
- print(f"✓ Cuenta '{label}' guardada.")
469
+ print(f"✓ Account '{label}' saved.")
447
470
  return 0
448
471
 
449
472
 
450
473
  def cmd_email_test(args) -> int:
451
474
  json_mode = getattr(args, "json", False)
452
- # Keep the legacy positional + the new --label flag both wired.
453
- label = getattr(args, "label", None) or getattr(args, "label_pos", None)
454
- if not label:
455
- msg = "usage: nexo email test <label>"
475
+ account_id, label = _selector_from_args(args)
476
+ if account_id is None and not label:
477
+ msg = _selector_usage("test")
456
478
  if json_mode:
457
479
  _emit_json({"ok": False, "message": msg})
458
480
  else:
@@ -462,9 +484,10 @@ def cmd_email_test(args) -> int:
462
484
  from email_config import load_email_config
463
485
 
464
486
  init_db()
465
- cfg = load_email_config(label=label)
487
+ cfg = load_email_config(label=label or None, account_id=account_id)
466
488
  if cfg is None:
467
- msg = f"Cuenta '{label}' no encontrada."
489
+ selector = f"id={account_id}" if account_id is not None else label
490
+ msg = f"Account '{selector}' not found."
468
491
  if json_mode:
469
492
  _emit_json({"ok": False, "message": msg})
470
493
  else:
@@ -496,7 +519,8 @@ def cmd_email_test(args) -> int:
496
519
  if json_mode:
497
520
  _emit_json({
498
521
  "ok": ok_imap and ok_smtp,
499
- "label": label,
522
+ "id": cfg.get("id"),
523
+ "label": cfg.get("label") or label,
500
524
  "imap": {"ok": ok_imap, "host": cfg["imap_host"], "port": cfg["imap_port"], "error": err_imap},
501
525
  "smtp": {"ok": ok_smtp, "host": cfg["smtp_host"], "port": cfg["smtp_port"], "error": err_smtp},
502
526
  "message": "Login OK" if (ok_imap and ok_smtp) else (err_imap or err_smtp or "test failed"),
@@ -515,21 +539,22 @@ def cmd_email_test(args) -> int:
515
539
 
516
540
  def cmd_email_remove(args) -> int:
517
541
  json_mode = getattr(args, "json", False)
518
- label = getattr(args, "label", None) or getattr(args, "label_pos", None)
519
- if not label:
520
- msg = "usage: nexo email remove <label>"
542
+ account_id, label = _selector_from_args(args)
543
+ if account_id is None and not label:
544
+ msg = _selector_usage("remove")
521
545
  if json_mode:
522
546
  _emit_json({"ok": False, "message": msg})
523
547
  else:
524
548
  print(msg)
525
549
  return 1
526
550
  from db import init_db
527
- from db._email_accounts import get_email_account, remove_email_account
551
+ from db._email_accounts import get_email_account, get_email_account_by_id, remove_email_account
528
552
 
529
553
  init_db()
530
- acc = get_email_account(label)
554
+ acc = get_email_account_by_id(account_id) if account_id is not None else get_email_account(label)
531
555
  if not acc:
532
- msg = f"Cuenta '{label}' no encontrada."
556
+ selector = f"id={account_id}" if account_id is not None else label
557
+ msg = f"Account '{selector}' not found."
533
558
  if json_mode:
534
559
  _emit_json({"ok": False, "message": msg})
535
560
  else:
@@ -539,53 +564,55 @@ def cmd_email_remove(args) -> int:
539
564
  if json_mode:
540
565
  _emit_json({"ok": False, "message": "missing --yes (interactive confirmation required)"})
541
566
  return 1
542
- if not _prompt_yes_no(f"¿Eliminar la cuenta '{label}' ({acc.get('email')})?", default=False):
543
- print("Cancelado.")
567
+ if not _prompt_yes_no(f"Delete account '{label}' ({acc.get('email')})?", default=False):
568
+ print("Cancelled.")
544
569
  return 0
545
570
  _delete_credential(acc.get("credential_service", ""), acc.get("credential_key", ""))
546
- remove_email_account(label)
571
+ remove_email_account(account_id=acc.get("id"))
547
572
  if json_mode:
548
- _emit_json({"ok": True, "label": label, "message": "removed"})
573
+ _emit_json({"ok": True, "id": acc.get("id"), "label": acc.get("label"), "message": "removed"})
549
574
  else:
550
- print(f"✓ Cuenta '{label}' eliminada.")
575
+ print(f"✓ Account '{acc.get('label')}' removed.")
551
576
  return 0
552
577
 
553
578
 
554
579
  def cmd_email_set_enabled(args) -> int:
555
- label = getattr(args, "label", None) or getattr(args, "label_pos", None)
580
+ account_id, label = _selector_from_args(args)
556
581
  json_mode = bool(getattr(args, "json", False))
557
582
  enabled = bool(getattr(args, "enabled", True))
558
- if not label:
559
- msg = "usage: nexo email enable|disable <label>"
583
+ if account_id is None and not label:
584
+ msg = _selector_usage("enable|disable")
560
585
  if json_mode:
561
586
  _emit_json({"ok": False, "message": msg})
562
587
  else:
563
588
  print(msg)
564
589
  return 1
565
590
  from db import init_db
566
- from db._email_accounts import get_email_account, set_email_account_enabled
591
+ from db._email_accounts import get_email_account, get_email_account_by_id, set_email_account_enabled
567
592
 
568
593
  init_db()
569
- acc = get_email_account(label)
594
+ acc = get_email_account_by_id(account_id) if account_id is not None else get_email_account(label)
570
595
  if not acc:
571
- msg = f"Cuenta '{label}' no encontrada."
596
+ selector = f"id={account_id}" if account_id is not None else label
597
+ msg = f"Account '{selector}' not found."
572
598
  if json_mode:
573
599
  _emit_json({"ok": False, "message": msg})
574
600
  else:
575
601
  print(f"✗ {msg}")
576
602
  return 1
577
- changed = set_email_account_enabled(label, enabled)
603
+ changed = set_email_account_enabled(account_id=acc.get("id"), enabled=enabled)
578
604
  if not changed:
579
- msg = f"No se pudo actualizar la cuenta '{label}'."
605
+ msg = f"Could not update account '{acc.get('label')}'."
580
606
  if json_mode:
581
607
  _emit_json({"ok": False, "message": msg})
582
608
  else:
583
609
  print(f"✗ {msg}")
584
610
  return 1
585
- updated = get_email_account(label) or {}
611
+ updated = get_email_account_by_id(acc.get("id")) or {}
586
612
  payload = {
587
613
  "ok": True,
588
- "label": label,
614
+ "id": updated.get("id"),
615
+ "label": updated.get("label") or label,
589
616
  "enabled": bool(updated.get("enabled", enabled)),
590
617
  "message": "enabled" if enabled else "disabled",
591
618
  }
@@ -593,22 +620,22 @@ def cmd_email_set_enabled(args) -> int:
593
620
  _emit_json(payload)
594
621
  else:
595
622
  print(
596
- f"✓ Cuenta '{label}' "
597
- + ("activada." if payload["enabled"] else "desactivada.")
623
+ f"✓ Account '{payload['label']}' "
624
+ + ("enabled." if payload["enabled"] else "disabled.")
598
625
  )
599
626
  return 0
600
627
 
601
628
 
602
629
  def register_email_parser(subparsers) -> None:
603
630
  """Hook called by cli.py to add the `email` subcommand tree."""
604
- p = subparsers.add_parser("email", help="Gestionar cuentas de correo NEXO")
631
+ p = subparsers.add_parser("email", help="Manage NEXO email accounts")
605
632
  p.set_defaults(func=lambda a: p.print_help() or 0)
606
633
  sub = p.add_subparsers(dest="email_action")
607
634
 
608
- s = sub.add_parser("setup", help="Asistente interactivo para añadir / reconfigurar una cuenta")
635
+ s = sub.add_parser("setup", help="Interactive wizard to add or reconfigure an account")
609
636
  s.set_defaults(func=cmd_email_setup)
610
637
 
611
- s = sub.add_parser("add", help="Añadir cuenta de forma no-interactiva (Desktop / scripts)")
638
+ s = sub.add_parser("add", help="Add an account non-interactively (Desktop / scripts)")
612
639
  s.add_argument("--label", required=True)
613
640
  s.add_argument("--email", required=True)
614
641
  s.add_argument("--imap-host", dest="imap_host", default="")
@@ -639,37 +666,41 @@ def register_email_parser(subparsers) -> None:
639
666
  s.add_argument("--json", dest="json", action="store_true")
640
667
  s.set_defaults(func=cmd_email_add)
641
668
 
642
- s = sub.add_parser("list", help="Listar cuentas configuradas")
669
+ s = sub.add_parser("list", help="List configured accounts")
643
670
  s.add_argument("--json", dest="json", action="store_true")
644
671
  s.set_defaults(func=cmd_email_list)
645
672
 
646
- s = sub.add_parser("test", help="Probar IMAP + SMTP de una cuenta")
673
+ s = sub.add_parser("test", help="Test IMAP + SMTP for an account")
647
674
  s.add_argument("label_pos", nargs="?", default=None,
648
- help="Etiqueta de la cuenta (legacy positional)")
675
+ help="Account label (legacy positional)")
649
676
  s.add_argument("--label", dest="label", default=None)
677
+ s.add_argument("--id", dest="account_id", type=int, default=None)
650
678
  s.add_argument("--json", dest="json", action="store_true")
651
679
  s.set_defaults(func=cmd_email_test)
652
680
 
653
- s = sub.add_parser("remove", help="Eliminar una cuenta")
681
+ s = sub.add_parser("remove", help="Remove an account")
654
682
  s.add_argument("label_pos", nargs="?", default=None,
655
- help="Etiqueta de la cuenta (legacy positional)")
683
+ help="Account label (legacy positional)")
656
684
  s.add_argument("--label", dest="label", default=None)
685
+ s.add_argument("--id", dest="account_id", type=int, default=None)
657
686
  s.add_argument("--yes", dest="yes", action="store_true",
658
687
  help="Skip the interactive confirmation (required for --json).")
659
688
  s.add_argument("--json", dest="json", action="store_true")
660
689
  s.set_defaults(func=cmd_email_remove)
661
690
 
662
- s = sub.add_parser("enable", help="Activar una cuenta sin borrarla")
691
+ s = sub.add_parser("enable", help="Enable an account without deleting it")
663
692
  s.add_argument("label_pos", nargs="?", default=None,
664
- help="Etiqueta de la cuenta (legacy positional)")
693
+ help="Account label (legacy positional)")
665
694
  s.add_argument("--label", dest="label", default=None)
695
+ s.add_argument("--id", dest="account_id", type=int, default=None)
666
696
  s.add_argument("--json", dest="json", action="store_true")
667
697
  s.set_defaults(func=cmd_email_set_enabled, enabled=True)
668
698
 
669
- s = sub.add_parser("disable", help="Desactivar una cuenta sin borrarla")
699
+ s = sub.add_parser("disable", help="Disable an account without deleting it")
670
700
  s.add_argument("label_pos", nargs="?", default=None,
671
- help="Etiqueta de la cuenta (legacy positional)")
701
+ help="Account label (legacy positional)")
672
702
  s.add_argument("--label", dest="label", default=None)
703
+ s.add_argument("--id", dest="account_id", type=int, default=None)
673
704
  s.add_argument("--json", dest="json", action="store_true")
674
705
  s.set_defaults(func=cmd_email_set_enabled, enabled=False)
675
706
 
@@ -78,6 +78,7 @@ except Exception:
78
78
 
79
79
 
80
80
  CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code"
81
+ DEFAULT_ASSISTANT_NAME = "Nova"
81
82
  HOOK_TIMEOUTS_BY_EVENT = {
82
83
  "SessionStart": 40,
83
84
  "Stop": 15,
@@ -99,6 +100,16 @@ def _default_nexo_home() -> Path:
99
100
  return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
100
101
 
101
102
 
103
+ def _read_json_file(path: Path) -> dict:
104
+ if not path.is_file():
105
+ return {}
106
+ try:
107
+ payload = json.loads(path.read_text())
108
+ except Exception:
109
+ return {}
110
+ return payload if isinstance(payload, dict) else {}
111
+
112
+
102
113
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
103
114
  explicit = (explicit or "").strip()
104
115
  if explicit:
@@ -106,6 +117,16 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
106
117
  env_name = os.environ.get("NEXO_NAME", "").strip()
107
118
  if env_name:
108
119
  return env_name
120
+ calibration = _read_json_file(nexo_home / "personal" / "brain" / "calibration.json")
121
+ user_payload = calibration.get("user")
122
+ if isinstance(user_payload, dict):
123
+ candidate = str(user_payload.get("assistant_name", "")).strip()
124
+ if candidate:
125
+ return candidate
126
+ for key in ("assistant_name", "identity", "operator_name"):
127
+ candidate = str(calibration.get(key, "")).strip()
128
+ if candidate:
129
+ return candidate
109
130
  version_file = nexo_home / "version.json"
110
131
  if version_file.is_file():
111
132
  try:
@@ -114,7 +135,7 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
114
135
  return candidate
115
136
  except Exception:
116
137
  pass
117
- return "NEXO"
138
+ return DEFAULT_ASSISTANT_NAME
118
139
 
119
140
 
120
141
  def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str] | None = None) -> Path:
@@ -11,11 +11,13 @@ from datetime import datetime, timedelta
11
11
  from pathlib import Path
12
12
  from typing import Optional
13
13
 
14
+ import paths
15
+
14
16
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
15
- _data_dir = os.path.join(NEXO_HOME, "data")
16
- os.makedirs(_data_dir, exist_ok=True)
17
+ _cognitive_dir = paths.cognitive_dir()
18
+ _cognitive_dir.mkdir(parents=True, exist_ok=True)
17
19
 
18
- COGNITIVE_DB = os.path.join(_data_dir, "cognitive.db")
20
+ COGNITIVE_DB = str(_cognitive_dir / "cognitive.db")
19
21
  EMBEDDING_DIM = 768
20
22
  LAMBDA_STM = 0.004126 # half-life = ln(2) / (7 * 24) ≈ 7 days
21
23
  LAMBDA_LTM = 0.000481 # half-life = ln(2) / (60 * 24) ≈ 60 days
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared prompt catalog for productized NEXO core automations."""
4
+
5
+ import os
6
+ import re
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+
10
+ _TOKEN_RE = re.compile(r"\[\[([a-zA-Z0-9_]+)\]\]")
11
+
12
+
13
+ def _resolve_repo_root() -> Path:
14
+ candidate = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent))).expanduser().resolve()
15
+ if candidate.name == "src":
16
+ return candidate.parent
17
+ if (candidate / "templates").is_dir():
18
+ return candidate
19
+ if (candidate.parent / "templates").is_dir():
20
+ return candidate.parent
21
+ return Path(__file__).resolve().parents[1]
22
+
23
+
24
+ PROMPTS_DIR = _resolve_repo_root() / "templates" / "core-prompts"
25
+
26
+
27
+ @lru_cache(maxsize=None)
28
+ def load_core_prompt_template(name: str) -> str:
29
+ path = PROMPTS_DIR / f"{name}.md"
30
+ if not path.is_file():
31
+ raise FileNotFoundError(f"Core prompt template not found: {path}")
32
+ return path.read_text(encoding="utf-8")
33
+
34
+
35
+ def render_core_prompt(name: str, /, **values: object) -> str:
36
+ template = load_core_prompt_template(name)
37
+ required = {match.group(1) for match in _TOKEN_RE.finditer(template)}
38
+ missing = sorted(key for key in required if key not in values)
39
+ if missing:
40
+ raise KeyError(f"Missing values for core prompt '{name}': {', '.join(missing)}")
41
+
42
+ rendered = _TOKEN_RE.sub(lambda match: str(values[match.group(1)]), template)
43
+ return rendered.rstrip("\n")
44
+
45
+
46
+ __all__ = [
47
+ "PROMPTS_DIR",
48
+ "load_core_prompt_template",
49
+ "render_core_prompt",
50
+ ]
@@ -8,6 +8,7 @@ import plistlib
8
8
  import sqlite3
9
9
  import contextlib
10
10
  import hashlib
11
+ import platform
11
12
  import socket
12
13
  from datetime import datetime, timedelta, timezone
13
14
  from pathlib import Path
@@ -34,6 +35,68 @@ def _load_json(path: Path, default):
34
35
  return default
35
36
 
36
37
 
38
+ def _normalize_platform_name(system: str | None = None) -> str:
39
+ value = str(system or os.environ.get("NEXO_PLATFORM") or platform.system()).strip().lower()
40
+ if value.startswith("darwin") or value.startswith("mac"):
41
+ return "darwin"
42
+ if value.startswith("linux"):
43
+ return "linux"
44
+ return value
45
+
46
+
47
+ def _normalize_power_policy_name(value: str | None) -> str:
48
+ return str(value or "unset").strip().lower().replace("-", "_")
49
+
50
+
51
+ def _cron_platform_enabled(cron: dict, *, system: str | None = None) -> bool:
52
+ allowed = cron.get("platforms")
53
+ if allowed is None:
54
+ allowed = cron.get("platform")
55
+ if not allowed:
56
+ return True
57
+ if isinstance(allowed, str):
58
+ values = [allowed]
59
+ elif isinstance(allowed, (list, tuple, set)):
60
+ values = list(allowed)
61
+ else:
62
+ return True
63
+ current = _normalize_platform_name(system)
64
+ normalized = {_normalize_platform_name(str(item)) for item in values if str(item).strip()}
65
+ return not normalized or current in normalized
66
+
67
+
68
+ def is_cron_enabled(
69
+ cron: dict,
70
+ *,
71
+ optionals: dict[str, bool] | None = None,
72
+ schedule_data: dict | None = None,
73
+ system: str | None = None,
74
+ ) -> bool:
75
+ optionals = optionals or {}
76
+ schedule_data = schedule_data or {}
77
+
78
+ optional_key = cron.get("optional")
79
+ automation_default = bool(schedule_data.get("automation_enabled", True))
80
+ if optional_key == "automation":
81
+ optional_enabled = optionals.get(optional_key, automation_default)
82
+ else:
83
+ optional_enabled = optionals.get(optional_key, False)
84
+ if optional_key and not optional_enabled:
85
+ return False
86
+
87
+ if not _cron_platform_enabled(cron, system=system):
88
+ return False
89
+
90
+ required_power_policy = cron.get("requires_power_policy")
91
+ if required_power_policy:
92
+ current_policy = _normalize_power_policy_name(schedule_data.get("power_policy"))
93
+ expected_policy = _normalize_power_policy_name(required_power_policy)
94
+ if current_policy != expected_policy:
95
+ return False
96
+
97
+ return True
98
+
99
+
37
100
  def _schedule_machine_id() -> str:
38
101
  schedule = _load_json(SCHEDULE_FILE, {})
39
102
  if isinstance(schedule, dict):
@@ -88,6 +151,10 @@ def load_enabled_crons() -> list[dict]:
88
151
  from automation_controls import apply_core_automation_overrides
89
152
  except Exception:
90
153
  apply_core_automation_overrides = None
154
+ try:
155
+ from product_mode import filter_blocked_crons
156
+ except Exception:
157
+ filter_blocked_crons = None
91
158
 
92
159
  manifest_candidates = [
93
160
  paths.crons_dir() / "manifest.json",
@@ -97,7 +164,9 @@ def load_enabled_crons() -> list[dict]:
97
164
  if not isinstance(optionals, dict):
98
165
  optionals = {}
99
166
  schedule_data = _load_json(SCHEDULE_FILE, {})
100
- automation_default = bool(schedule_data.get("automation_enabled", True)) if isinstance(schedule_data, dict) else True
167
+ if not isinstance(schedule_data, dict):
168
+ schedule_data = {}
169
+ platform_name = _normalize_platform_name()
101
170
 
102
171
  for manifest_path in manifest_candidates:
103
172
  if not manifest_path.is_file():
@@ -109,14 +178,19 @@ def load_enabled_crons() -> list[dict]:
109
178
 
110
179
  enabled = []
111
180
  for cron in data.get("crons", []):
112
- optional_key = cron.get("optional")
113
- if optional_key == "automation":
114
- optional_enabled = optionals.get(optional_key, automation_default)
115
- else:
116
- optional_enabled = optionals.get(optional_key, False)
117
- if optional_key and not optional_enabled:
181
+ if not is_cron_enabled(
182
+ cron,
183
+ optionals=optionals,
184
+ schedule_data=schedule_data,
185
+ system=platform_name,
186
+ ):
118
187
  continue
119
188
  enabled.append(dict(cron))
189
+ if callable(filter_blocked_crons):
190
+ try:
191
+ enabled = filter_blocked_crons(enabled)
192
+ except Exception:
193
+ pass
120
194
  if callable(apply_core_automation_overrides):
121
195
  try:
122
196
  return apply_core_automation_overrides(enabled)