nexo-brain 7.1.1 → 7.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/bin/nexo-brain.js +198 -92
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +10 -8
  6. package/src/auto_close_sessions.py +19 -2
  7. package/src/auto_update.py +232 -28
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +181 -1
  11. package/src/cli_email.py +104 -73
  12. package/src/client_sync.py +22 -1
  13. package/src/cognitive/_core.py +5 -3
  14. package/src/core_prompts.py +50 -0
  15. package/src/cron_recovery.py +81 -7
  16. package/src/crons/manifest.json +57 -0
  17. package/src/crons/sync.py +95 -26
  18. package/src/dashboard/app.py +59 -0
  19. package/src/dashboard/templates/base.html +2 -0
  20. package/src/dashboard/templates/feature-disabled.html +27 -0
  21. package/src/db/_email_accounts.py +67 -18
  22. package/src/db/_fts.py +5 -5
  23. package/src/db/_personal_scripts.py +1 -1
  24. package/src/db/_skills.py +3 -3
  25. package/src/doctor/providers/runtime.py +35 -20
  26. package/src/email_config.py +18 -9
  27. package/src/enforcement_classifier.py +3 -12
  28. package/src/evolution_cycle.py +37 -149
  29. package/src/guardian_telemetry.py +3 -2
  30. package/src/hook_guardrails.py +61 -0
  31. package/src/hooks/capture-tool-logs.sh +11 -3
  32. package/src/hooks/daily-briefing-check.sh +7 -2
  33. package/src/hooks/heartbeat-enforcement.py +14 -1
  34. package/src/hooks/heartbeat-posttool.sh +2 -0
  35. package/src/hooks/heartbeat-user-msg.sh +2 -0
  36. package/src/hooks/inbox-hook.sh +6 -2
  37. package/src/hooks/post-compact.sh +12 -4
  38. package/src/hooks/pre-compact.sh +12 -4
  39. package/src/migrate_embeddings.py +5 -3
  40. package/src/nexo_migrate.py +3 -1
  41. package/src/plugin_loader.py +14 -5
  42. package/src/plugins/adaptive_mode.py +4 -1
  43. package/src/plugins/backup.py +32 -20
  44. package/src/plugins/evolution.py +2 -0
  45. package/src/plugins/memory_export.py +6 -1
  46. package/src/plugins/personal_plugins.py +17 -7
  47. package/src/plugins/personal_scripts.py +64 -3
  48. package/src/presets/entities_universal.json +67 -4
  49. package/src/product_mode.py +201 -0
  50. package/src/r14_correction_learning.py +5 -20
  51. package/src/r15_project_context.py +4 -10
  52. package/src/r16_declared_done.py +3 -16
  53. package/src/r17_promise_debt.py +3 -16
  54. package/src/r18_followup_autocomplete.py +5 -7
  55. package/src/r19_project_grep.py +5 -8
  56. package/src/r20_constant_change.py +5 -15
  57. package/src/r21_legacy_path.py +5 -7
  58. package/src/r22_personal_script.py +4 -8
  59. package/src/r23_ssh_without_atlas.py +4 -11
  60. package/src/r23b_deploy_vhost.py +7 -6
  61. package/src/r23c_cwd_mismatch.py +7 -6
  62. package/src/r23d_chown_chmod_recursive.py +6 -6
  63. package/src/r23e_force_push_main.py +5 -6
  64. package/src/r23f_db_no_where.py +5 -6
  65. package/src/r23g_secrets_in_output.py +5 -5
  66. package/src/r23h_shebang_mismatch.py +6 -5
  67. package/src/r23i_auto_deploy_ignored.py +5 -6
  68. package/src/r23j_global_install.py +5 -6
  69. package/src/r23k_script_duplicates_skill.py +7 -6
  70. package/src/r23l_resource_collision.py +7 -6
  71. package/src/r23m_message_duplicate.py +6 -5
  72. package/src/r24_stale_memory.py +4 -9
  73. package/src/r25_nora_maria_read_only.py +5 -10
  74. package/src/r34_identity_coherence.py +6 -13
  75. package/src/r_catalog.py +3 -7
  76. package/src/resonance_map.py +13 -13
  77. package/src/runtime_power.py +29 -80
  78. package/src/script_registry.py +236 -6
  79. package/src/scripts/check-context.py +8 -25
  80. package/src/scripts/deep-sleep/extract.py +6 -10
  81. package/src/scripts/nexo-auto-update.py +27 -4
  82. package/src/scripts/nexo-catchup.py +9 -19
  83. package/src/scripts/nexo-cognitive-decay.py +26 -3
  84. package/src/scripts/nexo-daily-self-audit.py +50 -51
  85. package/src/scripts/nexo-email-migrate-config.py +30 -11
  86. package/src/scripts/nexo-email-monitor.py +97 -238
  87. package/src/scripts/nexo-followup-runner.py +70 -133
  88. package/src/scripts/nexo-hook-record.py +1 -1
  89. package/src/scripts/nexo-immune.py +6 -31
  90. package/src/scripts/nexo-impact-scorer.py +27 -4
  91. package/src/scripts/nexo-learning-housekeep.py +26 -3
  92. package/src/scripts/nexo-learning-validator.py +34 -32
  93. package/src/scripts/nexo-migrate.py +28 -12
  94. package/src/scripts/nexo-morning-agent.py +9 -23
  95. package/src/scripts/nexo-outcome-checker.py +27 -4
  96. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  97. package/src/scripts/nexo-pre-commit.py +28 -0
  98. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  99. package/src/scripts/nexo-reflection.py +33 -3
  100. package/src/scripts/nexo-runtime-preflight.py +27 -2
  101. package/src/scripts/nexo-send-reply.py +10 -8
  102. package/src/scripts/nexo-sleep.py +11 -25
  103. package/src/scripts/nexo-synthesis.py +7 -40
  104. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  105. package/src/scripts/nexo-watchdog.sh +23 -17
  106. package/src/scripts/phase_guardian_analysis.py +27 -4
  107. package/src/server.py +14 -3
  108. package/src/storage_router.py +8 -6
  109. package/src/tools_drive.py +5 -13
  110. package/src/tools_guardian.py +3 -4
  111. package/src/tools_menu.py +2 -2
  112. package/src/tools_reminders_crud.py +17 -0
  113. package/src/tools_sessions.py +1 -4
  114. package/src/user_context.py +3 -6
  115. package/src/user_data_portability.py +31 -23
  116. package/templates/CLAUDE.md.template +11 -3
  117. package/templates/CODEX.AGENTS.md.template +11 -3
  118. package/templates/core-prompts/catchup-assessment.md +19 -0
  119. package/templates/core-prompts/check-context.md +24 -0
  120. package/templates/core-prompts/daily-self-audit.md +42 -0
  121. package/templates/core-prompts/daily-synthesis.md +40 -0
  122. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  123. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  124. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  125. package/templates/core-prompts/email-monitor.md +202 -0
  126. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  127. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  128. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  129. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  130. package/templates/core-prompts/evolution-weekly.md +71 -0
  131. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  132. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  133. package/templates/core-prompts/followup-runner.md +74 -0
  134. package/templates/core-prompts/immune-triage.md +31 -0
  135. package/templates/core-prompts/interactive-startup.md +1 -0
  136. package/templates/core-prompts/json-object-only.md +1 -0
  137. package/templates/core-prompts/learning-validator.md +25 -0
  138. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  139. package/templates/core-prompts/morning-agent.md +23 -0
  140. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  141. package/templates/core-prompts/r-catalog.md +1 -0
  142. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  143. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  144. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  145. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  146. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  147. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  148. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  149. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  150. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  151. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  152. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  153. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  154. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  155. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  156. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  157. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  158. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  159. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  160. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  161. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  162. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  164. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  165. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  166. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  167. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  168. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  169. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  170. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  171. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  172. package/templates/core-prompts/sleep.md +25 -0
  173. package/templates/email-template.md +55 -0
  174. package/templates/nexo_helper.py +31 -13
  175. package/templates/plugin-template.py +3 -3
  176. package/templates/skill-template.md +2 -1
@@ -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
 
@@ -4086,7 +4268,6 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4086
4268
  try:
4087
4269
  _emit_progress(progress_fn, "Canonicalizing packaged runtime layout...")
4088
4270
  _maybe_migrate_to_f06_layout()
4089
- _ensure_f06_legacy_shims()
4090
4271
  try:
4091
4272
  _rewrite_f06_launch_agents()
4092
4273
  except Exception:
@@ -4103,6 +4284,22 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4103
4284
  except Exception as exc:
4104
4285
  actions.append(f"legacy-root-db-stubs-warning:{exc.__class__.__name__}")
4105
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
+
4106
4303
  code_root = _runtime_code_dir(dest)
4107
4304
  env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(code_root)}
4108
4305
 
@@ -4344,6 +4541,13 @@ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[s
4344
4541
  actions: list[str] = []
4345
4542
  parts: list[str] = []
4346
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
+
4347
4551
  retired = reconcile_result.get("retired_superseded_scripts", {})
4348
4552
  if isinstance(retired, dict):
4349
4553
  archived = len(retired.get("archived", []) or [])
@@ -4440,7 +4644,7 @@ def manual_sync_update(
4440
4644
  if copy_stats.get("script_conflicts"):
4441
4645
  sync_result["actions"].append(f"preserved-personal-scripts:{len(copy_stats['script_conflicts'])}")
4442
4646
  sync_result["warnings"].append(
4443
- 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"
4444
4648
  )
4445
4649
  # Update runtime dependencies (best-effort)
4446
4650
  try: