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
@@ -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,7 @@ import threading
20
20
  import time
21
21
  from pathlib import Path
22
22
 
23
+ from product_mode import enforce_desktop_product_contract
23
24
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
24
25
 
25
26
  try:
@@ -87,6 +88,13 @@ _CORE_SCRIPT_RUNTIME_FILES = frozenset({
87
88
  ".watchdog-fails",
88
89
  ".watchdog-nexo-repair.lock",
89
90
  })
91
+ _LEGACY_PERSONAL_BRAIN_DB_STUBS = frozenset({
92
+ "brain.db",
93
+ "cron_runs.db",
94
+ "nexo.db",
95
+ "nexo_brain.db",
96
+ "runtime.db",
97
+ })
90
98
 
91
99
 
92
100
  def _log(msg: str):
@@ -188,6 +196,101 @@ def _cleanup_legacy_root_db_stubs(runtime_root: Path = NEXO_HOME, *, dry_run: bo
188
196
  return report
189
197
 
190
198
 
199
+ def _sqlite_table_names(db_path: Path) -> list[str] | None:
200
+ """Return user tables for a SQLite file or ``None`` when unreadable."""
201
+ import sqlite3
202
+
203
+ conn = None
204
+ try:
205
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
206
+ rows = conn.execute(
207
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
208
+ ).fetchall()
209
+ return [str(row[0]) for row in rows if row and row[0]]
210
+ except Exception:
211
+ return None
212
+ finally:
213
+ if conn is not None:
214
+ try:
215
+ conn.close()
216
+ except Exception:
217
+ pass
218
+
219
+
220
+ def _cleanup_empty_personal_brain_db_stubs(runtime_root: Path = NEXO_HOME, *, dry_run: bool = False) -> dict:
221
+ """Archive obsolete DB shells from ``personal/brain`` after F0.6 migration.
222
+
223
+ Older layouts and partial migrations sometimes left duplicate DB files in
224
+ ``personal/brain``. Post-F0.6 those DBs are not canonical anymore: the live
225
+ runtime DBs live under ``runtime/data``. This helper only archives files
226
+ from a known allowlist when they are clearly empty residue:
227
+ - zero-byte files, or
228
+ - SQLite shells with zero tables
229
+
230
+ Any file with real tables is left untouched.
231
+ """
232
+ runtime_root = Path(runtime_root)
233
+ personal_brain_dir = runtime_root / "personal" / "brain"
234
+ report = {
235
+ "ok": True,
236
+ "dry_run": dry_run,
237
+ "candidates": [],
238
+ "archived": [],
239
+ "skipped": [],
240
+ "errors": [],
241
+ }
242
+ if not personal_brain_dir.is_dir():
243
+ return report
244
+
245
+ backup_root: Path | None = None
246
+ for name in sorted(_LEGACY_PERSONAL_BRAIN_DB_STUBS):
247
+ candidate = personal_brain_dir / name
248
+ if not candidate.is_file():
249
+ continue
250
+ try:
251
+ size = int(candidate.stat().st_size)
252
+ except OSError as exc:
253
+ report["errors"].append({"path": str(candidate), "error": str(exc)})
254
+ continue
255
+
256
+ reason = ""
257
+ if size == 0:
258
+ reason = "zero-byte"
259
+ else:
260
+ tables = _sqlite_table_names(candidate)
261
+ if tables is None:
262
+ report["skipped"].append({"path": str(candidate), "reason": "uninspectable"})
263
+ continue
264
+ if tables:
265
+ report["skipped"].append({
266
+ "path": str(candidate),
267
+ "reason": "has-tables",
268
+ "table_count": len(tables),
269
+ })
270
+ continue
271
+ reason = "sqlite-empty-shell"
272
+
273
+ report["candidates"].append({"path": str(candidate), "size": size, "reason": reason})
274
+ if dry_run:
275
+ continue
276
+
277
+ if backup_root is None:
278
+ timestamp = time.strftime("%Y-%m-%d-%H%M%S", time.gmtime())
279
+ backup_root = paths.backups_dir() / f"legacy-personal-brain-db-stubs-{timestamp}"
280
+ backup_root.mkdir(parents=True, exist_ok=True)
281
+ target = backup_root / candidate.name
282
+ try:
283
+ shutil.move(str(candidate), str(target))
284
+ report["archived"].append({
285
+ "path": str(candidate),
286
+ "backup_path": str(target),
287
+ "reason": reason,
288
+ })
289
+ except Exception as exc:
290
+ report["errors"].append({"path": str(candidate), "error": str(exc)})
291
+ return report
292
+
293
+
191
294
  def _validate_db_backup(source_db: Path, backup_db: Path) -> dict:
192
295
  """Check that a backup preserves non-empty critical tables from the source DB."""
193
296
  report = {
@@ -378,6 +481,7 @@ def _runtime_cli_wrapper_text() -> str:
378
481
  'fi\n'
379
482
  'NEXO_HOME="$RUNTIME_HOME"\n'
380
483
  'export NEXO_HOME\n'
484
+ 'export PYTHONDONTWRITEBYTECODE=1\n'
381
485
  'resolve_code_dir() {\n'
382
486
  ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then\n'
383
487
  ' printf \'%s\\n\' "${NEXO_CODE%/}"\n'
@@ -807,6 +911,17 @@ def _download_local_classifier_model() -> tuple[bool, str]:
807
911
  return True, ""
808
912
 
809
913
 
914
+ def _local_classifier_install_command() -> list[str]:
915
+ in_virtualenv = bool(os.environ.get("VIRTUAL_ENV")) or (
916
+ getattr(sys, "prefix", "") != getattr(sys, "base_prefix", getattr(sys, "prefix", ""))
917
+ )
918
+ cmd = [sys.executable, "-m", "pip", "install"]
919
+ if not in_virtualenv:
920
+ cmd.append("--user")
921
+ cmd.extend(CLASSIFIER_INSTALL_PACKAGES)
922
+ return cmd
923
+
924
+
810
925
  def _install_local_classifier_worker() -> None:
811
926
  from classifier_local import MODEL_REVISION
812
927
 
@@ -832,7 +947,7 @@ def _install_local_classifier_worker() -> None:
832
947
  })
833
948
  return
834
949
 
835
- install_cmd = ["pip3", "install", "--user", *CLASSIFIER_INSTALL_PACKAGES]
950
+ install_cmd = _local_classifier_install_command()
836
951
  _write_classifier_install_log("[classifier-install] " + " ".join(install_cmd))
837
952
  try:
838
953
  result = subprocess.run(
@@ -1441,21 +1556,24 @@ def _migrate_effort_to_resonance(dest: Path = NEXO_HOME) -> list[str]:
1441
1556
 
1442
1557
  def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1443
1558
  """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``.
1559
+ ``NEXO_HOME/personal/brain/resonance_tiers.json`` and purge the legacy
1560
+ copy at ``NEXO_HOME/resonance_tiers.json``.
1446
1561
 
1447
1562
  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.
1563
+ ``~/.nexo/brain/resonance_tiers.json`` but the F0.6 canonical location is
1564
+ ``~/.nexo/personal/brain/resonance_tiers.json``. Older installers also
1565
+ copied the file to ``~/.nexo/resonance_tiers.json`` (legacy flat-file
1566
+ layout), so Desktop failed with *"NEXO Brain contract missing"* until the
1567
+ user moved the file by hand. The runtime now publishes to the canonical
1568
+ F0.6 brain dir while legacy ``~/.nexo/brain`` remains only a compatibility
1569
+ alias when present.
1570
+
1571
+ Idempotent: no-op once the contract file is in ``personal/brain`` and the
1572
+ legacy flat-file copy is gone. Never raises — migration must not block an
1573
+ update.
1456
1574
  """
1457
1575
  actions: list[str] = []
1458
- brain_dir = dest / "brain"
1576
+ brain_dir = dest / "personal" / "brain"
1459
1577
  contract_path = brain_dir / "resonance_tiers.json"
1460
1578
  legacy_path = dest / "resonance_tiers.json"
1461
1579
 
@@ -1465,7 +1583,8 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1465
1583
  actions.append(f"resonance-contract-relocate-warning:mkdir:{exc.__class__.__name__}")
1466
1584
  return actions
1467
1585
 
1468
- # If the contract already exists in brain/, just drop the legacy copy.
1586
+ # If the contract already exists in the canonical brain dir, just drop the
1587
+ # legacy flat-file copy.
1469
1588
  if contract_path.is_file():
1470
1589
  if legacy_path.is_file():
1471
1590
  try:
@@ -1475,7 +1594,8 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1475
1594
  actions.append(f"resonance-contract-relocate-warning:unlink:{exc.__class__.__name__}")
1476
1595
  return actions
1477
1596
 
1478
- # Contract missing from brain/ — promote the legacy file if present.
1597
+ # Contract missing from the canonical brain dir — promote the legacy file
1598
+ # if present.
1479
1599
  if legacy_path.is_file():
1480
1600
  try:
1481
1601
  contract_path.write_bytes(legacy_path.read_bytes())
@@ -1960,7 +2080,6 @@ def _run_db_migrations() -> bool:
1960
2080
  # personal_scripts.path + LaunchAgent plists, then write the
1961
2081
  # F0.6 marker. Idempotent: returns immediately when already F0.6.
1962
2082
  _maybe_migrate_to_f06_layout()
1963
- _ensure_f06_legacy_shims()
1964
2083
  try:
1965
2084
  _rewrite_f06_launch_agents()
1966
2085
  except Exception:
@@ -2128,6 +2247,49 @@ def _f06_packaged_code_file_targets() -> list[tuple[str, Path]]:
2128
2247
  return targets
2129
2248
 
2130
2249
 
2250
+ def _f06_live_legacy_paths() -> list[Path]:
2251
+ """Return real pre-F0.6 paths that still need migration.
2252
+
2253
+ Symlink shims do not count here. We only care about physical files or
2254
+ directories that keep the flat layout alive as a second source of truth.
2255
+ """
2256
+ legacy_anchors = [
2257
+ NEXO_HOME / sub
2258
+ for sub in (
2259
+ "scripts",
2260
+ "brain",
2261
+ "data",
2262
+ "operations",
2263
+ "logs",
2264
+ "backups",
2265
+ "memory",
2266
+ "cognitive",
2267
+ "coordination",
2268
+ "exports",
2269
+ "nexo-email",
2270
+ "doctor",
2271
+ "snapshots",
2272
+ "crons",
2273
+ "skills",
2274
+ "plugins",
2275
+ "hooks",
2276
+ "rules",
2277
+ "db",
2278
+ "dashboard",
2279
+ "skills-core",
2280
+ )
2281
+ ]
2282
+ present = [p for p in legacy_anchors if p.exists() and not p.is_symlink()]
2283
+ if present:
2284
+ return present
2285
+ live_files: list[Path] = []
2286
+ for legacy_name, _canonical in _f06_packaged_code_file_targets():
2287
+ candidate = NEXO_HOME / legacy_name
2288
+ if candidate.exists() and not candidate.is_symlink():
2289
+ live_files.append(candidate)
2290
+ return live_files
2291
+
2292
+
2131
2293
  def _promote_packaged_runtime_code_to_core() -> None:
2132
2294
  """Move packaged runtime code out of ``~/.nexo`` root into ``core/``.
2133
2295
 
@@ -2424,6 +2586,23 @@ def _rewrite_f06_launch_agents() -> int:
2424
2586
  return rewrites
2425
2587
 
2426
2588
 
2589
+ def _cleanup_f06_root_residue() -> None:
2590
+ """Remove root-level cache artifacts that should not survive in F0.6 installs."""
2591
+ residue_targets = [
2592
+ NEXO_HOME / "__pycache__",
2593
+ ]
2594
+ for target in residue_targets:
2595
+ try:
2596
+ if not target.exists() and not target.is_symlink():
2597
+ continue
2598
+ if target.is_symlink() or target.is_file():
2599
+ target.unlink(missing_ok=True)
2600
+ continue
2601
+ shutil.rmtree(target, ignore_errors=True)
2602
+ except Exception as exc:
2603
+ _log(f"[F0.6] residue cleanup skipped for {target}: {exc}")
2604
+
2605
+
2427
2606
  def _maybe_migrate_to_f06_layout() -> None:
2428
2607
  """Plan F0.6 — one-shot physical layout migration. Idempotent.
2429
2608
 
@@ -2449,7 +2628,9 @@ def _maybe_migrate_to_f06_layout() -> None:
2449
2628
  current = ""
2450
2629
  if current == "F0.6":
2451
2630
  _promote_packaged_runtime_code_to_core()
2452
- _ensure_f06_legacy_shims()
2631
+ if _f06_live_legacy_paths():
2632
+ _ensure_f06_legacy_shims()
2633
+ _cleanup_f06_root_residue()
2453
2634
  try:
2454
2635
  _rewrite_f06_launch_agents()
2455
2636
  except Exception:
@@ -2457,14 +2638,7 @@ def _maybe_migrate_to_f06_layout() -> None:
2457
2638
  return # already migrated
2458
2639
  # Refuse to migrate from a non-NEXO_HOME or a fresh install
2459
2640
  # 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()]
2641
+ present = _f06_live_legacy_paths()
2468
2642
  if not present:
2469
2643
  # Fresh install — write marker and return.
2470
2644
  try:
@@ -2671,6 +2845,7 @@ def _maybe_migrate_to_f06_layout() -> None:
2671
2845
  except Exception as e:
2672
2846
  _log(f"[F0.6] marker write failed: {e}")
2673
2847
  _ensure_f06_legacy_shims()
2848
+ _cleanup_f06_root_residue()
2674
2849
  try:
2675
2850
  rewritten = _rewrite_f06_launch_agents()
2676
2851
  if rewritten:
@@ -3407,7 +3582,14 @@ def _auto_update_check_locked() -> dict:
3407
3582
  except Exception as e:
3408
3583
  _log(f"evolution-objective.json backfill error: {e}")
3409
3584
 
3410
- # Backfill NEXO_HOME/scripts/ for existing installs
3585
+ try:
3586
+ desktop_contract = enforce_desktop_product_contract(source="auto_update")
3587
+ if desktop_contract.get("applied") and desktop_contract.get("changed_objective"):
3588
+ _log("Desktop product contract enforced: evolution disabled")
3589
+ except Exception as e:
3590
+ _log(f"desktop product contract error: {e}")
3591
+
3592
+ # Backfill installed core/scripts/ for existing installs
3411
3593
  try:
3412
3594
  scripts_dest = paths.core_scripts_dir()
3413
3595
  # Deduce NEXO_CODE: env var first, then from __file__ (auto_update.py is in src/)
@@ -3422,7 +3604,7 @@ def _auto_update_check_locked() -> dict:
3422
3604
  dest = scripts_dest / f.name
3423
3605
  if f.is_file() and not dest.exists():
3424
3606
  shutil.copy2(str(f), str(dest))
3425
- _log("Backfilled NEXO_HOME/scripts/ from NEXO_CODE for existing install")
3607
+ _log("Backfilled NEXO_HOME/core/scripts from repo scripts for existing install")
3426
3608
  except Exception as e:
3427
3609
  _log(f"scripts backfill error: {e}")
3428
3610
 
@@ -3612,6 +3794,23 @@ def _resolve_sync_source() -> tuple[Path | None, Path | None]:
3612
3794
  return candidate
3613
3795
  return None
3614
3796
 
3797
+ try:
3798
+ runtime_core = (dest / "core").resolve()
3799
+ code_resolved = NEXO_CODE.resolve()
3800
+ except Exception:
3801
+ runtime_core = dest / "core"
3802
+ code_resolved = NEXO_CODE
3803
+
3804
+ # Packaged/runtime-only installs resolve the launcher to ``~/.nexo/core``.
3805
+ # Those must use the packaged updater path instead of treating the managed
3806
+ # runtime itself as a mutable source repository. Only a recorded external
3807
+ # source repo in ``version.json`` should reactivate source-sync mode.
3808
+ if code_resolved == runtime_core:
3809
+ version_source = _runtime_version_source()
3810
+ if version_source:
3811
+ return version_source / "src", version_source
3812
+ return None, None
3813
+
3615
3814
  try:
3616
3815
  same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
3617
3816
  except Exception:
@@ -3798,6 +3997,31 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
3798
3997
  return str(backup_dir)
3799
3998
 
3800
3999
 
4000
+ def _remove_runtime_copy_target(target: Path) -> None:
4001
+ """Remove a runtime copy destination, handling symlinks explicitly.
4002
+
4003
+ F0.6 installs keep compatibility shims such as ``~/.nexo/db ->
4004
+ ~/.nexo/core/db``. ``shutil.rmtree(..., ignore_errors=True)`` does not
4005
+ remove those symlinks, which makes subsequent ``copytree(...)`` calls fail
4006
+ with ``FileExistsError`` during ``nexo update``. This helper treats
4007
+ symlink/file targets differently from real directories so runtime sync and
4008
+ rollback can replace shim paths safely.
4009
+ """
4010
+ import shutil
4011
+
4012
+ try:
4013
+ if target.is_symlink() or target.is_file():
4014
+ target.unlink()
4015
+ return
4016
+ if target.is_dir():
4017
+ shutil.rmtree(str(target), ignore_errors=True)
4018
+ return
4019
+ if target.exists():
4020
+ target.unlink()
4021
+ except FileNotFoundError:
4022
+ return
4023
+
4024
+
3801
4025
  def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
3802
4026
  import shutil
3803
4027
 
@@ -3807,11 +4031,11 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
3807
4031
  for item in bdir.iterdir():
3808
4032
  target = dest / item.name
3809
4033
  if item.is_dir():
3810
- if target.exists():
3811
- shutil.rmtree(target, ignore_errors=True)
4034
+ _remove_runtime_copy_target(target)
3812
4035
  shutil.copytree(str(item), str(target))
3813
4036
  else:
3814
4037
  target.parent.mkdir(parents=True, exist_ok=True)
4038
+ _remove_runtime_copy_target(target)
3815
4039
  shutil.copy2(str(item), str(target))
3816
4040
 
3817
4041
 
@@ -3834,8 +4058,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
3834
4058
  pkg_src = src_dir / pkg
3835
4059
  pkg_dest = dest / pkg
3836
4060
  if pkg_src.is_dir():
3837
- if pkg_dest.exists():
3838
- shutil.rmtree(str(pkg_dest), ignore_errors=True)
4061
+ _remove_runtime_copy_target(pkg_dest)
3839
4062
  shutil.copytree(
3840
4063
  str(pkg_src),
3841
4064
  str(pkg_dest),
@@ -3847,30 +4070,37 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
3847
4070
  for name in flat_files:
3848
4071
  src_file = src_dir / name
3849
4072
  if src_file.is_file():
3850
- shutil.copy2(str(src_file), str(dest / name))
4073
+ target = dest / name
4074
+ _remove_runtime_copy_target(target)
4075
+ shutil.copy2(str(src_file), str(target))
3851
4076
  copied_files += 1
3852
4077
 
3853
4078
  _emit_progress(progress_fn, "Copying plugin modules...")
3854
4079
  plugins_src = src_dir / "plugins"
3855
4080
  plugins_dest = dest / "plugins"
3856
4081
  if plugins_src.is_dir():
4082
+ if plugins_dest.is_symlink():
4083
+ _remove_runtime_copy_target(plugins_dest)
3857
4084
  plugins_dest.mkdir(parents=True, exist_ok=True)
3858
4085
  for item in plugins_src.iterdir():
3859
4086
  if item.is_file() and item.suffix == ".py" and not is_duplicate_artifact_name(item):
3860
- shutil.copy2(str(item), str(plugins_dest / item.name))
4087
+ target = plugins_dest / item.name
4088
+ _remove_runtime_copy_target(target)
4089
+ shutil.copy2(str(item), str(target))
3861
4090
 
3862
4091
  _emit_progress(progress_fn, "Copying scripts...")
3863
4092
  scripts_src = src_dir / "scripts"
3864
4093
  scripts_dest = dest / "scripts"
3865
4094
  if scripts_src.is_dir():
4095
+ if scripts_dest.is_symlink():
4096
+ _remove_runtime_copy_target(scripts_dest)
3866
4097
  scripts_dest.mkdir(parents=True, exist_ok=True)
3867
4098
  for item in scripts_src.iterdir():
3868
4099
  if item.name == "__pycache__" or item.name.startswith(".") or is_duplicate_artifact_name(item):
3869
4100
  continue
3870
4101
  dst = scripts_dest / item.name
3871
4102
  if item.is_dir():
3872
- if dst.exists():
3873
- shutil.rmtree(str(dst), ignore_errors=True)
4103
+ _remove_runtime_copy_target(dst)
3874
4104
  shutil.copytree(str(item), str(dst), ignore=_runtime_copy_ignore())
3875
4105
  elif item.is_file():
3876
4106
  existing_class = installed_script_classes.get(item.name, "")
@@ -3899,25 +4129,37 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
3899
4129
  templates_src = repo_dir / "templates"
3900
4130
  templates_dest = dest / "templates"
3901
4131
  if templates_src.is_dir():
4132
+ if templates_dest.is_symlink():
4133
+ _remove_runtime_copy_target(templates_dest)
3902
4134
  templates_dest.mkdir(parents=True, exist_ok=True)
3903
4135
  for item in templates_src.iterdir():
3904
4136
  if item.name == "__pycache__" or is_duplicate_artifact_name(item):
3905
4137
  continue
3906
4138
  if item.is_file():
3907
- shutil.copy2(str(item), str(templates_dest / item.name))
4139
+ target = templates_dest / item.name
4140
+ _remove_runtime_copy_target(target)
4141
+ shutil.copy2(str(item), str(target))
3908
4142
  elif item.is_dir():
3909
4143
  sub_dest = templates_dest / item.name
4144
+ if sub_dest.is_symlink():
4145
+ _remove_runtime_copy_target(sub_dest)
3910
4146
  sub_dest.mkdir(parents=True, exist_ok=True)
3911
4147
  for sub in item.iterdir():
3912
4148
  if sub.is_file() and not is_duplicate_artifact_name(sub):
3913
- shutil.copy2(str(sub), str(sub_dest / sub.name))
4149
+ target = sub_dest / sub.name
4150
+ _remove_runtime_copy_target(target)
4151
+ shutil.copy2(str(sub), str(target))
3914
4152
 
3915
4153
  package_json = repo_dir / "package.json"
3916
4154
  if package_json.is_file():
3917
- shutil.copy2(str(package_json), str(dest / "package.json"))
4155
+ package_target = dest / "package.json"
4156
+ _remove_runtime_copy_target(package_target)
4157
+ shutil.copy2(str(package_json), str(package_target))
3918
4158
  try:
3919
4159
  pkg = json.loads(package_json.read_text())
3920
- (dest / "version.json").write_text(json.dumps({
4160
+ version_target = dest / "version.json"
4161
+ _remove_runtime_copy_target(version_target)
4162
+ version_target.write_text(json.dumps({
3921
4163
  "version": pkg.get("version", "?"),
3922
4164
  "source": str(repo_dir),
3923
4165
  }, indent=2))
@@ -3928,8 +4170,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
3928
4170
  skills_src = src_dir / "skills"
3929
4171
  skills_dest = dest / "skills-core"
3930
4172
  if skills_src.is_dir():
3931
- if skills_dest.exists():
3932
- shutil.rmtree(str(skills_dest), ignore_errors=True)
4173
+ _remove_runtime_copy_target(skills_dest)
3933
4174
  shutil.copytree(str(skills_src), str(skills_dest), ignore=_runtime_copy_ignore())
3934
4175
 
3935
4176
  bin_dir = dest / "bin"
@@ -4027,7 +4268,6 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4027
4268
  try:
4028
4269
  _emit_progress(progress_fn, "Canonicalizing packaged runtime layout...")
4029
4270
  _maybe_migrate_to_f06_layout()
4030
- _ensure_f06_legacy_shims()
4031
4271
  try:
4032
4272
  _rewrite_f06_launch_agents()
4033
4273
  except Exception:
@@ -4044,6 +4284,22 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4044
4284
  except Exception as exc:
4045
4285
  actions.append(f"legacy-root-db-stubs-warning:{exc.__class__.__name__}")
4046
4286
 
4287
+ try:
4288
+ personal_brain_cleanup = _cleanup_empty_personal_brain_db_stubs(dest, dry_run=False)
4289
+ archived = len(personal_brain_cleanup.get("archived", []) or [])
4290
+ if archived:
4291
+ actions.append(f"legacy-personal-brain-db-stubs-archived:{archived}")
4292
+ except Exception as exc:
4293
+ actions.append(f"legacy-personal-brain-db-stubs-warning:{exc.__class__.__name__}")
4294
+
4295
+ try:
4296
+ _emit_progress(progress_fn, "Applying Desktop product contract...")
4297
+ contract = enforce_desktop_product_contract(source="runtime_post_sync")
4298
+ if contract.get("applied"):
4299
+ actions.append("desktop-product-contract")
4300
+ except Exception as exc:
4301
+ actions.append(f"desktop-product-contract-warning:{exc.__class__.__name__}")
4302
+
4047
4303
  code_root = _runtime_code_dir(dest)
4048
4304
  env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(code_root)}
4049
4305
 
@@ -4285,6 +4541,13 @@ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[s
4285
4541
  actions: list[str] = []
4286
4542
  parts: list[str] = []
4287
4543
 
4544
+ renamed = reconcile_result.get("renamed_legacy_filenames", {})
4545
+ if isinstance(renamed, dict):
4546
+ renamed_count = len(renamed.get("renamed", []) or [])
4547
+ if renamed_count:
4548
+ actions.append(f"personal-script-filenames-renamed:{renamed_count}")
4549
+ parts.append(f"{renamed_count} legacy personal filenames normalized")
4550
+
4288
4551
  retired = reconcile_result.get("retired_superseded_scripts", {})
4289
4552
  if isinstance(retired, dict):
4290
4553
  archived = len(retired.get("archived", []) or [])
@@ -4381,7 +4644,7 @@ def manual_sync_update(
4381
4644
  if copy_stats.get("script_conflicts"):
4382
4645
  sync_result["actions"].append(f"preserved-personal-scripts:{len(copy_stats['script_conflicts'])}")
4383
4646
  sync_result["warnings"].append(
4384
- f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/scripts"
4647
+ f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/personal/scripts"
4385
4648
  )
4386
4649
  # Update runtime dependencies (best-effort)
4387
4650
  try: