nexo-brain 7.13.8 → 7.14.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.13.8",
3
+ "version": "7.14.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.13.8` is the current packaged-runtime line. Patch release over v7.13.7 — Brain now rejects Python <3.10 during Desktop-managed fresh installs, honors the Python interpreter prepared by Desktop, and fails clearly before dependency resolution if an unsupported Apple Python 3.9 reaches the installer.
21
+ Version `7.14.0` is the current packaged-runtime line. Minor release over v7.13.9 — Brain closes the install/reliability loop with update-path venv recovery, platform-gated wheels, WSL Desktop-managed flag preservation, startup memory authority warnings, legacy MEMORY write blocking, post-action real-world verification, and stale followup triage.
22
+
23
+ Previously in `7.13.9`: patch release — Brain moves aside an existing managed `.venv` when it was created with unsupported Python <3.10, then recreates it with the supported interpreter prepared by Desktop.
24
+
25
+ Previously in `7.13.8`: patch release — Brain rejects Python <3.10 during Desktop-managed fresh installs, honors the Python interpreter prepared by Desktop, and fails clearly before dependency resolution if an unsupported Apple Python 3.9 reaches the installer.
22
26
 
23
27
  Previously in `7.13.7`: patch release — Brain adds an authenticated official protocol-card client (`nexo_card_catalog`, `nexo_card_get`, `nexo_card_match`) so agents can ask the NEXO Desktop backend for the right task protocol at runtime. The protocol corpus stays private on the server; this open-source package ships only the client, tool map, and agent guidance.
24
28
 
package/bin/nexo-brain.js CHANGED
@@ -291,6 +291,23 @@ function findBundledWheel(wheelsDir, prefix) {
291
291
  }
292
292
  }
293
293
 
294
+ function bundledWheelsSupportCurrentPlatform(wheelsDir) {
295
+ if (!fs.existsSync(wheelsDir)) return false;
296
+ if (process.platform === "linux") return true;
297
+ if (process.platform !== "darwin") return false;
298
+ try {
299
+ const names = fs.readdirSync(wheelsDir).map((name) => String(name || "").toLowerCase());
300
+ const archTag = process.arch === "arm64" ? "arm64" : "x86_64";
301
+ return names.some((name) => (
302
+ name.endsWith(".whl")
303
+ && name.includes("macosx")
304
+ && (name.includes("universal2") || name.includes(archTag))
305
+ ));
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
294
311
  function pythonHasPip(pythonBin) {
295
312
  try {
296
313
  const result = spawnSync(pythonBin, ["-m", "pip", "--version"], {
@@ -303,6 +320,46 @@ function pythonHasPip(pythonBin) {
303
320
  }
304
321
  }
305
322
 
323
+ function managedVenvPythonPath(nexoHome = NEXO_HOME) {
324
+ const venvPath = path.join(nexoHome, ".venv");
325
+ return process.platform === "win32"
326
+ ? path.join(venvPath, "Scripts", "python.exe")
327
+ : path.join(venvPath, "bin", "python3");
328
+ }
329
+
330
+ function safeTimestampForPath() {
331
+ return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
332
+ }
333
+
334
+ function uniqueBackupPath(targetPath, suffix) {
335
+ const dir = path.dirname(targetPath);
336
+ const base = path.basename(targetPath);
337
+ const stamp = safeTimestampForPath();
338
+ let candidate = path.join(dir, `${base}.${suffix}-${stamp}`);
339
+ if (!fs.existsSync(candidate)) return candidate;
340
+ for (let i = 2; i < 100; i += 1) {
341
+ candidate = path.join(dir, `${base}.${suffix}-${stamp}-${i}`);
342
+ if (!fs.existsSync(candidate)) return candidate;
343
+ }
344
+ return path.join(dir, `${base}.${suffix}-${stamp}-${process.pid}`);
345
+ }
346
+
347
+ function ensureManagedVenvCompatible(venvPath, venvPython) {
348
+ if (!fs.existsSync(venvPython)) return;
349
+ const version = pythonVersion(venvPython);
350
+ if (version && pythonVersionMeetsMinimum(version)) return;
351
+
352
+ const reason = version ? `Python ${version}` : "an unreadable Python executable";
353
+ const backupPath = uniqueBackupPath(venvPath, "unsupported-python");
354
+ log(` Existing Python virtual environment uses ${reason}; moving it aside to recreate.`);
355
+ try {
356
+ fs.renameSync(venvPath, backupPath);
357
+ } catch (err) {
358
+ throw new Error(`Existing NEXO Python virtual environment is incompatible and could not be moved aside: ${err.message || err}`);
359
+ }
360
+ log(` Previous Python virtual environment moved to ${backupPath}`);
361
+ }
362
+
306
363
  function seedPipFromBundledWheels(venvPython, bundledWheelsDir) {
307
364
  if (!fs.existsSync(venvPython) || !fs.existsSync(bundledWheelsDir)) return false;
308
365
  if (pythonHasPip(venvPython)) return true;
@@ -576,15 +633,13 @@ function resolveSystemPython() {
576
633
  }
577
634
 
578
635
  function ensureWarmupPython(nexoHome = NEXO_HOME) {
579
- const existing = findVenvPython(nexoHome);
580
- if (existing) return existing;
581
-
582
- const basePython = resolveSystemPython();
583
636
  const venvPath = path.join(nexoHome, ".venv");
584
- const venvPython = process.platform === "win32"
585
- ? path.join(venvPath, "Scripts", "python.exe")
586
- : path.join(venvPath, "bin", "python3");
637
+ const venvPython = managedVenvPythonPath(nexoHome);
587
638
  fs.mkdirSync(nexoHome, { recursive: true });
639
+ ensureManagedVenvCompatible(venvPath, venvPython);
640
+ if (fs.existsSync(venvPython)) return venvPython;
641
+
642
+ const basePython = resolveInstallerPython() || resolveSystemPython();
588
643
  if (!fs.existsSync(venvPython)) {
589
644
  log(" Creating Python virtual environment for model warmup...");
590
645
  const result = spawnSync(basePython, ["-m", "venv", venvPath], { stdio: "inherit", timeout: 120000 });
@@ -2398,7 +2453,9 @@ async function maybeConfigurePublicContribution(schedule, useDefaults) {
2398
2453
  * Resolve the venv python path for an existing NEXO_HOME installation.
2399
2454
  */
2400
2455
  function findVenvPython(nexoHome) {
2401
- const venvPy = path.join(nexoHome, ".venv", "bin", "python3");
2456
+ const venvPath = path.join(nexoHome, ".venv");
2457
+ const venvPy = managedVenvPythonPath(nexoHome);
2458
+ ensureManagedVenvCompatible(venvPath, venvPy);
2402
2459
  if (fs.existsSync(venvPy)) return venvPy;
2403
2460
  return null;
2404
2461
  }
@@ -3702,11 +3759,11 @@ async function runSetup() {
3702
3759
  log("Installing cognitive engine dependencies...");
3703
3760
  fs.mkdirSync(NEXO_HOME, { recursive: true });
3704
3761
  const venvPath = path.join(NEXO_HOME, ".venv");
3705
- const venvPython = platform === "win32"
3706
- ? path.join(venvPath, "Scripts", "python.exe")
3707
- : path.join(venvPath, "bin", "python3");
3762
+ const venvPython = managedVenvPythonPath(NEXO_HOME);
3708
3763
  const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
3709
3764
 
3765
+ ensureManagedVenvCompatible(venvPath, venvPython);
3766
+
3710
3767
  // Create venv if it doesn't exist
3711
3768
  if (!fs.existsSync(venvPython)) {
3712
3769
  log(" Creating Python virtual environment...");
@@ -3724,6 +3781,13 @@ async function runSetup() {
3724
3781
  }
3725
3782
  }
3726
3783
  }
3784
+ if (fs.existsSync(venvPython)) {
3785
+ const venvVersion = pythonVersion(venvPython);
3786
+ if (!venvVersion || !pythonVersionMeetsMinimum(venvVersion)) {
3787
+ log(`Python virtual environment is unsupported after creation (${venvVersion || "unknown version"}).`);
3788
+ process.exit(1);
3789
+ }
3790
+ }
3727
3791
  if (fs.existsSync(venvPython) && !pythonHasPip(venvPython)) {
3728
3792
  seedPipFromBundledWheels(venvPython, bundledWheelsDir);
3729
3793
  }
@@ -3734,12 +3798,11 @@ async function runSetup() {
3734
3798
  // Detect bundled wheels in resources/python-wheels (offline-first). If
3735
3799
  // present, pip uses --no-index --find-links to install without internet.
3736
3800
  // Falls back to PyPI if bundle not found.
3737
- // v0.32.5 el bundle empaca wheels manylinux (cp312 x86_64) porque
3738
- // en Win Brain corre dentro de WSL Ubuntu noble. En Mac, Brain corre
3739
- // nativo macOS y NO acepta esos wheels (ABI distinto). Si gateamos
3740
- // useBundle a !linux, pip cae al PyPI online — bien. macOS y Win
3741
- // (host nativo) deben tener red la primera vez.
3742
- const useBundle = process.platform === "linux" && fs.existsSync(bundledWheelsDir);
3801
+ // Desktop bundles Linux/WSL wheels and, from 0.32.44, macOS arm64/x64
3802
+ // wheels. Only use --no-index when the bundle clearly contains wheels
3803
+ // compatible with the current runtime; otherwise fall back to PyPI
3804
+ // instead of failing on ABI-mismatched wheels.
3805
+ const useBundle = bundledWheelsSupportCurrentPlatform(bundledWheelsDir);
3743
3806
  const pipArgs = useBundle
3744
3807
  ? ["-m", "pip", "install", "--no-index", "--find-links", bundledWheelsDir, "--progress-bar", "off", "-r", requirementsFile]
3745
3808
  : ["-m", "pip", "install", "-v", "--progress-bar", "off", "--default-timeout=60", "-r", requirementsFile];
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.13.8",
3
+ "version": "7.14.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -1732,6 +1732,63 @@ class HeadlessEnforcer:
1732
1732
  self._enqueue(prompt, "R23m_message_duplicate", rule_id="R23m_message_duplicate")
1733
1733
  _logger.info("[R23m %s] enqueued", mode.upper())
1734
1734
 
1735
+ def _check_post_external_action_verification(self, tool_name: str, tool_input):
1736
+ """Require an explicit re-open/re-read step after outbound actions."""
1737
+ external_tools = {
1738
+ "nexo_send",
1739
+ "nexo_email_send",
1740
+ "gmail_send",
1741
+ "nexo_calendar_create",
1742
+ "nexo_calendar_update",
1743
+ "google_calendar_create",
1744
+ "google_calendar_update",
1745
+ "calendar_create",
1746
+ "calendar_update",
1747
+ }
1748
+ if tool_name not in external_tools:
1749
+ return
1750
+ target = ""
1751
+ if isinstance(tool_input, dict):
1752
+ target = str(
1753
+ tool_input.get("to")
1754
+ or tool_input.get("recipient")
1755
+ or tool_input.get("thread")
1756
+ or tool_input.get("title")
1757
+ or tool_input.get("summary")
1758
+ or ""
1759
+ ).strip()
1760
+ prompt = (
1761
+ f"You just performed an external action with `{tool_name}`"
1762
+ f"{(' for ' + target[:120]) if target else ''}. "
1763
+ "Before you report it as sent or finished, reopen the real sent message/calendar item "
1764
+ "and verify the external facts: recipients, CC/BCC, subject, body/signature, "
1765
+ "date/time/timezone, links, invitees, attachments, and any identity/location claims. "
1766
+ "If anything is wrong, fix it first; otherwise include that verification in the closure evidence."
1767
+ )
1768
+ self._enqueue(
1769
+ prompt,
1770
+ f"post-action-verify:{tool_name}:{self.tool_call_count}",
1771
+ rule_id="R23n_post_action_verification",
1772
+ )
1773
+ if self._session_id:
1774
+ try:
1775
+ from db import create_protocol_debt, list_protocol_tasks # type: ignore
1776
+
1777
+ tasks = list_protocol_tasks(status="open", session_id=self._session_id, limit=1)
1778
+ task_id = str((tasks[0] if tasks else {}).get("task_id") or "")
1779
+ create_protocol_debt(
1780
+ self._session_id,
1781
+ "post_external_action_verification_required",
1782
+ severity="warn",
1783
+ task_id=task_id,
1784
+ evidence=(
1785
+ f"{tool_name} was called for an external action. The agent must reopen/re-read "
1786
+ "the real sent/event artifact before claiming completion."
1787
+ ),
1788
+ )
1789
+ except Exception:
1790
+ pass
1791
+
1735
1792
  def _check_r23h(self, tool_name: str, tool_input):
1736
1793
  """R23h — script shebang vs interpreter mismatch (Fase D2 shadow)."""
1737
1794
  if _r23h_should is None:
@@ -2170,6 +2227,7 @@ class HeadlessEnforcer:
2170
2227
  self._check_r23i(name, tool_input)
2171
2228
  self._check_r23k(name, tool_input)
2172
2229
  self._check_r23m(name, tool_input)
2230
+ self._check_post_external_action_verification(name, tool_input)
2173
2231
 
2174
2232
  # D2 shadow rules — low-signal, rolling out carefully.
2175
2233
  self._check_r23h(name, tool_input)
@@ -524,6 +524,59 @@ def _is_live_repo_path(path: str) -> bool:
524
524
  return False
525
525
 
526
526
 
527
+ def _legacy_memory_write_allowed() -> bool:
528
+ return os.environ.get("NEXO_ALLOW_LEGACY_MEMORY_WRITE", "").strip().lower() in {"1", "true", "yes", "on"}
529
+
530
+
531
+ def _is_legacy_client_memory_path(path_value: str) -> bool:
532
+ if not str(path_value or "").strip():
533
+ return False
534
+ try:
535
+ candidate = _resolve_runtime_path(path_value)
536
+ home = Path.home().resolve(strict=False)
537
+ relative = candidate.relative_to(home)
538
+ except Exception:
539
+ return False
540
+ parts = relative.parts
541
+ if len(parts) == 2 and parts[0] in {".claude", ".codex"} and parts[1] == "MEMORY.md":
542
+ return True
543
+ if len(parts) >= 2 and parts[0] in {".claude", ".codex"} and parts[1] == "memories":
544
+ return True
545
+ return False
546
+
547
+
548
+ def _collect_legacy_memory_write_blocks(conn, *, sid: str, task: dict | None, tool_name: str, files: list[str]) -> list[dict]:
549
+ if _legacy_memory_write_allowed():
550
+ return []
551
+ blocks: list[dict] = []
552
+ task_id = str((task or {}).get("task_id") or "").strip()
553
+ for filepath in files:
554
+ if not _is_legacy_client_memory_path(filepath):
555
+ continue
556
+ debt = _ensure_protocol_debt(
557
+ conn,
558
+ session_id=sid,
559
+ task_id=task_id,
560
+ debt_type="legacy_client_memory_write_blocked",
561
+ severity="error",
562
+ evidence=(
563
+ f"{tool_name} attempted to write {filepath}. Legacy Claude/Codex "
564
+ "MEMORY files are not durable NEXO Brain state and must not be "
565
+ "updated by agents. Use NEXO Brain memory/profile/calibration APIs instead."
566
+ ),
567
+ file_token=filepath,
568
+ )
569
+ blocks.append({
570
+ "file": filepath,
571
+ "task_id": task_id,
572
+ "debt_id": debt.get("id"),
573
+ "debt_type": "legacy_client_memory_write_blocked",
574
+ "reason_code": "legacy_client_memory_protected",
575
+ "severity": "error",
576
+ })
577
+ return blocks
578
+
579
+
527
580
  def _extract_touched_files(tool_input) -> list[str]:
528
581
  files: list[str] = []
529
582
  if not isinstance(tool_input, dict):
@@ -1481,6 +1534,24 @@ def process_pre_tool_event(payload: dict) -> dict:
1481
1534
  sid = _resolve_nexo_sid(conn, claude_sid)
1482
1535
  open_task = _find_any_open_task(conn, sid) if sid else None
1483
1536
  warnings: list[dict] = []
1537
+ legacy_memory_blocks = _collect_legacy_memory_write_blocks(
1538
+ conn,
1539
+ sid=sid,
1540
+ task=open_task,
1541
+ tool_name=tool_name,
1542
+ files=files,
1543
+ )
1544
+ if legacy_memory_blocks:
1545
+ return {
1546
+ "ok": True,
1547
+ "session_id": sid,
1548
+ "tool_name": tool_name,
1549
+ "operation": op,
1550
+ "strictness": strictness,
1551
+ "blocks": legacy_memory_blocks,
1552
+ "warnings": warnings,
1553
+ "status": "blocked",
1554
+ }
1484
1555
  if tool_name == "Bash":
1485
1556
  launchagent_operation_warnings = _collect_launchagent_operation_warnings(
1486
1557
  conn,
@@ -2113,6 +2184,11 @@ def format_pretool_block_message(result: dict) -> str:
2113
2184
  f"- {file_note}: `~/.nexo/core` is a protected install surface. "
2114
2185
  "Route the change through the source repo + release/update flow instead of editing the live installed core."
2115
2186
  )
2187
+ elif item.get("reason_code") == "legacy_client_memory_protected":
2188
+ lines.append(
2189
+ f"- {file_note}: legacy Claude/Codex MEMORY files are read-only in NEXO. "
2190
+ "Use NEXO Brain profile/calibration/memory tools instead."
2191
+ )
2116
2192
  elif item.get("reason_code") == "guard_unacknowledged":
2117
2193
  lines.append(
2118
2194
  f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
@@ -0,0 +1,243 @@
1
+ """Startup audit for client memory layers.
2
+
3
+ NEXO Brain is the durable memory authority. Client-local MEMORY files can
4
+ exist from older Claude/Codex installs, but they must not silently override
5
+ calibration/profile data.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import paths
17
+
18
+ LEGACY_MEMORY_PATHS = (
19
+ (".claude", "MEMORY.md"),
20
+ (".codex", "MEMORY.md"),
21
+ )
22
+ LEGACY_MEMORY_DIRS = (
23
+ (".claude", "memories"),
24
+ (".codex", "memories"),
25
+ )
26
+ BOOTSTRAP_PATHS = (
27
+ (".claude", "CLAUDE.md"),
28
+ (".codex", "AGENTS.md"),
29
+ )
30
+
31
+ AUTHORITY_ORDER = [
32
+ "brain/calibration.json",
33
+ "brain/profile.json",
34
+ "NEXO Brain DB: followups, learnings, decisions, diary, outcomes",
35
+ "managed client bootstrap CORE blocks",
36
+ "legacy client MEMORY.md files (read-only, lowest authority)",
37
+ ]
38
+
39
+ LOCATION_HINT_RE = re.compile(
40
+ r"\b("
41
+ r"lives?|resides?|resident|residence|location|city|from|based|"
42
+ r"vive|reside|residencia|ubicaci[oó]n|ciudad|de\s+"
43
+ r")\b",
44
+ re.IGNORECASE,
45
+ )
46
+ TOKEN_RE = re.compile(r"[A-Za-z0-9_ÁÉÍÓÚÜÑáéíóúüñ]+")
47
+
48
+
49
+ def _read_json(path: Path) -> dict[str, Any]:
50
+ try:
51
+ if path.exists():
52
+ payload = json.loads(path.read_text(encoding="utf-8"))
53
+ if isinstance(payload, dict):
54
+ return payload
55
+ except Exception:
56
+ return {}
57
+ return {}
58
+
59
+
60
+ def _brain_dir_candidates(nexo_home: Path) -> list[Path]:
61
+ candidates = [nexo_home / "brain", nexo_home / "personal" / "brain"]
62
+ try:
63
+ current = paths.brain_dir()
64
+ if current not in candidates:
65
+ candidates.append(current)
66
+ except Exception:
67
+ pass
68
+ unique: list[Path] = []
69
+ seen = set()
70
+ for candidate in candidates:
71
+ key = str(candidate)
72
+ if key not in seen:
73
+ seen.add(key)
74
+ unique.append(candidate)
75
+ return unique
76
+
77
+
78
+ def _load_canonical_sources(nexo_home: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
79
+ for brain_dir in _brain_dir_candidates(nexo_home):
80
+ calibration = _read_json(brain_dir / "calibration.json")
81
+ profile = _read_json(brain_dir / "profile.json")
82
+ if calibration or profile:
83
+ return calibration, profile, {
84
+ "calibration": str(brain_dir / "calibration.json"),
85
+ "profile": str(brain_dir / "profile.json"),
86
+ }
87
+ fallback = _brain_dir_candidates(nexo_home)[0]
88
+ return {}, {}, {
89
+ "calibration": str(fallback / "calibration.json"),
90
+ "profile": str(fallback / "profile.json"),
91
+ }
92
+
93
+
94
+ def _iter_strings(value: Any) -> list[str]:
95
+ if isinstance(value, str):
96
+ return [value.strip()] if value.strip() else []
97
+ if isinstance(value, dict):
98
+ out: list[str] = []
99
+ for item in value.values():
100
+ out.extend(_iter_strings(item))
101
+ return out
102
+ if isinstance(value, list):
103
+ out: list[str] = []
104
+ for item in value:
105
+ out.extend(_iter_strings(item))
106
+ return out
107
+ return []
108
+
109
+
110
+ def _tokenize(text: str) -> set[str]:
111
+ return {
112
+ token.lower()
113
+ for token in TOKEN_RE.findall(text or "")
114
+ if len(token) >= 4
115
+ }
116
+
117
+
118
+ def _canonical_location_tokens(calibration: dict[str, Any], profile: dict[str, Any]) -> set[str]:
119
+ values: list[str] = []
120
+ for container in (profile, calibration):
121
+ for key in (
122
+ "current_residence",
123
+ "residence",
124
+ "location",
125
+ "base_location",
126
+ "city",
127
+ "country",
128
+ "timezone",
129
+ ):
130
+ if key in container:
131
+ values.extend(_iter_strings(container.get(key)))
132
+ user = container.get("user")
133
+ if isinstance(user, dict):
134
+ for key in ("location", "city", "country", "timezone"):
135
+ if key in user:
136
+ values.extend(_iter_strings(user.get(key)))
137
+ tokens: set[str] = set()
138
+ for value in values:
139
+ tokens.update(_tokenize(value))
140
+ return tokens
141
+
142
+
143
+ def _legacy_memory_paths(home: Path) -> list[Path]:
144
+ found: list[Path] = []
145
+ for parts in LEGACY_MEMORY_PATHS:
146
+ candidate = home.joinpath(*parts)
147
+ if candidate.exists():
148
+ found.append(candidate)
149
+ for parts in LEGACY_MEMORY_DIRS:
150
+ candidate = home.joinpath(*parts)
151
+ if candidate.exists() and any(candidate.iterdir()):
152
+ found.append(candidate)
153
+ return found
154
+
155
+
156
+ def _location_like_lines(text: str) -> list[str]:
157
+ lines: list[str] = []
158
+ for raw in str(text or "").splitlines():
159
+ line = raw.strip()
160
+ if not line or len(line) > 500:
161
+ continue
162
+ if LOCATION_HINT_RE.search(line):
163
+ lines.append(line[:240])
164
+ return lines
165
+
166
+
167
+ def audit_memory_layers(
168
+ *,
169
+ home: str | Path | None = None,
170
+ nexo_home: str | Path | None = None,
171
+ max_warnings: int = 5,
172
+ ) -> dict[str, Any]:
173
+ """Return a read-only audit of memory authority and legacy client files."""
174
+
175
+ home_path = Path(home) if home is not None else Path.home()
176
+ nexo_home_path = Path(nexo_home) if nexo_home is not None else Path(os.environ.get("NEXO_HOME", str(home_path / ".nexo")))
177
+ calibration, profile, source_paths = _load_canonical_sources(nexo_home_path)
178
+ canonical_location_tokens = _canonical_location_tokens(calibration, profile)
179
+
180
+ warnings: list[dict[str, Any]] = []
181
+ legacy_paths = _legacy_memory_paths(home_path)
182
+ if legacy_paths:
183
+ warnings.append({
184
+ "type": "legacy_client_memory_present",
185
+ "severity": "warn",
186
+ "paths": [str(path) for path in legacy_paths],
187
+ "message": "Legacy Claude/Codex MEMORY files exist. They are lower authority than NEXO Brain and should stay read-only.",
188
+ })
189
+
190
+ for parts in BOOTSTRAP_PATHS:
191
+ candidate = home_path.joinpath(*parts)
192
+ try:
193
+ text = candidate.read_text(encoding="utf-8") if candidate.exists() else ""
194
+ except Exception:
195
+ text = ""
196
+ if not text:
197
+ continue
198
+ for line in _location_like_lines(text):
199
+ line_tokens = _tokenize(line)
200
+ if canonical_location_tokens and line_tokens.isdisjoint(canonical_location_tokens):
201
+ warnings.append({
202
+ "type": "possible_identity_location_conflict",
203
+ "severity": "warn",
204
+ "path": str(candidate),
205
+ "line": line,
206
+ "message": "Bootstrap contains a location-like profile fact that does not match canonical calibration/profile tokens.",
207
+ })
208
+ break
209
+
210
+ return {
211
+ "ok": True,
212
+ "authority_order": AUTHORITY_ORDER,
213
+ "canonical_sources": source_paths,
214
+ "legacy_paths": [str(path) for path in legacy_paths],
215
+ "warnings": warnings[:max(0, int(max_warnings or 0))],
216
+ "warning_count": len(warnings),
217
+ }
218
+
219
+
220
+ def format_memory_layer_warnings(report: dict[str, Any]) -> list[str]:
221
+ warnings = report.get("warnings") if isinstance(report, dict) else None
222
+ if not isinstance(warnings, list) or not warnings:
223
+ return []
224
+ lines = [
225
+ "NEXO Brain/calibration/profile are authoritative; legacy client MEMORY files are read-only and lowest priority.",
226
+ ]
227
+ for warning in warnings:
228
+ if not isinstance(warning, dict):
229
+ continue
230
+ kind = warning.get("type") or "memory_layer_warning"
231
+ if kind == "legacy_client_memory_present":
232
+ paths_text = ", ".join(warning.get("paths") or [])
233
+ lines.append(f"Legacy MEMORY present: {paths_text}")
234
+ elif kind == "possible_identity_location_conflict":
235
+ lines.append(
236
+ "Possible profile conflict in "
237
+ f"{warning.get('path')}: {warning.get('line')}"
238
+ )
239
+ else:
240
+ lines.append(str(warning.get("message") or kind))
241
+ if report.get("warning_count", 0) > len(warnings):
242
+ lines.append(f"{report['warning_count'] - len(warnings)} more memory-layer warning(s) omitted.")
243
+ return lines
@@ -80,6 +80,34 @@ def _is_trivial_evidence(text: str) -> tuple[bool, str]:
80
80
  return False, ""
81
81
 
82
82
 
83
+ def _external_real_world_text(task: dict, *parts: str) -> str:
84
+ fields = [
85
+ task.get("goal", ""),
86
+ task.get("area", ""),
87
+ task.get("project_hint", ""),
88
+ task.get("context_hint", ""),
89
+ task.get("verification_step", ""),
90
+ ]
91
+ fields.extend(part for part in parts if part)
92
+ return " ".join(str(part or "") for part in fields).lower()
93
+
94
+
95
+ def _requires_external_real_world_check(task: dict, *parts: str) -> bool:
96
+ if str(task.get("task_type") or "").strip() not in ACTION_TASKS:
97
+ return False
98
+ text = _external_real_world_text(task, *parts)
99
+ return any(keyword in text for keyword in EXTERNAL_REAL_WORLD_ACTION_KEYWORDS)
100
+
101
+
102
+ def _has_external_real_world_evidence(text: str) -> bool:
103
+ lowered = str(text or "").lower()
104
+ if _is_trivial_evidence(lowered)[0]:
105
+ return False
106
+ has_verify_verb = any(keyword in lowered for keyword in REAL_WORLD_VERIFICATION_VERBS)
107
+ has_artifact = any(keyword in lowered for keyword in REAL_WORLD_ARTIFACT_KEYWORDS)
108
+ return has_verify_verb and has_artifact
109
+
110
+
83
111
  ACTION_TASKS = {"edit", "execute", "delegate"}
84
112
  RESPONSE_TASKS = {"answer", "analyze"}
85
113
  _GUARD_TOUCH_DEBT_TYPES = {
@@ -87,6 +115,93 @@ _GUARD_TOUCH_DEBT_TYPES = {
87
115
  "conditioned_file_touch_without_guard_ack",
88
116
  "write_without_file_guard_check",
89
117
  }
118
+ EXTERNAL_REAL_WORLD_ACTION_KEYWORDS = {
119
+ "email",
120
+ "e-mail",
121
+ "gmail",
122
+ "correo",
123
+ "mail",
124
+ "message",
125
+ "mensaje",
126
+ "whatsapp",
127
+ "telegram",
128
+ "sms",
129
+ "calendar",
130
+ "calendario",
131
+ "event",
132
+ "evento",
133
+ "invite",
134
+ "invitation",
135
+ "invitacion",
136
+ "invitación",
137
+ "meet",
138
+ "zoom",
139
+ "booking",
140
+ "reserva",
141
+ "send",
142
+ "sent",
143
+ "enviar",
144
+ "enviado",
145
+ "enviada",
146
+ "client",
147
+ "cliente",
148
+ "family",
149
+ "familia",
150
+ }
151
+ REAL_WORLD_VERIFICATION_VERBS = {
152
+ "verified",
153
+ "verify",
154
+ "checked",
155
+ "rechecked",
156
+ "re-read",
157
+ "reread",
158
+ "opened",
159
+ "inspected",
160
+ "confirmed",
161
+ "verificado",
162
+ "verifique",
163
+ "verifiqué",
164
+ "comprobado",
165
+ "comprobe",
166
+ "comprobé",
167
+ "revisado",
168
+ "revise",
169
+ "revisé",
170
+ "abierto",
171
+ "abri",
172
+ "abrí",
173
+ "confirmado",
174
+ }
175
+ REAL_WORLD_ARTIFACT_KEYWORDS = {
176
+ "sent folder",
177
+ "sent item",
178
+ "message-id",
179
+ "email",
180
+ "correo",
181
+ "destinatario",
182
+ "recipient",
183
+ "cc",
184
+ "bcc",
185
+ "subject",
186
+ "asunto",
187
+ "body",
188
+ "cuerpo",
189
+ "firma",
190
+ "signature",
191
+ "calendar",
192
+ "calendario",
193
+ "event",
194
+ "evento",
195
+ "invitee",
196
+ "invitado",
197
+ "meet link",
198
+ "meet",
199
+ "zoom",
200
+ "booking",
201
+ "reserva",
202
+ "sent",
203
+ "enviado",
204
+ }
90
205
  HIGH_STAKES_KEYWORDS = {
91
206
  "medical",
92
207
  "legal",
@@ -1462,6 +1577,60 @@ def handle_task_close(
1462
1577
  indent=2,
1463
1578
  )
1464
1579
 
1580
+ if clean_outcome == "done" and _requires_external_real_world_check(
1581
+ task,
1582
+ clean_evidence,
1583
+ clean_change_verify,
1584
+ outcome_notes,
1585
+ clean_change_summary,
1586
+ ):
1587
+ real_world_evidence = "\n".join(
1588
+ part
1589
+ for part in (
1590
+ clean_evidence,
1591
+ clean_change_verify,
1592
+ outcome_notes,
1593
+ clean_change_summary,
1594
+ )
1595
+ if part
1596
+ )
1597
+ if _has_external_real_world_evidence(real_world_evidence):
1598
+ resolve_protocol_debts(
1599
+ task_id=task_id,
1600
+ debt_types=["external_real_world_verification_missing"],
1601
+ resolution="task_close evidence includes post-action real-world verification.",
1602
+ )
1603
+ else:
1604
+ debt = _ensure_open_debt(
1605
+ task["session_id"],
1606
+ task_id,
1607
+ "external_real_world_verification_missing",
1608
+ severity="error",
1609
+ evidence=(
1610
+ "External-stakes task closed as done without proof that the real sent/event/booking "
1611
+ f"artifact was reopened and verified. Goal: {task.get('goal','')}. "
1612
+ f"Evidence provided: {real_world_evidence[:240]!r}"
1613
+ ),
1614
+ debts=debts_created,
1615
+ )
1616
+ return json.dumps(
1617
+ {
1618
+ "ok": False,
1619
+ "error": "Cannot close external-stakes task as 'done' without post-action real-world verification.",
1620
+ "hint": (
1621
+ "Re-open the sent email/message/calendar/booking artifact and verify recipients, "
1622
+ "CC/BCC, subject, body/signature, date/time/timezone, links, invitees, and attachments as applicable. "
1623
+ "Then retry nexo_task_close with that evidence."
1624
+ ),
1625
+ "task_id": task_id,
1626
+ "blocked_by": "external_real_world_verify",
1627
+ "debt_id": debt.get("id"),
1628
+ "debt_type": "external_real_world_verification_missing",
1629
+ },
1630
+ ensure_ascii=False,
1631
+ indent=2,
1632
+ )
1633
+
1465
1634
  # ── Release checklist: require channel alignment evidence for release tasks ──
1466
1635
  is_release = _is_release_task(
1467
1636
  goal=task.get("goal") or "",
@@ -5,7 +5,7 @@ NEXO Followup Hygiene — Weekly cleanup of followup/reminder statuses.
5
5
 
6
6
  Runs Sundays via LaunchAgent (or manually). Tasks:
7
7
  1. Normalize dirty statuses (COMPLETED YYYY-MM-DD -> COMPLETED)
8
- 2. Flag PENDING followups >14 days without updates as STALE
8
+ 2. Escalate PENDING followups >14 days without updates to needs_decision
9
9
  3. Generate summary of orphaned/forgotten followups for synthesis
10
10
 
11
11
  No CLI needed — this is pure mechanical cleanup.
@@ -85,21 +85,38 @@ def main():
85
85
  )
86
86
  log(f"Normalized {dirty_r} dirty reminder statuses")
87
87
 
88
- # 2. Flag stale followups (PENDING >14 days, no updates)
88
+ # 2. Escalate stale followups (PENDING >14 days, no updates)
89
89
  cutoff = (date.today() - timedelta(days=14)).isoformat()
90
+ updated_cutoff = datetime.now().timestamp() - (14 * 24 * 60 * 60)
90
91
  stale = conn.execute(
91
92
  "SELECT id, description, date, updated_at FROM followups "
92
93
  "WHERE status NOT LIKE 'COMPLETED%' "
93
- "AND status NOT IN ('DELETED','archived','blocked','waiting') "
94
+ "AND status NOT IN ('DELETED','archived','blocked','waiting','needs_decision','waiting_user') "
94
95
  "AND date != '' AND date < ? "
96
+ "AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
95
97
  "ORDER BY date",
96
- (cutoff,)
98
+ (cutoff, updated_cutoff)
97
99
  ).fetchall()
98
100
 
101
+ escalated_stale = []
99
102
  if stale:
100
- log(f"Found {len(stale)} stale followups (>14 days overdue):")
103
+ log(f"Escalating {len(stale)} stale followups (>14 days overdue, no recent update):")
101
104
  for s in stale[:10]:
102
105
  log(f" {s['id']}: {s['description'][:60]} (due: {s['date']})")
106
+ for s in stale:
107
+ result = nexo_db.update_followup(
108
+ str(s["id"]),
109
+ status="needs_decision",
110
+ date=TODAY,
111
+ history_actor="followup-hygiene",
112
+ history_event="stale_triage",
113
+ history_note=(
114
+ "Weekly hygiene escalated this old due followup to needs_decision "
115
+ "instead of leaving it in the executable briefing indefinitely."
116
+ ),
117
+ )
118
+ if not result.get("error"):
119
+ escalated_stale.append(str(s["id"]))
103
120
 
104
121
  # 3. Orphaned followups (no date, no recent update)
105
122
  orphans = conn.execute(
@@ -123,8 +140,10 @@ def main():
123
140
  "date": TODAY,
124
141
  "dirty_normalized": dirty_f + dirty_r,
125
142
  "stale_count": len(stale) if stale else 0,
143
+ "stale_escalated_count": len(escalated_stale),
126
144
  "orphan_count": len(orphans) if orphans else 0,
127
145
  "stale_ids": [s["id"] for s in stale[:20]] if stale else [],
146
+ "stale_escalated_ids": escalated_stale[:20],
128
147
  "orphan_ids": [o["id"] for o in orphans[:20]] if orphans else [],
129
148
  }
130
149
 
@@ -132,7 +151,7 @@ def main():
132
151
  summary_file.parent.mkdir(parents=True, exist_ok=True)
133
152
  summary_file.write_text(json.dumps(summary, indent=2))
134
153
 
135
- log(f"Summary: {dirty_f + dirty_r} normalized, {len(stale) if stale else 0} stale, {len(orphans) if orphans else 0} orphans")
154
+ log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(orphans) if orphans else 0} orphans")
136
155
  log("=== Followup Hygiene complete ===")
137
156
 
138
157
 
@@ -74,6 +74,9 @@ CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
74
74
  LOCK_FILE = LOG_DIR / "followup-runner.lock"
75
75
  MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
76
76
  COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
77
+ STALE_FOLLOWUP_TRIAGE_DAYS = 14
78
+ MAX_STALE_TRIAGE_PER_RUN = 8
79
+ MAX_NEEDS_OPERATOR_BRIEFING = 12
77
80
  DEFAULT_ASSISTANT_NAME = "Nova"
78
81
  DEFAULT_OPERATOR_LANGUAGE = "en"
79
82
 
@@ -101,6 +104,43 @@ def save_state(state: dict):
101
104
 
102
105
 
103
106
  # ── DB access ───────────────────────────────────────────────────────────
107
+ def _parse_date(value: str) -> date | None:
108
+ try:
109
+ return date.fromisoformat(str(value or "").strip()[:10])
110
+ except ValueError:
111
+ return None
112
+
113
+
114
+ def _followup_days_overdue(date_value: str, *, today_value: date | None = None) -> int:
115
+ due = _parse_date(date_value)
116
+ if not due:
117
+ return 0
118
+ today_obj = today_value or date.today()
119
+ return max(0, (today_obj - due).days)
120
+
121
+
122
+ def _history_has_recent_movement(history, *, days: int = STALE_FOLLOWUP_TRIAGE_DAYS) -> bool:
123
+ if not history:
124
+ return False
125
+ cutoff = date.today() - timedelta(days=days)
126
+ for event in history:
127
+ if not isinstance(event, dict):
128
+ continue
129
+ created = _parse_date(str(event.get("created_at") or event.get("date") or ""))
130
+ if created and created >= cutoff:
131
+ return True
132
+ return False
133
+
134
+
135
+ def _is_stale_followup_for_triage(followup: dict) -> bool:
136
+ status = str(followup.get("status") or "").strip().lower()
137
+ if status in {"needs_decision", "waiting_user", "blocked", "waiting"}:
138
+ return False
139
+ if _followup_days_overdue(str(followup.get("date") or "")) < STALE_FOLLOWUP_TRIAGE_DAYS:
140
+ return False
141
+ return not _history_has_recent_movement(followup.get("history") or [])
142
+
143
+
104
144
  def _is_in_cooldown(fu_id: str, state: dict) -> bool:
105
145
  """Check if a followup was recently attempted and should be skipped."""
106
146
  attempts = state.get("attempts", {})
@@ -252,14 +292,14 @@ def get_all_active_followups(state: dict) -> dict:
252
292
  operator_name = str(operator.get("operator_name") or "the operator")
253
293
  if not NEXO_DB.exists():
254
294
  log(f"DB not found: {NEXO_DB}")
255
- return {"actionable": [], "needs_operator": [], "future": [], "backlog": []}
295
+ return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
256
296
 
257
297
  today = date.today().isoformat()
258
298
  conn = sqlite3.connect(str(NEXO_DB))
259
299
  conn.row_factory = sqlite3.Row
260
300
  try:
261
301
  rows = conn.execute(
262
- "SELECT id, description, date, reasoning, verification, priority, recurrence, status "
302
+ "SELECT id, description, date, reasoning, verification, priority, recurrence, status, owner, updated_at "
263
303
  "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
264
304
  "AND UPPER(COALESCE(status, '')) NOT IN ('BLOCKED', 'ARCHIVED', 'DELETED', 'WAITING') "
265
305
  "AND description NOT LIKE '[Abandoned]%' "
@@ -270,7 +310,7 @@ def get_all_active_followups(state: dict) -> dict:
270
310
  " date ASC"
271
311
  ).fetchall()
272
312
 
273
- result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": []}
313
+ result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
274
314
  undated_triage_budget = 2
275
315
 
276
316
  for row in rows:
@@ -296,7 +336,12 @@ def get_all_active_followups(state: dict) -> dict:
296
336
  result["actionable"].append(triage_fu)
297
337
  undated_triage_budget -= 1
298
338
  elif fu_date <= today:
299
- if needs_operator:
339
+ if _is_stale_followup_for_triage(fu):
340
+ stale_fu = dict(fu)
341
+ stale_fu["stale_triage"] = True
342
+ stale_fu["days_overdue"] = _followup_days_overdue(fu_date)
343
+ result["stale_triage"].append(stale_fu)
344
+ elif needs_operator:
300
345
  result["needs_operator"].append(fu)
301
346
  elif _is_in_cooldown(fu["id"], state):
302
347
  result["cooled_down"].append(fu)
@@ -310,12 +355,16 @@ def get_all_active_followups(state: dict) -> dict:
310
355
  overflow = result["actionable"][MAX_FOLLOWUPS_PER_RUN:]
311
356
  result["actionable"] = result["actionable"][:MAX_FOLLOWUPS_PER_RUN]
312
357
  log(f"Capped actionable to {MAX_FOLLOWUPS_PER_RUN}, deferred {len(overflow)} to next run")
358
+ if len(result["needs_operator"]) > MAX_NEEDS_OPERATOR_BRIEFING:
359
+ overflow = result["needs_operator"][MAX_NEEDS_OPERATOR_BRIEFING:]
360
+ result["needs_operator"] = result["needs_operator"][:MAX_NEEDS_OPERATOR_BRIEFING]
361
+ log(f"Capped needs_operator to {MAX_NEEDS_OPERATOR_BRIEFING}, deferred {len(overflow)} noisy items")
313
362
 
314
363
  return result
315
364
 
316
365
  except Exception as e:
317
366
  log(f"DB error: {e}")
318
- return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": []}
367
+ return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
319
368
  finally:
320
369
  conn.close()
321
370
 
@@ -753,10 +802,37 @@ def main():
753
802
  groups = get_all_active_followups(state)
754
803
  all_actionable = list(groups["actionable"])
755
804
  cooled = groups.get("cooled_down", [])
805
+ stale_triage = groups.get("stale_triage", [])
756
806
 
757
807
  log(f"Actionable: {len(all_actionable)}, Cooled down: {len(cooled)}, "
758
808
  f"Needs operator: {len(groups['needs_operator'])}, "
759
- f"Future: {len(groups['future'])}, Backlog: {len(groups['backlog'])}")
809
+ f"Future: {len(groups['future'])}, Backlog: {len(groups['backlog'])}, "
810
+ f"Stale triage: {len(stale_triage)}")
811
+
812
+ for fu in stale_triage[:MAX_STALE_TRIAGE_PER_RUN]:
813
+ fid = str(fu.get("id") or "")
814
+ if not fid:
815
+ continue
816
+ days_overdue = int(fu.get("days_overdue") or 0)
817
+ summary = (
818
+ f"Followup overdue for {days_overdue} days without recent movement. "
819
+ "Operator decision required: close as obsolete, reschedule with reason, or convert into a concrete next action."
820
+ )
821
+ update_followup_fields(
822
+ fid,
823
+ date_value=date.today().isoformat(),
824
+ status="needs_decision",
825
+ history_event="stale_triage",
826
+ history_note=summary,
827
+ )
828
+ upsert_attention_reminder(
829
+ fid,
830
+ summary=summary,
831
+ options={"a": "close obsolete", "b": "reschedule", "c": "convert to next action"},
832
+ status="needs_decision",
833
+ operator_language=_operator_language(),
834
+ )
835
+ record_attempt(state, fid, "needs_decision")
760
836
 
761
837
  results = []
762
838
 
@@ -851,7 +927,7 @@ def main():
851
927
  record_attempt(state, fid, r["status"])
852
928
  log(f" {fid}: {r['status']} -> cooldown {COOLDOWN_DAYS} days")
853
929
 
854
- total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"])
930
+ total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"]) + len(stale_triage)
855
931
  attention_handed_off = any(
856
932
  r.get("needs_attention") or r["status"] in ("needs_decision", "blocked")
857
933
  for r in results
@@ -478,6 +478,18 @@ def handle_startup(
478
478
  lines.append(f" {raw_line}")
479
479
  lines.append(f" Full briefing: {briefing_path}")
480
480
 
481
+ try:
482
+ from memory_layer_audit import audit_memory_layers, format_memory_layer_warnings
483
+
484
+ memory_warnings = format_memory_layer_warnings(audit_memory_layers(max_warnings=4))
485
+ if memory_warnings:
486
+ lines.append("")
487
+ lines.append("MEMORY LAYER CHECK:")
488
+ for raw_line in memory_warnings:
489
+ lines.append(f" {raw_line}")
490
+ except Exception:
491
+ pass
492
+
481
493
  # Check LaunchAgent health (macOS only)
482
494
  la_warnings = _check_launchagents()
483
495
  if la_warnings:
@@ -1,4 +1,4 @@
1
- <!-- nexo-claude-md-version: 2.1.7 -->
1
+ <!-- nexo-claude-md-version: 2.1.8 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — Cognitive Co-Operator
@@ -87,6 +87,7 @@ Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name
87
87
  ## User Profile
88
88
  - **Calibration:** `{{NEXO_HOME}}/brain/calibration.json` (personality settings + language + user name)
89
89
  - **Profile:** `{{NEXO_HOME}}/brain/profile.json` (deep scan results from onboarding)
90
+ - **Memory authority:** `calibration.json` + `profile.json` + NEXO Brain DB win over legacy client memory. Do not read, write, or rely on `MEMORY.md` / `.claude/memories` / `.codex/memories` as source of truth; surface conflicts at startup instead of carrying old facts silently.
90
91
 
91
92
  ### First Session Onboarding (only if profile.json lacks `role` or `technical_level`)
92
93
  Ask TWO questions: (1) "What do you do?" -> save to profile.json + `nexo_preference_set("role", answer)`. (2) "Technical level? Beginner / Intermediate / Advanced" -> save + `nexo_preference_set("technical_level", answer)`. Then: "Got it. From now on I learn by observing." Never ask onboarding questions again.
@@ -1,4 +1,4 @@
1
- <!-- nexo-codex-agents-version: 1.2.6 -->
1
+ <!-- nexo-codex-agents-version: 1.2.7 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — NEXO Shared Brain for Codex
@@ -98,6 +98,11 @@ Operational rule R34 (Layer 2) watches this coherence.
98
98
  - **Skills:** reusable procedures plus `nexo_skill_evolution_candidates` for text->script and skill refinement.
99
99
  - **Watchdog / Immune / Followups:** reliability, quarantine, reminders, and self-healing are native parts of NEXO.
100
100
 
101
+ ## Memory Authority
102
+ - `calibration.json`, `profile.json`, and NEXO Brain DB are authoritative for identity, profile, decisions, learnings, diary, and followups.
103
+ - Legacy client memory files such as `MEMORY.md`, `.claude/memories`, and `.codex/memories` are lowest-authority read-only leftovers. Do not write them or use them to override Brain/calibration/profile.
104
+ - If bootstrap text and Brain profile disagree, surface the conflict at startup and use Brain/calibration/profile until corrected.
105
+
101
106
  ## Project Atlas
102
107
  Search `{{NEXO_HOME}}/brain/project-atlas.json` BEFORE touching any project. Never assume server, port, or code location.
103
108