nexo-brain 7.1.1 → 7.1.3

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 (185) 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 +242 -28
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +211 -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/enforcement_engine.py +6 -9
  29. package/src/evolution_cycle.py +37 -149
  30. package/src/guardian_telemetry.py +3 -2
  31. package/src/hook_guardrails.py +61 -0
  32. package/src/hooks/capture-tool-logs.sh +11 -3
  33. package/src/hooks/daily-briefing-check.sh +7 -2
  34. package/src/hooks/heartbeat-enforcement.py +14 -1
  35. package/src/hooks/heartbeat-posttool.sh +2 -0
  36. package/src/hooks/heartbeat-user-msg.sh +2 -0
  37. package/src/hooks/inbox-hook.sh +6 -2
  38. package/src/hooks/post-compact.sh +12 -4
  39. package/src/hooks/pre-compact.sh +12 -4
  40. package/src/migrate_embeddings.py +5 -3
  41. package/src/nexo_migrate.py +3 -1
  42. package/src/plugin_loader.py +14 -5
  43. package/src/plugins/adaptive_mode.py +4 -1
  44. package/src/plugins/backup.py +32 -20
  45. package/src/plugins/evolution.py +2 -0
  46. package/src/plugins/memory_export.py +6 -1
  47. package/src/plugins/personal_plugins.py +17 -7
  48. package/src/plugins/personal_scripts.py +64 -3
  49. package/src/plugins/update.py +34 -14
  50. package/src/presets/entities_universal.json +67 -4
  51. package/src/product_mode.py +201 -0
  52. package/src/r14_correction_learning.py +5 -20
  53. package/src/r15_project_context.py +4 -10
  54. package/src/r16_declared_done.py +3 -16
  55. package/src/r17_promise_debt.py +3 -16
  56. package/src/r18_followup_autocomplete.py +5 -7
  57. package/src/r19_project_grep.py +5 -8
  58. package/src/r20_constant_change.py +5 -15
  59. package/src/r21_legacy_path.py +5 -7
  60. package/src/r22_personal_script.py +4 -8
  61. package/src/r23_ssh_without_atlas.py +4 -11
  62. package/src/r23b_deploy_vhost.py +7 -6
  63. package/src/r23c_cwd_mismatch.py +7 -6
  64. package/src/r23d_chown_chmod_recursive.py +6 -6
  65. package/src/r23e_force_push_main.py +5 -6
  66. package/src/r23f_db_no_where.py +5 -6
  67. package/src/r23g_secrets_in_output.py +5 -5
  68. package/src/r23h_shebang_mismatch.py +6 -5
  69. package/src/r23i_auto_deploy_ignored.py +5 -6
  70. package/src/r23j_global_install.py +5 -6
  71. package/src/r23k_script_duplicates_skill.py +7 -6
  72. package/src/r23l_resource_collision.py +7 -6
  73. package/src/r23m_message_duplicate.py +6 -5
  74. package/src/r24_stale_memory.py +4 -9
  75. package/src/r25_nora_maria_read_only.py +5 -10
  76. package/src/r34_identity_coherence.py +6 -13
  77. package/src/r_catalog.py +3 -7
  78. package/src/resonance_map.py +13 -13
  79. package/src/runtime_power.py +29 -80
  80. package/src/script_registry.py +236 -6
  81. package/src/scripts/check-context.py +8 -25
  82. package/src/scripts/deep-sleep/extract.py +9 -16
  83. package/src/scripts/nexo-auto-update.py +27 -4
  84. package/src/scripts/nexo-catchup.py +9 -19
  85. package/src/scripts/nexo-cognitive-decay.py +26 -3
  86. package/src/scripts/nexo-daily-self-audit.py +50 -51
  87. package/src/scripts/nexo-email-migrate-config.py +30 -11
  88. package/src/scripts/nexo-email-monitor.py +97 -238
  89. package/src/scripts/nexo-followup-runner.py +70 -133
  90. package/src/scripts/nexo-hook-record.py +1 -1
  91. package/src/scripts/nexo-immune.py +6 -31
  92. package/src/scripts/nexo-impact-scorer.py +27 -4
  93. package/src/scripts/nexo-learning-housekeep.py +26 -3
  94. package/src/scripts/nexo-learning-validator.py +34 -32
  95. package/src/scripts/nexo-migrate.py +28 -12
  96. package/src/scripts/nexo-morning-agent.py +9 -23
  97. package/src/scripts/nexo-outcome-checker.py +27 -4
  98. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  99. package/src/scripts/nexo-pre-commit.py +28 -0
  100. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  101. package/src/scripts/nexo-reflection.py +33 -3
  102. package/src/scripts/nexo-runtime-preflight.py +27 -2
  103. package/src/scripts/nexo-send-reply.py +10 -8
  104. package/src/scripts/nexo-sleep.py +11 -25
  105. package/src/scripts/nexo-synthesis.py +7 -40
  106. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  107. package/src/scripts/nexo-watchdog.sh +23 -17
  108. package/src/scripts/phase_guardian_analysis.py +27 -4
  109. package/src/server.py +14 -3
  110. package/src/storage_router.py +8 -6
  111. package/src/t4_llm_gate.py +17 -84
  112. package/src/tools_drive.py +5 -13
  113. package/src/tools_guardian.py +3 -4
  114. package/src/tools_menu.py +2 -2
  115. package/src/tools_reminders_crud.py +17 -0
  116. package/src/tools_sessions.py +1 -4
  117. package/src/user_context.py +3 -6
  118. package/src/user_data_portability.py +141 -44
  119. package/templates/CLAUDE.md.template +11 -3
  120. package/templates/CODEX.AGENTS.md.template +11 -3
  121. package/templates/core-prompts/catchup-assessment.md +19 -0
  122. package/templates/core-prompts/check-context.md +24 -0
  123. package/templates/core-prompts/daily-self-audit.md +42 -0
  124. package/templates/core-prompts/daily-synthesis.md +40 -0
  125. package/templates/core-prompts/deep-sleep-extract-json-conversion.md +6 -0
  126. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  127. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  128. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  129. package/templates/core-prompts/email-monitor.md +202 -0
  130. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  131. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  132. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  133. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  134. package/templates/core-prompts/evolution-weekly.md +71 -0
  135. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  136. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  137. package/templates/core-prompts/followup-runner.md +74 -0
  138. package/templates/core-prompts/immune-triage.md +31 -0
  139. package/templates/core-prompts/interactive-startup.md +1 -0
  140. package/templates/core-prompts/json-object-only.md +1 -0
  141. package/templates/core-prompts/learning-validator.md +25 -0
  142. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  143. package/templates/core-prompts/morning-agent.md +23 -0
  144. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  145. package/templates/core-prompts/r-catalog.md +1 -0
  146. package/templates/core-prompts/r13-pre-edit-guard-injection.md +1 -0
  147. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  148. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  149. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  150. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  151. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  152. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  153. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  154. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  155. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  156. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  157. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  158. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  159. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  160. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  161. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  162. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  164. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  165. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  166. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  167. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  168. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  169. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  170. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  171. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  172. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  173. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  174. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  175. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  176. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  177. package/templates/core-prompts/sleep.md +25 -0
  178. package/templates/core-prompts/t4-r15-project-context-gate.md +14 -0
  179. package/templates/core-prompts/t4-r23e-force-push-gate.md +14 -0
  180. package/templates/core-prompts/t4-r23f-db-no-where-gate.md +14 -0
  181. package/templates/core-prompts/t4-r23h-shebang-mismatch-gate.md +14 -0
  182. package/templates/email-template.md +55 -0
  183. package/templates/nexo_helper.py +31 -13
  184. package/templates/plugin-template.py +3 -3
  185. package/templates/skill-template.md +2 -1
@@ -10,6 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  import tempfile
12
12
  import time
13
+ from functools import lru_cache
13
14
  from pathlib import Path
14
15
 
15
16
  try:
@@ -29,6 +30,7 @@ from client_preferences import (
29
30
  resolve_client_runtime_profile,
30
31
  resolve_terminal_client,
31
32
  )
33
+ from core_prompts import render_core_prompt
32
34
 
33
35
 
34
36
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
@@ -39,13 +41,6 @@ MODEL_PRICING_USD_PER_1M = {
39
41
  "gpt-5.4": {"input": 1.25, "cached_input": 0.125, "output": 10.0},
40
42
  "gpt-5.4-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.0},
41
43
  }
42
- INTERACTIVE_STARTUP_PROMPT = (
43
- "Start as NEXO for this session now. Use the managed bootstrap already installed "
44
- "for this client, run nexo_startup and nexo_heartbeat for this first turn, then "
45
- "reply with one concise startup status in the user's language."
46
- )
47
-
48
-
49
44
  class AgentRunnerError(RuntimeError):
50
45
  """Base exception for runner failures."""
51
46
 
@@ -438,10 +433,15 @@ def _codex_interactive_launch_flags() -> list[str]:
438
433
  return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
439
434
 
440
435
 
436
+ @lru_cache(maxsize=1)
437
+ def _interactive_startup_prompt_text() -> str:
438
+ return render_core_prompt("interactive-startup")
439
+
440
+
441
441
  def _interactive_startup_prompt(client: str) -> str:
442
442
  client_key = normalize_client_key(client)
443
443
  if client_key in {CLIENT_CLAUDE_CODE, CLIENT_CODEX}:
444
- return INTERACTIVE_STARTUP_PROMPT
444
+ return _interactive_startup_prompt_text()
445
445
  return ""
446
446
 
447
447
 
@@ -1206,6 +1206,7 @@ def probe_automation_backend(
1206
1206
  backend: str | None = None,
1207
1207
  cwd: str | os.PathLike[str] | None = None,
1208
1208
  timeout: int = 60,
1209
+ caller: str = "automation_probe",
1209
1210
  ) -> dict:
1210
1211
  selected_backend = backend or resolve_automation_backend()
1211
1212
  if selected_backend == BACKEND_NONE:
@@ -1221,6 +1222,7 @@ def probe_automation_backend(
1221
1222
  cwd=cwd,
1222
1223
  timeout=timeout,
1223
1224
  output_format="text",
1225
+ caller=caller,
1224
1226
  )
1225
1227
  except AutomationBackendUnavailableError as exc:
1226
1228
  return {
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import sys
12
12
  import datetime
13
+ from pathlib import Path
13
14
 
14
15
  # Ensure imports work both from ``src/auto_close_sessions.py`` and from the
15
16
  # packaged runtime copy under ``core/scripts/auto_close_sessions.py``.
@@ -27,10 +28,26 @@ from db import (
27
28
  get_orphan_sessions, read_checkpoint, write_session_diary, now_epoch,
28
29
  SESSION_STALE_SECONDS,
29
30
  )
31
+ try:
32
+ import paths
33
+ except ModuleNotFoundError as exc:
34
+ if getattr(exc, "name", "") != "paths":
35
+ raise
36
+
37
+ class _PathsFallback:
38
+ @staticmethod
39
+ def operations_dir():
40
+ return Path(os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))) / "operations"
41
+
42
+ @staticmethod
43
+ def coordination_dir():
44
+ return Path(os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))) / "coordination"
45
+
46
+ paths = _PathsFallback()
30
47
 
31
48
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
32
- LOG_DIR = os.path.join(NEXO_HOME, "operations", "tool-logs")
33
- AUTO_CLOSE_LOG = os.path.join(NEXO_HOME, "coordination", "auto-close.log")
49
+ LOG_DIR = str(paths.operations_dir() / "tool-logs")
50
+ AUTO_CLOSE_LOG = str(paths.coordination_dir() / "auto-close.log")
34
51
 
35
52
 
36
53
  def get_tool_log_summary(sid: str) -> str:
@@ -20,6 +20,17 @@ import threading
20
20
  import time
21
21
  from pathlib import Path
22
22
 
23
+ try:
24
+ from product_mode import enforce_desktop_product_contract
25
+ except ModuleNotFoundError as exc:
26
+ if getattr(exc, "name", "") != "product_mode":
27
+ raise
28
+ _core_runtime = Path(__file__).resolve().parent / "core"
29
+ if _core_runtime.is_dir():
30
+ core_path = str(_core_runtime)
31
+ if core_path not in sys.path:
32
+ sys.path.insert(0, core_path)
33
+ from product_mode import enforce_desktop_product_contract
23
34
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
24
35
 
25
36
  try:
@@ -87,6 +98,13 @@ _CORE_SCRIPT_RUNTIME_FILES = frozenset({
87
98
  ".watchdog-fails",
88
99
  ".watchdog-nexo-repair.lock",
89
100
  })
101
+ _LEGACY_PERSONAL_BRAIN_DB_STUBS = frozenset({
102
+ "brain.db",
103
+ "cron_runs.db",
104
+ "nexo.db",
105
+ "nexo_brain.db",
106
+ "runtime.db",
107
+ })
90
108
 
91
109
 
92
110
  def _log(msg: str):
@@ -188,6 +206,101 @@ def _cleanup_legacy_root_db_stubs(runtime_root: Path = NEXO_HOME, *, dry_run: bo
188
206
  return report
189
207
 
190
208
 
209
+ def _sqlite_table_names(db_path: Path) -> list[str] | None:
210
+ """Return user tables for a SQLite file or ``None`` when unreadable."""
211
+ import sqlite3
212
+
213
+ conn = None
214
+ try:
215
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
216
+ rows = conn.execute(
217
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
218
+ ).fetchall()
219
+ return [str(row[0]) for row in rows if row and row[0]]
220
+ except Exception:
221
+ return None
222
+ finally:
223
+ if conn is not None:
224
+ try:
225
+ conn.close()
226
+ except Exception:
227
+ pass
228
+
229
+
230
+ def _cleanup_empty_personal_brain_db_stubs(runtime_root: Path = NEXO_HOME, *, dry_run: bool = False) -> dict:
231
+ """Archive obsolete DB shells from ``personal/brain`` after F0.6 migration.
232
+
233
+ Older layouts and partial migrations sometimes left duplicate DB files in
234
+ ``personal/brain``. Post-F0.6 those DBs are not canonical anymore: the live
235
+ runtime DBs live under ``runtime/data``. This helper only archives files
236
+ from a known allowlist when they are clearly empty residue:
237
+ - zero-byte files, or
238
+ - SQLite shells with zero tables
239
+
240
+ Any file with real tables is left untouched.
241
+ """
242
+ runtime_root = Path(runtime_root)
243
+ personal_brain_dir = runtime_root / "personal" / "brain"
244
+ report = {
245
+ "ok": True,
246
+ "dry_run": dry_run,
247
+ "candidates": [],
248
+ "archived": [],
249
+ "skipped": [],
250
+ "errors": [],
251
+ }
252
+ if not personal_brain_dir.is_dir():
253
+ return report
254
+
255
+ backup_root: Path | None = None
256
+ for name in sorted(_LEGACY_PERSONAL_BRAIN_DB_STUBS):
257
+ candidate = personal_brain_dir / name
258
+ if not candidate.is_file():
259
+ continue
260
+ try:
261
+ size = int(candidate.stat().st_size)
262
+ except OSError as exc:
263
+ report["errors"].append({"path": str(candidate), "error": str(exc)})
264
+ continue
265
+
266
+ reason = ""
267
+ if size == 0:
268
+ reason = "zero-byte"
269
+ else:
270
+ tables = _sqlite_table_names(candidate)
271
+ if tables is None:
272
+ report["skipped"].append({"path": str(candidate), "reason": "uninspectable"})
273
+ continue
274
+ if tables:
275
+ report["skipped"].append({
276
+ "path": str(candidate),
277
+ "reason": "has-tables",
278
+ "table_count": len(tables),
279
+ })
280
+ continue
281
+ reason = "sqlite-empty-shell"
282
+
283
+ report["candidates"].append({"path": str(candidate), "size": size, "reason": reason})
284
+ if dry_run:
285
+ continue
286
+
287
+ if backup_root is None:
288
+ timestamp = time.strftime("%Y-%m-%d-%H%M%S", time.gmtime())
289
+ backup_root = paths.backups_dir() / f"legacy-personal-brain-db-stubs-{timestamp}"
290
+ backup_root.mkdir(parents=True, exist_ok=True)
291
+ target = backup_root / candidate.name
292
+ try:
293
+ shutil.move(str(candidate), str(target))
294
+ report["archived"].append({
295
+ "path": str(candidate),
296
+ "backup_path": str(target),
297
+ "reason": reason,
298
+ })
299
+ except Exception as exc:
300
+ report["errors"].append({"path": str(candidate), "error": str(exc)})
301
+ return report
302
+
303
+
191
304
  def _validate_db_backup(source_db: Path, backup_db: Path) -> dict:
192
305
  """Check that a backup preserves non-empty critical tables from the source DB."""
193
306
  report = {
@@ -378,6 +491,7 @@ def _runtime_cli_wrapper_text() -> str:
378
491
  'fi\n'
379
492
  'NEXO_HOME="$RUNTIME_HOME"\n'
380
493
  'export NEXO_HOME\n'
494
+ 'export PYTHONDONTWRITEBYTECODE=1\n'
381
495
  'resolve_code_dir() {\n'
382
496
  ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then\n'
383
497
  ' printf \'%s\\n\' "${NEXO_CODE%/}"\n'
@@ -807,6 +921,17 @@ def _download_local_classifier_model() -> tuple[bool, str]:
807
921
  return True, ""
808
922
 
809
923
 
924
+ def _local_classifier_install_command() -> list[str]:
925
+ in_virtualenv = bool(os.environ.get("VIRTUAL_ENV")) or (
926
+ getattr(sys, "prefix", "") != getattr(sys, "base_prefix", getattr(sys, "prefix", ""))
927
+ )
928
+ cmd = [sys.executable, "-m", "pip", "install"]
929
+ if not in_virtualenv:
930
+ cmd.append("--user")
931
+ cmd.extend(CLASSIFIER_INSTALL_PACKAGES)
932
+ return cmd
933
+
934
+
810
935
  def _install_local_classifier_worker() -> None:
811
936
  from classifier_local import MODEL_REVISION
812
937
 
@@ -832,7 +957,7 @@ def _install_local_classifier_worker() -> None:
832
957
  })
833
958
  return
834
959
 
835
- install_cmd = ["pip3", "install", "--user", *CLASSIFIER_INSTALL_PACKAGES]
960
+ install_cmd = _local_classifier_install_command()
836
961
  _write_classifier_install_log("[classifier-install] " + " ".join(install_cmd))
837
962
  try:
838
963
  result = subprocess.run(
@@ -1441,21 +1566,24 @@ def _migrate_effort_to_resonance(dest: Path = NEXO_HOME) -> list[str]:
1441
1566
 
1442
1567
  def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1443
1568
  """Ensure ``resonance_tiers.json`` lives at the public contract path
1444
- ``NEXO_HOME/brain/resonance_tiers.json`` and purge the legacy copy at
1445
- ``NEXO_HOME/resonance_tiers.json``.
1569
+ ``NEXO_HOME/personal/brain/resonance_tiers.json`` and purge the legacy
1570
+ copy at ``NEXO_HOME/resonance_tiers.json``.
1446
1571
 
1447
1572
  Context: v6.0.0 defined the public contract (read by NEXO Desktop) as
1448
- ``~/.nexo/brain/resonance_tiers.json`` but the installer kept copying
1449
- the file to ``~/.nexo/resonance_tiers.json`` (legacy flat-file layout),
1450
- so Desktop failed with *"NEXO Brain contract missing"* until the user
1451
- moved the file by hand. v6.0.3 publishes straight to ``brain/`` and
1452
- this migration reconciles existing runtimes.
1453
-
1454
- Idempotent: no-op once the contract file is in ``brain/`` and the
1455
- legacy file is gone. Never raises — migration must not block an update.
1573
+ ``~/.nexo/brain/resonance_tiers.json`` but the F0.6 canonical location is
1574
+ ``~/.nexo/personal/brain/resonance_tiers.json``. Older installers also
1575
+ copied the file to ``~/.nexo/resonance_tiers.json`` (legacy flat-file
1576
+ layout), so Desktop failed with *"NEXO Brain contract missing"* until the
1577
+ user moved the file by hand. The runtime now publishes to the canonical
1578
+ F0.6 brain dir while legacy ``~/.nexo/brain`` remains only a compatibility
1579
+ alias when present.
1580
+
1581
+ Idempotent: no-op once the contract file is in ``personal/brain`` and the
1582
+ legacy flat-file copy is gone. Never raises — migration must not block an
1583
+ update.
1456
1584
  """
1457
1585
  actions: list[str] = []
1458
- brain_dir = dest / "brain"
1586
+ brain_dir = dest / "personal" / "brain"
1459
1587
  contract_path = brain_dir / "resonance_tiers.json"
1460
1588
  legacy_path = dest / "resonance_tiers.json"
1461
1589
 
@@ -1465,7 +1593,8 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1465
1593
  actions.append(f"resonance-contract-relocate-warning:mkdir:{exc.__class__.__name__}")
1466
1594
  return actions
1467
1595
 
1468
- # If the contract already exists in brain/, just drop the legacy copy.
1596
+ # If the contract already exists in the canonical brain dir, just drop the
1597
+ # legacy flat-file copy.
1469
1598
  if contract_path.is_file():
1470
1599
  if legacy_path.is_file():
1471
1600
  try:
@@ -1475,7 +1604,8 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1475
1604
  actions.append(f"resonance-contract-relocate-warning:unlink:{exc.__class__.__name__}")
1476
1605
  return actions
1477
1606
 
1478
- # Contract missing from brain/ — promote the legacy file if present.
1607
+ # Contract missing from the canonical brain dir — promote the legacy file
1608
+ # if present.
1479
1609
  if legacy_path.is_file():
1480
1610
  try:
1481
1611
  contract_path.write_bytes(legacy_path.read_bytes())
@@ -1960,7 +2090,6 @@ def _run_db_migrations() -> bool:
1960
2090
  # personal_scripts.path + LaunchAgent plists, then write the
1961
2091
  # F0.6 marker. Idempotent: returns immediately when already F0.6.
1962
2092
  _maybe_migrate_to_f06_layout()
1963
- _ensure_f06_legacy_shims()
1964
2093
  try:
1965
2094
  _rewrite_f06_launch_agents()
1966
2095
  except Exception:
@@ -2128,6 +2257,49 @@ def _f06_packaged_code_file_targets() -> list[tuple[str, Path]]:
2128
2257
  return targets
2129
2258
 
2130
2259
 
2260
+ def _f06_live_legacy_paths() -> list[Path]:
2261
+ """Return real pre-F0.6 paths that still need migration.
2262
+
2263
+ Symlink shims do not count here. We only care about physical files or
2264
+ directories that keep the flat layout alive as a second source of truth.
2265
+ """
2266
+ legacy_anchors = [
2267
+ NEXO_HOME / sub
2268
+ for sub in (
2269
+ "scripts",
2270
+ "brain",
2271
+ "data",
2272
+ "operations",
2273
+ "logs",
2274
+ "backups",
2275
+ "memory",
2276
+ "cognitive",
2277
+ "coordination",
2278
+ "exports",
2279
+ "nexo-email",
2280
+ "doctor",
2281
+ "snapshots",
2282
+ "crons",
2283
+ "skills",
2284
+ "plugins",
2285
+ "hooks",
2286
+ "rules",
2287
+ "db",
2288
+ "dashboard",
2289
+ "skills-core",
2290
+ )
2291
+ ]
2292
+ present = [p for p in legacy_anchors if p.exists() and not p.is_symlink()]
2293
+ if present:
2294
+ return present
2295
+ live_files: list[Path] = []
2296
+ for legacy_name, _canonical in _f06_packaged_code_file_targets():
2297
+ candidate = NEXO_HOME / legacy_name
2298
+ if candidate.exists() and not candidate.is_symlink():
2299
+ live_files.append(candidate)
2300
+ return live_files
2301
+
2302
+
2131
2303
  def _promote_packaged_runtime_code_to_core() -> None:
2132
2304
  """Move packaged runtime code out of ``~/.nexo`` root into ``core/``.
2133
2305
 
@@ -2424,6 +2596,23 @@ def _rewrite_f06_launch_agents() -> int:
2424
2596
  return rewrites
2425
2597
 
2426
2598
 
2599
+ def _cleanup_f06_root_residue() -> None:
2600
+ """Remove root-level cache artifacts that should not survive in F0.6 installs."""
2601
+ residue_targets = [
2602
+ NEXO_HOME / "__pycache__",
2603
+ ]
2604
+ for target in residue_targets:
2605
+ try:
2606
+ if not target.exists() and not target.is_symlink():
2607
+ continue
2608
+ if target.is_symlink() or target.is_file():
2609
+ target.unlink(missing_ok=True)
2610
+ continue
2611
+ shutil.rmtree(target, ignore_errors=True)
2612
+ except Exception as exc:
2613
+ _log(f"[F0.6] residue cleanup skipped for {target}: {exc}")
2614
+
2615
+
2427
2616
  def _maybe_migrate_to_f06_layout() -> None:
2428
2617
  """Plan F0.6 — one-shot physical layout migration. Idempotent.
2429
2618
 
@@ -2449,7 +2638,9 @@ def _maybe_migrate_to_f06_layout() -> None:
2449
2638
  current = ""
2450
2639
  if current == "F0.6":
2451
2640
  _promote_packaged_runtime_code_to_core()
2452
- _ensure_f06_legacy_shims()
2641
+ if _f06_live_legacy_paths():
2642
+ _ensure_f06_legacy_shims()
2643
+ _cleanup_f06_root_residue()
2453
2644
  try:
2454
2645
  _rewrite_f06_launch_agents()
2455
2646
  except Exception:
@@ -2457,14 +2648,7 @@ def _maybe_migrate_to_f06_layout() -> None:
2457
2648
  return # already migrated
2458
2649
  # Refuse to migrate from a non-NEXO_HOME or a fresh install
2459
2650
  # without legacy dirs to move.
2460
- legacy_anchors = [NEXO_HOME / sub for sub in
2461
- ("scripts", "brain", "data", "operations", "logs", "backups",
2462
- "memory", "cognitive", "coordination", "exports",
2463
- "nexo-email", "doctor", "snapshots", "crons",
2464
- "skills", "plugins", "hooks", "rules", "db", "dashboard", "skills-core")]
2465
- present = [p for p in legacy_anchors if p.exists() and not p.is_symlink()]
2466
- if not present:
2467
- present = [NEXO_HOME / name for name, _ in _f06_packaged_code_file_targets() if (NEXO_HOME / name).exists()]
2651
+ present = _f06_live_legacy_paths()
2468
2652
  if not present:
2469
2653
  # Fresh install — write marker and return.
2470
2654
  try:
@@ -2671,6 +2855,7 @@ def _maybe_migrate_to_f06_layout() -> None:
2671
2855
  except Exception as e:
2672
2856
  _log(f"[F0.6] marker write failed: {e}")
2673
2857
  _ensure_f06_legacy_shims()
2858
+ _cleanup_f06_root_residue()
2674
2859
  try:
2675
2860
  rewritten = _rewrite_f06_launch_agents()
2676
2861
  if rewritten:
@@ -3407,7 +3592,14 @@ def _auto_update_check_locked() -> dict:
3407
3592
  except Exception as e:
3408
3593
  _log(f"evolution-objective.json backfill error: {e}")
3409
3594
 
3410
- # Backfill NEXO_HOME/scripts/ for existing installs
3595
+ try:
3596
+ desktop_contract = enforce_desktop_product_contract(source="auto_update")
3597
+ if desktop_contract.get("applied") and desktop_contract.get("changed_objective"):
3598
+ _log("Desktop product contract enforced: evolution disabled")
3599
+ except Exception as e:
3600
+ _log(f"desktop product contract error: {e}")
3601
+
3602
+ # Backfill installed core/scripts/ for existing installs
3411
3603
  try:
3412
3604
  scripts_dest = paths.core_scripts_dir()
3413
3605
  # Deduce NEXO_CODE: env var first, then from __file__ (auto_update.py is in src/)
@@ -3422,7 +3614,7 @@ def _auto_update_check_locked() -> dict:
3422
3614
  dest = scripts_dest / f.name
3423
3615
  if f.is_file() and not dest.exists():
3424
3616
  shutil.copy2(str(f), str(dest))
3425
- _log("Backfilled NEXO_HOME/scripts/ from NEXO_CODE for existing install")
3617
+ _log("Backfilled NEXO_HOME/core/scripts from repo scripts for existing install")
3426
3618
  except Exception as e:
3427
3619
  _log(f"scripts backfill error: {e}")
3428
3620
 
@@ -4086,7 +4278,6 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4086
4278
  try:
4087
4279
  _emit_progress(progress_fn, "Canonicalizing packaged runtime layout...")
4088
4280
  _maybe_migrate_to_f06_layout()
4089
- _ensure_f06_legacy_shims()
4090
4281
  try:
4091
4282
  _rewrite_f06_launch_agents()
4092
4283
  except Exception:
@@ -4103,6 +4294,22 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4103
4294
  except Exception as exc:
4104
4295
  actions.append(f"legacy-root-db-stubs-warning:{exc.__class__.__name__}")
4105
4296
 
4297
+ try:
4298
+ personal_brain_cleanup = _cleanup_empty_personal_brain_db_stubs(dest, dry_run=False)
4299
+ archived = len(personal_brain_cleanup.get("archived", []) or [])
4300
+ if archived:
4301
+ actions.append(f"legacy-personal-brain-db-stubs-archived:{archived}")
4302
+ except Exception as exc:
4303
+ actions.append(f"legacy-personal-brain-db-stubs-warning:{exc.__class__.__name__}")
4304
+
4305
+ try:
4306
+ _emit_progress(progress_fn, "Applying Desktop product contract...")
4307
+ contract = enforce_desktop_product_contract(source="runtime_post_sync")
4308
+ if contract.get("applied"):
4309
+ actions.append("desktop-product-contract")
4310
+ except Exception as exc:
4311
+ actions.append(f"desktop-product-contract-warning:{exc.__class__.__name__}")
4312
+
4106
4313
  code_root = _runtime_code_dir(dest)
4107
4314
  env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(code_root)}
4108
4315
 
@@ -4344,6 +4551,13 @@ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[s
4344
4551
  actions: list[str] = []
4345
4552
  parts: list[str] = []
4346
4553
 
4554
+ renamed = reconcile_result.get("renamed_legacy_filenames", {})
4555
+ if isinstance(renamed, dict):
4556
+ renamed_count = len(renamed.get("renamed", []) or [])
4557
+ if renamed_count:
4558
+ actions.append(f"personal-script-filenames-renamed:{renamed_count}")
4559
+ parts.append(f"{renamed_count} legacy personal filenames normalized")
4560
+
4347
4561
  retired = reconcile_result.get("retired_superseded_scripts", {})
4348
4562
  if isinstance(retired, dict):
4349
4563
  archived = len(retired.get("archived", []) or [])
@@ -4440,7 +4654,7 @@ def manual_sync_update(
4440
4654
  if copy_stats.get("script_conflicts"):
4441
4655
  sync_result["actions"].append(f"preserved-personal-scripts:{len(copy_stats['script_conflicts'])}")
4442
4656
  sync_result["warnings"].append(
4443
- f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/scripts"
4657
+ f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/personal/scripts"
4444
4658
  )
4445
4659
  # Update runtime dependencies (best-effort)
4446
4660
  try: