nexo-brain 7.38.6 → 7.38.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.38.6",
4
- "description": "Local NEXO runtime core for NEXO Desktop: memory, Deep Sleep, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
3
+ "version": "7.38.7",
4
+ "description": "Local NEXO runtime core for NEXO Desktop: memory, Deep Sleep, Evolution support-ticket mode, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Cognitive S.L.",
7
7
  "email": "info@nexo-desktop.com",
package/README.md CHANGED
@@ -9,13 +9,15 @@ compatibility only; they are not a separate product line.
9
9
 
10
10
  ## Current contract
11
11
 
12
- - Version `7.38.6` is the current packaged-runtime line
12
+ - Version `7.38.7` is the current packaged-runtime line
13
13
  - Public product: NEXO Desktop
14
14
  - Runtime role: local NEXO core bundled with Desktop
15
- - Active systems: local memory, Deep Sleep, Skills, Watchdog, followups,
16
- doctor diagnostics, and MCP tooling
17
- - Removed system: Evolution background self-improvement loop and its legacy
18
- tool surfaces
15
+ - Active systems: local memory, Deep Sleep, Evolution support-ticket mode,
16
+ Skills, Watchdog, followups, doctor diagnostics, and MCP tooling
17
+ - Evolution mode: enabled by default for Desktop-managed installs; it never
18
+ opens GitHub branches, pushes, PRs, transcripts, local databases, or raw
19
+ private evidence, and routes product-improvement requests through sanitized
20
+ support tickets
19
21
  - Distribution: Desktop installers and update manifests are published through
20
22
  the NEXO Desktop release channel
21
23
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.38.6",
3
+ "version": "7.38.7",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO runtime core for NEXO Desktop. Provides local memory, automation, Deep Sleep, skills, watchdog, and MCP tooling for the Desktop product.",
5
+ "description": "NEXO runtime core for NEXO Desktop. Provides local memory, Deep Sleep, Evolution support-ticket mode, skills, watchdog, and MCP tooling for the Desktop product.",
6
6
  "homepage": "https://nexo-desktop.com",
7
7
  "bin": {
8
8
  "nexo-brain": "bin/nexo-brain.js",
@@ -21,12 +21,8 @@ import time
21
21
  from pathlib import Path
22
22
 
23
23
  try:
24
- from product_mode import (
25
- DESKTOP_EVOLUTION_RETIRED_REASON,
26
- DESKTOP_EVOLUTION_SUPPORT_MODE,
27
- desktop_product_requested,
28
- enforce_desktop_product_contract,
29
- )
24
+ from product_mode import desktop_product_requested, enforce_desktop_product_contract
25
+ from product_mode import DESKTOP_EVOLUTION_SUPPORT_MODE
30
26
  except ModuleNotFoundError as exc:
31
27
  if getattr(exc, "name", "") != "product_mode":
32
28
  raise
@@ -35,12 +31,8 @@ except ModuleNotFoundError as exc:
35
31
  core_path = str(_core_runtime)
36
32
  if core_path not in sys.path:
37
33
  sys.path.insert(0, core_path)
38
- from product_mode import (
39
- DESKTOP_EVOLUTION_RETIRED_REASON,
40
- DESKTOP_EVOLUTION_SUPPORT_MODE,
41
- desktop_product_requested,
42
- enforce_desktop_product_contract,
43
- )
34
+ from product_mode import desktop_product_requested, enforce_desktop_product_contract
35
+ from product_mode import DESKTOP_EVOLUTION_SUPPORT_MODE
44
36
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
45
37
 
46
38
  try:
@@ -1441,7 +1433,6 @@ def _cleanup_retired_runtime_files():
1441
1433
  """Remove retired core files that should not survive updates."""
1442
1434
  retired = [
1443
1435
  paths.core_scripts_dir() / "nexo-day-orchestrator.sh",
1444
- paths.core_scripts_dir() / "nexo-evolution-run.py",
1445
1436
  paths.core_scripts_dir() / "heartbeat-enforcement.py",
1446
1437
  paths.core_scripts_dir() / "heartbeat-posttool.sh",
1447
1438
  paths.core_scripts_dir() / "heartbeat-user-msg.sh",
@@ -4383,28 +4374,29 @@ def _auto_update_check_locked() -> dict:
4383
4374
  except Exception as e:
4384
4375
  _log(f"File migration runner error: {e}")
4385
4376
 
4386
- # Legacy cleanup: existing evolution-objective.json files are left as
4387
- # historical state, but the removed Evolution engine must stay disabled.
4377
+ # Legacy cleanup: keep the objective but migrate old Desktop-disabled
4378
+ # states back to the support-ticket-only Evolution mode.
4388
4379
  try:
4389
4380
  evo_obj_path = paths.brain_dir() / "evolution-objective.json"
4390
4381
  if evo_obj_path.exists():
4391
4382
  raw_objective = json.loads(evo_obj_path.read_text())
4392
4383
  if isinstance(raw_objective, dict):
4393
- raw_objective["evolution_enabled"] = False
4384
+ raw_objective["evolution_enabled"] = True
4394
4385
  raw_objective["evolution_mode"] = DESKTOP_EVOLUTION_SUPPORT_MODE
4395
- raw_objective["disabled_by"] = "desktop_product"
4396
- raw_objective["disabled_reason"] = DESKTOP_EVOLUTION_RETIRED_REASON
4397
- raw_objective["support_ticket_mode"] = False
4398
- raw_objective["removed_at"] = raw_objective.get("removed_at") or time.strftime("%Y-%m-%dT%H:%M:%S")
4386
+ raw_objective["desktop_managed"] = True
4387
+ raw_objective["support_ticket_mode"] = True
4388
+ raw_objective.pop("disabled_by", None)
4389
+ raw_objective.pop("disabled_reason", None)
4390
+ raw_objective.pop("removed_at", None)
4399
4391
  evo_obj_path.write_text(json.dumps(raw_objective, indent=2, ensure_ascii=False) + "\n")
4400
- _log("Marked legacy evolution-objective.json as removed")
4392
+ _log("Migrated evolution-objective.json to support-ticket mode")
4401
4393
  except Exception as e:
4402
4394
  _log(f"legacy evolution cleanup error: {e}")
4403
4395
 
4404
4396
  try:
4405
4397
  desktop_contract = enforce_desktop_product_contract(source="auto_update")
4406
4398
  if desktop_contract.get("applied") and desktop_contract.get("changed_objective"):
4407
- _log("Desktop product contract enforced: legacy Evolution removed")
4399
+ _log("Desktop product contract enforced: Evolution support-ticket mode")
4408
4400
  except Exception as e:
4409
4401
  _log(f"desktop product contract error: {e}")
4410
4402
 
@@ -388,30 +388,30 @@ def classify_evolution_policy(
388
388
  break
389
389
  if not evolution_entry:
390
390
  return EvolutionPolicyClassification(
391
- status="retired",
392
- severity="OK",
393
- reason="Evolution cron is retired; no LaunchAgent is required",
391
+ status="missing",
392
+ severity="P1",
393
+ reason="Evolution is enabled by product policy but missing from the cron manifest",
394
394
  )
395
395
  label = str(evolution_entry.get("launchagent_label") or "com.nexo.evolution")
396
396
  if launchagent_labels is None:
397
397
  return EvolutionPolicyClassification(
398
- status="legacy_declared_inventory_unknown",
398
+ status="unknown",
399
399
  severity="P2",
400
- reason="Legacy Evolution cron is declared, but LaunchAgent inventory was not supplied",
400
+ reason="Evolution is declared, but LaunchAgent inventory was not supplied",
401
401
  launchagent_label=label,
402
402
  )
403
403
  labels = {str(item) for item in launchagent_labels}
404
404
  if label in labels:
405
405
  return EvolutionPolicyClassification(
406
- status="legacy_loaded",
407
- severity="P1",
408
- reason="Legacy Evolution cron and LaunchAgent are still loaded; update should retire them",
406
+ status="enabled_and_loaded",
407
+ severity="OK",
408
+ reason="Evolution is declared and loaded in the supplied inventory",
409
409
  launchagent_label=label,
410
410
  )
411
411
  return EvolutionPolicyClassification(
412
- status="legacy_declared_not_loaded",
413
- severity="P2",
414
- reason="Legacy Evolution cron remains in the manifest but is not loaded",
412
+ status="enabled_but_not_loaded",
413
+ severity="P1",
414
+ reason="Evolution is declared but absent from the supplied inventory",
415
415
  launchagent_label=label,
416
416
  )
417
417
 
@@ -17,7 +17,6 @@ _TOGGLEABLE_AUTOMATIONS = frozenset({
17
17
  "morning-agent",
18
18
  })
19
19
  _EXCLUDED_HELPERS = frozenset({
20
- "evolution",
21
20
  "prevent-sleep",
22
21
  "tcc-approve",
23
22
  })
@@ -25,7 +24,9 @@ _NON_EDITABLE_REASONS: dict[str, str] = {
25
24
  "catchup": "Runs only at login/wake catch-up; cadence is fixed by product design.",
26
25
  "dashboard": "Persistent KeepAlive surface; cadence does not apply.",
27
26
  }
28
- _CLI_ONLY_REASONS: dict[str, str] = {}
27
+ _CLI_ONLY_REASONS: dict[str, str] = {
28
+ "evolution": "Weekly support-ticket-only improvement cycle; adjust cadence from the CLI when needed.",
29
+ }
29
30
  _INTERVAL_BOUNDS: dict[str, dict[str, int]] = {
30
31
  "auto-close-sessions": {
31
32
  "minimum_interval_seconds": 5 * 60,
@@ -145,6 +145,21 @@
145
145
  "run_on_boot": true,
146
146
  "run_on_wake": true
147
147
  },
148
+ {
149
+ "id": "evolution",
150
+ "script": "scripts/nexo-evolution-run.py",
151
+ "schedule_strategy": "machine_weekly_spread",
152
+ "schedule": {"hour": 5, "minute": 0, "weekday": 0},
153
+ "description": "Weekly self-improvement cycle — propose and evaluate changes",
154
+ "core": true,
155
+ "optional": "automation",
156
+ "recovery_policy": "catchup",
157
+ "idempotent": true,
158
+ "max_catchup_age": 1209600,
159
+ "stuck_after_seconds": 14400,
160
+ "run_on_boot": true,
161
+ "run_on_wake": true
162
+ },
148
163
  {
149
164
  "id": "followup-hygiene",
150
165
  "script": "scripts/nexo-followup-hygiene.py",
@@ -1555,7 +1555,7 @@ class HeadlessEnforcer:
1555
1555
  if "/.nexo/runtime/" in posix:
1556
1556
  matches.append(path)
1557
1557
  continue
1558
- if "vicshop" in posix or "canarirural" in posix:
1558
+ if any(marker in posix for marker in ("/public_html/", "/htdocs/", "/wwwroot/")):
1559
1559
  matches.append(path)
1560
1560
  continue
1561
1561
  if "/nexo-desktop/" in posix and (
@@ -0,0 +1,408 @@
1
+ """NEXO Evolution Cycle — Self-improvement via Opus API.
2
+
3
+ Runs weekly after DMN. Analyzes patterns, proposes improvements.
4
+ v1: observe-only (all proposals logged as 'proposed' for the user to review).
5
+ v1.1 (future): sandbox execution of auto-approved changes.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import paths
11
+ import shutil
12
+ import subprocess
13
+ import sqlite3
14
+ import time
15
+ from datetime import datetime, date, timedelta
16
+ from pathlib import Path
17
+ from core_prompts import render_core_prompt
18
+
19
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
20
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(NEXO_HOME)))
21
+ NEXO_DB = paths.db_path()
22
+ # Evolution sandbox lives under the runtime root (equivalent to
23
+ # ``paths.runtime_dir() / "sandbox"``). Kept as ``NEXO_HOME / sandbox /
24
+ # workspace`` for backwards compatibility with existing installs that already
25
+ # have a populated sandbox at this path. Do NOT relocate without a migration.
26
+ SANDBOX_DIR = NEXO_HOME / "sandbox" / "workspace"
27
+ SNAPSHOTS_DIR = paths.snapshots_dir()
28
+ RESTORE_LOG = paths.logs_dir() / "snapshot-restores.log"
29
+
30
+ # Evolution config: brain/ (canonical) > cortex/ (legacy) > NEXO_CODE (dev)
31
+ def _resolve_evolution_file(name: str) -> Path:
32
+ for candidate in [paths.brain_dir() / name, NEXO_HOME / "cortex" / name, NEXO_CODE / name]:
33
+ if candidate.exists():
34
+ return candidate
35
+ return paths.brain_dir() / name # default canonical path
36
+
37
+ OBJECTIVE_FILE = _resolve_evolution_file("evolution-objective.json")
38
+ PROMPT_FILE = _resolve_evolution_file("evolution-prompt.md")
39
+
40
+ MAX_SNAPSHOTS = 8
41
+
42
+
43
+ def _normalize_dimensions(raw: dict | None) -> dict:
44
+ normalized = {}
45
+ for key, value in (raw or {}).items():
46
+ canonical_key = "agi" if key == "agi_readiness" else key
47
+ if isinstance(value, dict):
48
+ normalized[canonical_key] = {
49
+ "current": int(value.get("current", 0) or 0),
50
+ "target": int(value.get("target", 0) or 0),
51
+ }
52
+ else:
53
+ normalized[canonical_key] = {
54
+ "current": 0,
55
+ "target": int(value or 0),
56
+ }
57
+ return normalized
58
+
59
+
60
+ def normalize_objective(obj: dict | None) -> dict:
61
+ """Upgrade legacy objective files to the canonical schema."""
62
+ source = dict(obj or {})
63
+
64
+ if "evolution_mode" in source:
65
+ mode = str(source.get("evolution_mode") or "auto").strip().lower()
66
+ if mode in {"public", "public_core", "contributor", "draft_prs"}:
67
+ mode = "support_ticket"
68
+ else:
69
+ legacy_mode = str(source.get("review_mode") or "").strip().lower()
70
+ if legacy_mode in {"manual", "review"}:
71
+ mode = "review"
72
+ elif legacy_mode in {"managed", "hybrid", "owner", "core"}:
73
+ mode = "managed"
74
+ elif legacy_mode in {"public", "public_core", "contributor", "draft_prs"}:
75
+ mode = "support_ticket"
76
+ else:
77
+ mode = "auto"
78
+
79
+ if mode not in {"auto", "review", "managed", "public_core", "support_ticket"}:
80
+ mode = "auto"
81
+
82
+ dimensions = source.get("dimensions")
83
+ if not isinstance(dimensions, dict) or not dimensions:
84
+ dimensions = _normalize_dimensions(source.get("dimension_targets"))
85
+ else:
86
+ dimensions = _normalize_dimensions(dimensions)
87
+
88
+ defaults = {
89
+ "episodic_memory": {"current": 0, "target": 90},
90
+ "autonomy": {"current": 0, "target": 80},
91
+ "proactivity": {"current": 0, "target": 70},
92
+ "self_improvement": {"current": 0, "target": 60},
93
+ "agi": {"current": 0, "target": 20},
94
+ }
95
+ merged_dimensions = dict(defaults)
96
+ merged_dimensions.update(dimensions)
97
+
98
+ normalized = dict(source)
99
+ normalized["evolution_mode"] = mode
100
+ normalized["dimensions"] = merged_dimensions
101
+ normalized["total_evolutions"] = int(source.get("total_evolutions", source.get("cycles_completed", 0)) or 0)
102
+ normalized["last_evolution"] = source.get("last_evolution", source.get("last_cycle"))
103
+ normalized["total_proposals_made"] = int(source.get("total_proposals_made", 0) or 0)
104
+ normalized["total_auto_applied"] = int(source.get("total_auto_applied", 0) or 0)
105
+ normalized["consecutive_failures"] = int(source.get("consecutive_failures", 0) or 0)
106
+ normalized["history"] = source.get("history", []) if isinstance(source.get("history"), list) else []
107
+ normalized["evolution_enabled"] = bool(source.get("evolution_enabled", True))
108
+ normalized.pop("review_mode", None)
109
+ normalized.pop("dimension_targets", None)
110
+ normalized.pop("cycles_completed", None)
111
+ normalized.pop("last_cycle", None)
112
+ return normalized
113
+
114
+
115
+ def load_objective() -> dict:
116
+ if OBJECTIVE_FILE.exists():
117
+ return normalize_objective(json.loads(OBJECTIVE_FILE.read_text()))
118
+ return normalize_objective({})
119
+
120
+
121
+ def save_objective(obj: dict):
122
+ OBJECTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
123
+ OBJECTIVE_FILE.write_text(json.dumps(normalize_objective(obj), indent=2, ensure_ascii=False))
124
+
125
+
126
+ def get_week_data(db_path: str) -> dict:
127
+ """Gather last 7 days of learnings, decisions, changes, diaries."""
128
+ conn = sqlite3.connect(db_path, timeout=10)
129
+ try:
130
+ conn.row_factory = sqlite3.Row
131
+ cutoff_epoch = time.time() - 7 * 86400
132
+ cutoff_date = (date.today() - timedelta(days=7)).isoformat()
133
+
134
+ data = {}
135
+
136
+ rows = conn.execute(
137
+ "SELECT category, title, content FROM learnings WHERE created_at > ? ORDER BY created_at DESC LIMIT 50",
138
+ (cutoff_epoch,)
139
+ ).fetchall()
140
+ data["learnings"] = [dict(r) for r in rows]
141
+
142
+ rows = conn.execute(
143
+ "SELECT domain, decision, alternatives, based_on, confidence, outcome FROM decisions "
144
+ "WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
145
+ (cutoff_date,)
146
+ ).fetchall()
147
+ data["decisions"] = [dict(r) for r in rows]
148
+
149
+ rows = conn.execute(
150
+ "SELECT files, what_changed, why, affects, risks FROM change_log "
151
+ "WHERE created_at > ? ORDER BY created_at DESC LIMIT 30",
152
+ (cutoff_date,)
153
+ ).fetchall()
154
+ data["changes"] = [dict(r) for r in rows]
155
+
156
+ rows = conn.execute(
157
+ "SELECT summary, decisions as diary_decisions, pending, mental_state, domain, user_signals "
158
+ "FROM session_diary WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
159
+ (cutoff_date,)
160
+ ).fetchall()
161
+ data["diaries"] = [dict(r) for r in rows]
162
+
163
+ rows = conn.execute(
164
+ "SELECT * FROM evolution_log ORDER BY id DESC LIMIT 20"
165
+ ).fetchall()
166
+ data["evolution_history"] = [dict(r) for r in rows]
167
+
168
+ rows = conn.execute(
169
+ "SELECT dimension, score, delta, measured_at FROM evolution_metrics "
170
+ "WHERE id IN (SELECT MAX(id) FROM evolution_metrics GROUP BY dimension)"
171
+ ).fetchall()
172
+ data["current_metrics"] = {r["dimension"]: dict(r) for r in rows}
173
+
174
+ return data
175
+ finally:
176
+ conn.close()
177
+
178
+
179
+ def create_snapshot(files_to_backup: list) -> str:
180
+ """Create a snapshot of specific files before modification."""
181
+ ts = datetime.now().strftime("%Y-%m-%dT%H:%M")
182
+ snap_dir = SNAPSHOTS_DIR / ts
183
+ files_dir = snap_dir / "files"
184
+
185
+ manifest = {
186
+ "created_at": datetime.now().isoformat(),
187
+ "files": [],
188
+ "reason": "evolution_cycle"
189
+ }
190
+
191
+ for filepath in files_to_backup:
192
+ fp = Path(filepath).expanduser()
193
+ if fp.exists():
194
+ rel = str(fp).replace(str(Path.home()) + "/", "")
195
+ dest = files_dir / rel
196
+ dest.parent.mkdir(parents=True, exist_ok=True)
197
+ if os.path.abspath(str(fp)) == os.path.abspath(str(dest)):
198
+ continue # Skip: source and destination are the same file
199
+ shutil.copy2(fp, dest)
200
+ manifest["files"].append(rel)
201
+
202
+ snap_dir.mkdir(parents=True, exist_ok=True)
203
+ (snap_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
204
+
205
+ latest = SNAPSHOTS_DIR / "latest"
206
+ if latest.is_symlink():
207
+ latest.unlink()
208
+ latest.symlink_to(snap_dir)
209
+
210
+ _cleanup_snapshots()
211
+ return str(snap_dir)
212
+
213
+
214
+ def _cleanup_snapshots():
215
+ """Remove old snapshots, keeping MAX_SNAPSHOTS most recent + golden."""
216
+ if not SNAPSHOTS_DIR.exists():
217
+ return
218
+ snaps = sorted(
219
+ [d for d in SNAPSHOTS_DIR.iterdir()
220
+ if d.is_dir() and d.name not in ("latest", "golden")],
221
+ key=lambda d: d.stat().st_mtime,
222
+ reverse=True
223
+ )
224
+ for old in snaps[MAX_SNAPSHOTS:]:
225
+ shutil.rmtree(old)
226
+
227
+
228
+ def dry_run_restore_test() -> bool:
229
+ """Test that snapshot+restore works before making real changes."""
230
+ test_file = SANDBOX_DIR / "restore-test.txt"
231
+ test_file.parent.mkdir(parents=True, exist_ok=True)
232
+ test_file.write_text("original_content")
233
+
234
+ snap_dir = create_snapshot([str(test_file)])
235
+
236
+ test_file.write_text("modified_content")
237
+
238
+ # Find restore script: repo scripts/ first, then installed core/scripts/.
239
+ _nexo_code = Path(os.environ.get("NEXO_CODE", ""))
240
+ restore_script = None
241
+ for candidate in [_nexo_code / "scripts" / "nexo-snapshot-restore.sh",
242
+ paths.core_scripts_dir() / "nexo-snapshot-restore.sh"]:
243
+ if candidate.exists():
244
+ restore_script = candidate
245
+ break
246
+ if not restore_script:
247
+ test_file.unlink(missing_ok=True)
248
+ return False # No restore script available
249
+
250
+ try:
251
+ subprocess.run(
252
+ [str(restore_script), snap_dir],
253
+ capture_output=True, timeout=10, check=True
254
+ )
255
+ content = test_file.read_text()
256
+ test_file.unlink(missing_ok=True)
257
+ # Clean up test snapshot
258
+ snap_path = Path(snap_dir)
259
+ if snap_path.exists():
260
+ shutil.rmtree(snap_path)
261
+ return content == "original_content"
262
+ except Exception:
263
+ test_file.unlink(missing_ok=True)
264
+ return False
265
+
266
+
267
+ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
268
+ """Build a SHORT prompt — CLI investigates on its own using tools."""
269
+
270
+ objective_dims = normalize_objective(objective).get("dimensions", {})
271
+ current_scores = {
272
+ dim: int(m["score"])
273
+ for dim, m in week_data.get("current_metrics", {}).items()
274
+ if isinstance(m, dict) and isinstance(m.get("score"), (int, float))
275
+ }
276
+ if not current_scores:
277
+ current_scores = {
278
+ dim: int((payload or {}).get("current", 0) or 0)
279
+ for dim, payload in objective_dims.items()
280
+ if isinstance(payload, dict)
281
+ }
282
+
283
+ # Summary stats only — CLI will dig deeper with tools
284
+ stats = {
285
+ "learnings_this_week": len(week_data.get("learnings", [])),
286
+ "decisions_this_week": len(week_data.get("decisions", [])),
287
+ "changes_this_week": len(week_data.get("changes", [])),
288
+ "diaries_this_week": len(week_data.get("diaries", [])),
289
+ "evolution_history": len(week_data.get("evolution_history", [])),
290
+ "current_scores": current_scores,
291
+ }
292
+
293
+ mode = normalize_objective(objective).get("evolution_mode", "auto")
294
+ total = objective.get("total_evolutions", 0)
295
+ max_auto = max_auto_changes(total)
296
+ if mode == "review":
297
+ mode_desc = "review-only, nothing executes automatically"
298
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/, ~/.nexo/personal/brain/"
299
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
300
+ elif mode == "managed":
301
+ mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
302
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/, ~/.nexo/personal/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
303
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md, personality.md, user-profile.md"
304
+ elif mode in {"public_core", "support_ticket"}:
305
+ mode_desc = "support-ticket mode, no automatic code writes and no GitHub publishing"
306
+ safe_zones = "read-only analysis plus anonymized support-ticket creation"
307
+ immutable_files = "all local runtime data, personal files, local DBs/logs, prompts, secrets, CLAUDE.md, AGENTS.md, user-profile.md"
308
+ else:
309
+ mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
310
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/"
311
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
312
+
313
+ return render_core_prompt(
314
+ "evolution-weekly",
315
+ learnings_this_week=stats["learnings_this_week"],
316
+ decisions_this_week=stats["decisions_this_week"],
317
+ changes_this_week=stats["changes_this_week"],
318
+ diaries_this_week=stats["diaries_this_week"],
319
+ evolution_history=stats["evolution_history"],
320
+ current_scores_json=json.dumps(stats["current_scores"]),
321
+ mode=mode,
322
+ mode_desc=mode_desc,
323
+ cycle_number=total + 1,
324
+ nexo_db=NEXO_DB,
325
+ week_cutoff_ts=time.time() - 7 * 86400,
326
+ safe_zones=safe_zones,
327
+ immutable_files=immutable_files,
328
+ )
329
+
330
+
331
+ def build_public_contribution_prompt(
332
+ *,
333
+ repo_root: str,
334
+ cycle_number: int,
335
+ queued_candidate: dict | None = None,
336
+ ) -> str:
337
+ """Prompt for the public-core contributor mode.
338
+
339
+ This prompt must never rely on private runtime state. It should inspect only
340
+ the isolated public repo checkout, make one coherent improvement, and end
341
+ by returning machine-readable summary JSON.
342
+ """
343
+
344
+ queued_section = ""
345
+ if queued_candidate:
346
+ queued_files = "\n".join(
347
+ f"- {path}" for path in (queued_candidate.get("files_changed") or [])[:20]
348
+ ) or "- (no files recorded)"
349
+ queued_source = str((queued_candidate.get("metadata") or {}).get("source") or "managed-runtime")
350
+ queued_section = f"""
351
+
352
+ PRIORITY PUBLIC-PORT QUEUE ITEM:
353
+ - Source: {queued_source}
354
+ - Title: {str(queued_candidate.get("title") or "").strip()}
355
+ - Why it matters: {str(queued_candidate.get("reasoning") or "").strip()}
356
+ - Files originally touched:
357
+ {queued_files}
358
+
359
+ This item was already fixed or detected outside the public contribution runner.
360
+ Before inventing another improvement, verify whether the public repository still
361
+ needs the same change and port it if necessary. If the repo is already correct,
362
+ make the smallest validating change that captures the same gap.
363
+ """
364
+
365
+ return render_core_prompt(
366
+ "evolution-public-contribution",
367
+ repo_root=repo_root,
368
+ cycle_number=cycle_number,
369
+ queued_section=queued_section,
370
+ )
371
+
372
+
373
+ def build_public_pr_review_prompt(
374
+ *,
375
+ pr_number: int,
376
+ title: str,
377
+ author: str,
378
+ url: str,
379
+ body: str,
380
+ files: list[str],
381
+ diff_text: str,
382
+ ) -> str:
383
+ """Legacy prompt template kept for old artifacts; active Evolution no longer reviews PRs."""
384
+
385
+ rendered_files = "\n".join(f"- {path}" for path in files[:40]) if files else "- (no file list provided)"
386
+ trimmed_diff = (diff_text or "").strip()
387
+ if len(trimmed_diff) > 80000:
388
+ trimmed_diff = trimmed_diff[:80000] + "\n\n[diff truncated by NEXO]"
389
+
390
+ return render_core_prompt(
391
+ "evolution-public-pr-review",
392
+ pr_number=pr_number,
393
+ author=author,
394
+ url=url,
395
+ title=title,
396
+ body=body or "(empty)",
397
+ rendered_files=rendered_files,
398
+ trimmed_diff=trimmed_diff or "(empty diff)",
399
+ )
400
+
401
+
402
+ def max_auto_changes(total_evolutions: int) -> int:
403
+ """Progressive trust: 1 for first 4 cycles, 2 for next 4, then 3."""
404
+ if total_evolutions < 4:
405
+ return 1
406
+ elif total_evolutions < 8:
407
+ return 2
408
+ return 3