nexo-brain 7.12.10 → 7.12.15

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.12.10",
3
+ "version": "7.12.15",
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,7 @@
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.12.2` is the current packaged-runtime line. Patch release — legacy headless automation paths now stay on the resonance engine: `task_profile` no longer pre-fills model/effort, `email-monitor` stops carrying a private routing override, personal automation helpers stop injecting a default model, and runtime updates scrub the last stale email-profile field automatically. Result: email daemon, personal scripts, and updated installs all converge on the same `caller`/`tier` backend → `(model, effort)` resolution path already used by Deep Sleep and morning-agent.
21
+ Version `7.12.15` is the current packaged-runtime line. Patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
22
22
 
23
23
  Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -225,6 +225,50 @@ function run(cmd, opts = {}) {
225
225
  }
226
226
  }
227
227
 
228
+ function findBundledWheel(wheelsDir, prefix) {
229
+ try {
230
+ const normalizedPrefix = String(prefix || "").toLowerCase() + "-";
231
+ const matches = fs.readdirSync(wheelsDir)
232
+ .filter((name) => name.toLowerCase().startsWith(normalizedPrefix) && name.endsWith(".whl"))
233
+ .sort();
234
+ if (!matches.length) return "";
235
+ return path.join(wheelsDir, matches[matches.length - 1]);
236
+ } catch {
237
+ return "";
238
+ }
239
+ }
240
+
241
+ function pythonHasPip(pythonBin) {
242
+ try {
243
+ const result = spawnSync(pythonBin, ["-m", "pip", "--version"], {
244
+ stdio: "ignore",
245
+ timeout: 15000,
246
+ });
247
+ return result.status === 0;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ function seedPipFromBundledWheels(venvPython, bundledWheelsDir) {
254
+ if (!fs.existsSync(venvPython) || !fs.existsSync(bundledWheelsDir)) return false;
255
+ if (pythonHasPip(venvPython)) return true;
256
+ const pipWheel = findBundledWheel(bundledWheelsDir, "pip");
257
+ if (!pipWheel) return false;
258
+ log(" Seeding pip into venv from bundled wheels...");
259
+ const result = spawnSync(venvPython, [
260
+ path.join(pipWheel, "pip"),
261
+ "install",
262
+ "--no-index",
263
+ "--find-links",
264
+ bundledWheelsDir,
265
+ "pip",
266
+ "setuptools",
267
+ "wheel",
268
+ ], { stdio: "inherit", timeout: 120000 });
269
+ return result.status === 0 && pythonHasPip(venvPython);
270
+ }
271
+
228
272
  function log(msg) {
229
273
  console.log(` ${msg}`);
230
274
  try {
@@ -360,7 +404,14 @@ function isOnboardingComplete(calibration) {
360
404
  const meta = calibration.meta && typeof calibration.meta === "object" ? calibration.meta : {};
361
405
 
362
406
  if (meta.onboarding_completed === true) {
363
- return nonEmptyString(name) && nonEmptyString(language);
407
+ // v7.12.11 bug surfaced on Inma's smoke install 2026-05-03: Desktop
408
+ // bootstrap runs nexo-brain in --yes/--skip mode, which used to write
409
+ // `onboarding_completed: true` alongside the placeholder "Usuario"/"en"
410
+ // defaults. The Desktop wizard then never fired because this returned
411
+ // true on the very first launch. Treat a placeholder name as "marker is
412
+ // a lie, real onboarding never happened" so the renderer can still
413
+ // surface the wizard. Same guard applied to the legacy fallback below.
414
+ return nonEmptyString(name) && !isPlaceholderUserName(name) && nonEmptyString(language);
364
415
  }
365
416
 
366
417
  // Legacy fallback: v7.8/v7.9-era calibration files may not carry the
@@ -3440,8 +3491,16 @@ async function runSetup() {
3440
3491
  execution_first: true,
3441
3492
  },
3442
3493
  meta: {
3443
- onboarding_completed: true,
3444
- onboarding_completed_at: new Date().toISOString(),
3494
+ // v7.12.11 — only mark onboarding_completed when the user actually
3495
+ // answered the prompts (interactive run, !useDefaults). The
3496
+ // Desktop bootstrap calls nexo-brain with `--yes/--skip` to set up
3497
+ // the runtime non-interactively; in that path the values are
3498
+ // placeholders ("Usuario" / "en" / "Nova") and the real wizard
3499
+ // lives in the renderer. Marking it complete here used to short-
3500
+ // circuit that wizard and leave new users staring at an empty chat
3501
+ // (Inma 2026-05-03 smoke install).
3502
+ onboarding_completed: !useDefaults,
3503
+ onboarding_completed_at: !useDefaults ? new Date().toISOString() : null,
3445
3504
  },
3446
3505
  auto_install: "ask", // updated later if user answers P11
3447
3506
  calibrated_at: new Date().toISOString(),
@@ -3526,15 +3585,28 @@ async function runSetup() {
3526
3585
  const venvPython = platform === "win32"
3527
3586
  ? path.join(venvPath, "Scripts", "python.exe")
3528
3587
  : path.join(venvPath, "bin", "python3");
3588
+ const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
3529
3589
 
3530
3590
  // Create venv if it doesn't exist
3531
3591
  if (!fs.existsSync(venvPython)) {
3532
3592
  log(" Creating Python virtual environment...");
3533
3593
  const venvResult = spawnSync(python, ["-m", "venv", venvPath], { stdio: "inherit" });
3534
3594
  if (venvResult.status !== 0) {
3535
- log("Failed to create venv. Trying pip install directly...");
3595
+ if (platform === "linux" && fs.existsSync(bundledWheelsDir)) {
3596
+ log(" Python venv could not seed pip; retrying offline without pip...");
3597
+ try { fs.rmSync(venvPath, { recursive: true, force: true }); } catch {}
3598
+ const bareVenv = spawnSync(python, ["-m", "venv", "--without-pip", venvPath], { stdio: "inherit" });
3599
+ if (bareVenv.status !== 0) {
3600
+ log("Failed to create venv. Trying pip install directly...");
3601
+ }
3602
+ } else {
3603
+ log("Failed to create venv. Trying pip install directly...");
3604
+ }
3536
3605
  }
3537
3606
  }
3607
+ if (fs.existsSync(venvPython) && !pythonHasPip(venvPython)) {
3608
+ seedPipFromBundledWheels(venvPython, bundledWheelsDir);
3609
+ }
3538
3610
 
3539
3611
  // Use venv python if available, otherwise fall back to system python with --break-system-packages
3540
3612
  const pipPython = fs.existsSync(venvPython) ? venvPython : python;
@@ -3542,7 +3614,6 @@ async function runSetup() {
3542
3614
  // Detect bundled wheels in resources/python-wheels (offline-first). If
3543
3615
  // present, pip uses --no-index --find-links to install without internet.
3544
3616
  // Falls back to PyPI if bundle not found.
3545
- const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
3546
3617
  // v0.32.5 — el bundle empaca wheels manylinux (cp312 x86_64) porque
3547
3618
  // en Win Brain corre dentro de WSL Ubuntu noble. En Mac, Brain corre
3548
3619
  // nativo macOS y NO acepta esos wheels (ABI distinto). Si gateamos
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.10",
3
+ "version": "7.12.15",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -1233,11 +1233,9 @@ def _reload_launch_agents_after_bump() -> dict:
1233
1233
  "errors": [{plist, stderr}],
1234
1234
  }
1235
1235
 
1236
- Linux equivalent: systemctl --user daemon-reload + restart of timer
1237
- units. Implemented as a no-op stub on Linux for now (the macOS
1238
- LaunchAgent path is the production target — Linux users running
1239
- `nexo update` get the cron sync but not the per-timer restart yet).
1240
- Captured as a TODO for the next round.
1236
+ Linux/WSL remains a no-op by default. Systemd user-unit reload can be
1237
+ enabled explicitly for development/runtime experiments, but public update
1238
+ paths must not restart host services unexpectedly.
1241
1239
  """
1242
1240
  result: dict = {
1243
1241
  "scanned": 0,
@@ -1247,8 +1245,58 @@ def _reload_launch_agents_after_bump() -> dict:
1247
1245
  "platform": sys.platform,
1248
1246
  }
1249
1247
 
1248
+ if sys.platform == "linux":
1249
+ if os.environ.get("NEXO_ENABLE_SYSTEMD_RELOAD_AFTER_BUMP") != "1":
1250
+ result["skipped_reason"] = "systemd-reload-disabled"
1251
+ return result
1252
+
1253
+ # Experimental Linux path: reload systemd user units so nexo-* timers
1254
+ # updated by auto-update pick up new ExecStart paths. Best-effort: a
1255
+ # missing systemd (Docker/CI/most WSL installs) just returns no-op.
1256
+ try:
1257
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
1258
+ scanned_units = []
1259
+ if unit_dir.is_dir():
1260
+ scanned_units = sorted(
1261
+ [p for p in unit_dir.iterdir() if p.suffix in {".service", ".timer"} and p.name.startswith("nexo")]
1262
+ )
1263
+ result["scanned"] = len(scanned_units)
1264
+ if scanned_units:
1265
+ reload_proc = subprocess.run(
1266
+ ["systemctl", "--user", "daemon-reload"],
1267
+ capture_output=True,
1268
+ text=True,
1269
+ timeout=15,
1270
+ )
1271
+ if reload_proc.returncode != 0:
1272
+ result["errors"].append({
1273
+ "unit": "daemon-reload",
1274
+ "stderr": (reload_proc.stderr or reload_proc.stdout or "").strip(),
1275
+ })
1276
+ else:
1277
+ for unit in scanned_units:
1278
+ restart = subprocess.run(
1279
+ ["systemctl", "--user", "restart", unit.name],
1280
+ capture_output=True,
1281
+ text=True,
1282
+ timeout=15,
1283
+ )
1284
+ if restart.returncode == 0:
1285
+ result["reloaded"] += 1
1286
+ else:
1287
+ result["errors"].append({
1288
+ "unit": unit.name,
1289
+ "stderr": (restart.stderr or restart.stdout or "").strip(),
1290
+ })
1291
+ except FileNotFoundError:
1292
+ result["skipped_reason"] = "systemctl-not-available"
1293
+ except Exception as e:
1294
+ result["errors"].append({"unit": "*", "stderr": f"systemd reload failed: {e}"})
1295
+ return result
1296
+
1250
1297
  if sys.platform != "darwin":
1251
- # macOS-only for now. systemd path tracked separately.
1298
+ # Other platforms (win32 native never reached because Brain runs
1299
+ # inside WSL Linux on Windows): nothing to reload.
1252
1300
  return result
1253
1301
  if _is_ephemeral_runtime_install():
1254
1302
  result["skipped_reason"] = "ephemeral-runtime"
package/src/cli.py CHANGED
@@ -1966,8 +1966,42 @@ def _service_control(service_name: str, action: str) -> int:
1966
1966
 
1967
1967
  label = f"com.nexo.{service_name}"
1968
1968
 
1969
+ # v7.12.12 — Linux/WSL path uses systemd user units (`<service>.service`
1970
+ # in ~/.config/systemd/user/). Until 7.12.11 this stub printed
1971
+ # "supported only on macOS" and returned 1, leaving Linux operators
1972
+ # with no CLI to start/stop the brain — they had to learn systemctl
1973
+ # syntax themselves. Symmetric semantics: on=start, off=stop,
1974
+ # status=is-active. Names match the macOS labels with "nexo-" prefix.
1975
+ if plat.system() == "Linux":
1976
+ unit = f"nexo-{service_name}.service"
1977
+ if action == "status":
1978
+ result = subprocess.run(
1979
+ ["systemctl", "--user", "is-active", unit],
1980
+ capture_output=True,
1981
+ text=True,
1982
+ )
1983
+ state = (result.stdout or "").strip() or "unknown"
1984
+ print(f"{service_name}: {'running' if state == 'active' else state}")
1985
+ return 0
1986
+ if action in ("on", "off"):
1987
+ verb = "start" if action == "on" else "stop"
1988
+ result = subprocess.run(
1989
+ ["systemctl", "--user", verb, unit],
1990
+ capture_output=True,
1991
+ text=True,
1992
+ )
1993
+ if result.returncode == 0:
1994
+ print(f"{service_name}: {'started' if action == 'on' else 'stopped'}")
1995
+ return 0
1996
+ err = (result.stderr or result.stdout or "").strip()
1997
+ print(f"Failed to {verb} {service_name}: {err}", file=sys.stderr)
1998
+ print(f"Hint: install the unit at ~/.config/systemd/user/{unit}", file=sys.stderr)
1999
+ return 1
2000
+ print(f"Unknown action: {action}. Use on, off, or status.", file=sys.stderr)
2001
+ return 1
2002
+
1969
2003
  if plat.system() != "Darwin":
1970
- print(f"Service control only supported on macOS for now.", file=sys.stderr)
2004
+ print(f"Service control not supported on this platform: {plat.system()}", file=sys.stderr)
1971
2005
  return 1
1972
2006
 
1973
2007
  plist_path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
@@ -396,15 +396,20 @@ def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = N
396
396
  "attempts": attempts,
397
397
  }
398
398
 
399
- install_cmd = (
400
- ["sudo", "npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE]
401
- if sys.platform == "linux"
402
- else ["npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE]
403
- )
399
+ # v7.12.12 — try npm without sudo first on Linux. Hardcoding `sudo`
400
+ # used to make sense on stock Ubuntu where /usr/lib/node_modules is
401
+ # root-owned, but inside the WSL distro NEXO Desktop ships, the
402
+ # default global prefix is already `/home/<user>/.nexo/runtime/
403
+ # bootstrap/npm-global` (writable). A blind `sudo` there triggers a
404
+ # password prompt on a TTY the user cannot see, so the bootstrap
405
+ # appears stuck. Try unprivileged first; if it fails with EACCES
406
+ # (insufficient permissions), retry with sudo.
404
407
  install_error = ""
408
+ install = None
409
+ base_cmd = ["npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE]
405
410
  try:
406
411
  install = subprocess.run(
407
- install_cmd,
412
+ base_cmd,
408
413
  capture_output=True,
409
414
  text=True,
410
415
  timeout=180,
@@ -413,6 +418,29 @@ def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = N
413
418
  if install.returncode != 0:
414
419
  install_error = (install.stderr or install.stdout or "npm install failed").strip()
415
420
  attempts.append(install_error)
421
+ permission_failure = (
422
+ "EACCES" in install_error
423
+ or "permission denied" in install_error.lower()
424
+ or "operation not permitted" in install_error.lower()
425
+ )
426
+ if permission_failure and sys.platform == "linux":
427
+ attempts.append("retrying with sudo (Linux EACCES fallback)")
428
+ try:
429
+ install = subprocess.run(
430
+ ["sudo", "-n", "npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE],
431
+ capture_output=True,
432
+ text=True,
433
+ timeout=180,
434
+ env=env,
435
+ )
436
+ if install.returncode != 0:
437
+ install_error = (install.stderr or install.stdout or "sudo npm install failed").strip()
438
+ attempts.append(install_error)
439
+ else:
440
+ install_error = ""
441
+ except Exception as exc:
442
+ install_error = f"sudo npm install failed: {exc}"
443
+ attempts.append(install_error)
416
444
  except Exception as exc:
417
445
  install_error = f"npm install failed: {exc}"
418
446
  attempts.append(install_error)
@@ -1101,26 +1101,82 @@ async def api_ops_execute(fid: str):
1101
1101
  if not row:
1102
1102
  return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
1103
1103
  item = dict(row)
1104
- if platform.system() != "Darwin":
1105
- return JSONResponse(
1106
- {"error": "This operation requires macOS (uses osascript to open Terminal)"},
1107
- status_code=501,
1108
- )
1109
1104
  # Security: avoid interpolating user-controlled data into shell commands.
1110
- # Write the followup ID to a temp file and pass a safe, fixed command to osascript.
1105
+ # Write the followup ID to a temp file and pass a safe, fixed command.
1111
1106
  import tempfile
1112
1107
  tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", prefix="nexo-followup-", delete=False)
1113
1108
  tmp.write(fid)
1114
1109
  tmp.close()
1115
- # The selected terminal client reads the followup ID from the temp file — no shell interpolation of description
1116
1110
  try:
1111
+ # Use the selected terminal client from NEXO preferences (Claude Code or
1112
+ # Codex) instead of hardcoding a launcher in the dashboard.
1117
1113
  _, shell_cmd = build_followup_terminal_shell_command(tmp.name)
1118
1114
  except AgentRunnerError as exc:
1119
1115
  return JSONResponse({"error": str(exc)}, status_code=503)
1120
- escaped = shell_cmd.replace("\\", "\\\\").replace('"', '\\"')
1121
- script = f'tell application "Terminal" to do script "{escaped}"'
1122
- subprocess.Popen(["osascript", "-e", script])
1123
- return {"success": True, "followup_id": fid}
1116
+
1117
+ system = platform.system()
1118
+ if system == "Darwin":
1119
+ # macOS: open Terminal.app via osascript (legacy path).
1120
+ escaped = shell_cmd.replace("\\", "\\\\").replace('"', '\\"')
1121
+ script = f'tell application "Terminal" to do script "{escaped}"'
1122
+ subprocess.Popen(["osascript", "-e", script])
1123
+ return {"success": True, "followup_id": fid, "platform": "darwin"}
1124
+
1125
+ # 7.12.14 — Windows / WSL parity. Until now this endpoint returned 501
1126
+ # on any non-Darwin host, leaving Win11 users without a way to launch
1127
+ # followups from the dashboard. We detect WSL via WSL_DISTRO_NAME /
1128
+ # /proc/sys/fs/binfmt_misc/WSLInterop and shell out through Win
1129
+ # interop (cmd.exe /c start ... wt.exe) so the user gets a real
1130
+ # Windows Terminal tab running `wsl.exe -d <distro> -- bash -lc "<cmd>"`
1131
+ # — matching the Mac UX of "click → terminal opens with claude waiting".
1132
+ is_wsl = bool(os.environ.get("WSL_DISTRO_NAME")) or os.path.exists(
1133
+ "/proc/sys/fs/binfmt_misc/WSLInterop"
1134
+ )
1135
+ if is_wsl:
1136
+ distro = os.environ.get("WSL_DISTRO_NAME") or "Ubuntu-24.04"
1137
+ # Quote the bash command for cmd.exe; cmd's start unwraps one level
1138
+ # so we double-escape inner double quotes.
1139
+ bash_cmd = shell_cmd.replace('"', '\\"')
1140
+ wsl_invoke = f'wsl.exe -d {distro} -- bash -lc "{bash_cmd}"'
1141
+ try:
1142
+ subprocess.Popen(
1143
+ ["cmd.exe", "/c", "start", "", "wt.exe", "new-tab", "--", "cmd.exe", "/k", wsl_invoke],
1144
+ stdout=subprocess.DEVNULL,
1145
+ stderr=subprocess.DEVNULL,
1146
+ )
1147
+ except FileNotFoundError:
1148
+ return JSONResponse(
1149
+ {
1150
+ "error": "Could not launch Windows Terminal automatically.",
1151
+ "manual_command": wsl_invoke,
1152
+ "followup_id": fid,
1153
+ },
1154
+ status_code=503,
1155
+ )
1156
+ return {"success": True, "followup_id": fid, "platform": "wsl", "distro": distro}
1157
+
1158
+ # Pure Linux (no WSL): try common terminal emulators in order, then
1159
+ # fall back to returning the manual command. Best-effort because the
1160
+ # dashboard is primarily Mac + WSL.
1161
+ from shutil import which
1162
+ for emulator in ("x-terminal-emulator", "gnome-terminal", "konsole", "xterm"):
1163
+ if which(emulator):
1164
+ try:
1165
+ if emulator == "gnome-terminal":
1166
+ subprocess.Popen([emulator, "--", "bash", "-lc", shell_cmd])
1167
+ else:
1168
+ subprocess.Popen([emulator, "-e", shell_cmd])
1169
+ return {"success": True, "followup_id": fid, "platform": "linux", "emulator": emulator}
1170
+ except OSError:
1171
+ continue
1172
+ return JSONResponse(
1173
+ {
1174
+ "error": "No terminal emulator available to launch the followup automatically.",
1175
+ "manual_command": shell_cmd,
1176
+ "followup_id": fid,
1177
+ },
1178
+ status_code=503,
1179
+ )
1124
1180
 
1125
1181
 
1126
1182
  # ---------------------------------------------------------------------------
@@ -1959,8 +2015,21 @@ def main():
1959
2015
  webbrowser.open(f"http://localhost:{args.port}")
1960
2016
  threading.Thread(target=_open, daemon=True).start()
1961
2017
 
2018
+ # v7.12.12 — bind 0.0.0.0 when running inside WSL so the Windows host
2019
+ # can reach the dashboard. WSL2 does auto port forwarding for 127.0.0.1
2020
+ # in most setups, but corporate networks and certain WSL versions break
2021
+ # the loopback bridge; binding all interfaces inside the guest closes
2022
+ # that gap with no security cost (the WSL guest is private to the
2023
+ # logged-in Windows user). On Mac/Linux native, keep 127.0.0.1.
2024
+ bind_host = "127.0.0.1"
2025
+ try:
2026
+ from src.windows_runtime import running_inside_wsl # type: ignore
2027
+ if running_inside_wsl():
2028
+ bind_host = "0.0.0.0"
2029
+ except Exception:
2030
+ pass
1962
2031
  import uvicorn
1963
- uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="info")
2032
+ uvicorn.run(app, host=bind_host, port=args.port, log_level="info")
1964
2033
 
1965
2034
 
1966
2035
  if __name__ == "__main__":
@@ -405,14 +405,20 @@ def _onboard_steps() -> list[dict]:
405
405
  "id": "residence",
406
406
  "prompt": {"es": "¿Dónde vives o trabajas habitualmente?", "en": "Where do you live or work most days?"},
407
407
  "hint": {
408
- "es": "Una ciudad o zona; me ayuda con horarios, clima, ofertas locales y mil cosas más.",
409
- "en": "A city or area; helps me with schedules, weather, local options, and a thousand small things.",
408
+ "es": "Empieza a escribir y elige tu ciudad. Guardo coordenadas para clima, horarios, etc.",
409
+ "en": "Start typing and pick your city. We save coordinates for weather, schedules, and so on.",
410
410
  },
411
- "type": "text",
411
+ # v7.12.13 — `city` triggers the geocoding autocomplete in the
412
+ # renderer (CityStep talks to Nominatim and writes a JSON
413
+ # payload with display, name, lat, lon, country). The weather
414
+ # widget and any future location-aware feature can then read
415
+ # real coordinates from profile.current_residence instead of
416
+ # a free-text label.
417
+ "type": "city",
412
418
  "writes": "profile.current_residence",
413
419
  "file": "profile.json",
414
420
  "optional": True,
415
- "validate": r"^.{0,120}$",
421
+ "validate": r"^.{0,2000}$",
416
422
  },
417
423
  {
418
424
  "id": "role",
@@ -0,0 +1,222 @@
1
+ """Durable sent-email continuity for NEXO automations.
2
+
3
+ The email monitor tracks inbound lifecycle rows. This module tracks outbound
4
+ messages so startup, duplicate checks, and briefings can see what NEXO already
5
+ sent even when the send path did not originate from an inbound email row.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import paths
17
+
18
+
19
+ EMAIL_SENT_TABLE_SQL = """
20
+ CREATE TABLE IF NOT EXISTS sent_email_events (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ message_id TEXT,
23
+ sender TEXT,
24
+ to_addrs TEXT NOT NULL DEFAULT '',
25
+ cc_addrs TEXT NOT NULL DEFAULT '',
26
+ subject TEXT NOT NULL DEFAULT '',
27
+ in_reply_to TEXT NOT NULL DEFAULT '',
28
+ references_header TEXT NOT NULL DEFAULT '',
29
+ source TEXT NOT NULL DEFAULT '',
30
+ status TEXT NOT NULL DEFAULT 'sent',
31
+ sent_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
32
+ meta TEXT NOT NULL DEFAULT '{}'
33
+ );
34
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sent_email_message_id
35
+ ON sent_email_events(message_id)
36
+ WHERE message_id IS NOT NULL AND message_id != '';
37
+ CREATE INDEX IF NOT EXISTS idx_sent_email_sent_at ON sent_email_events(sent_at);
38
+ CREATE INDEX IF NOT EXISTS idx_sent_email_subject ON sent_email_events(subject);
39
+ """
40
+
41
+ RECENT_SENT_EMAILS_TITLE = "EMAILS ENVIADOS ULTIMAS 24H POR LA OPERATIVA"
42
+
43
+
44
+ def sent_email_db_path() -> Path:
45
+ return paths.nexo_email_dir() / "nexo-email.db"
46
+
47
+
48
+ def ensure_sent_email_table(conn: sqlite3.Connection) -> None:
49
+ conn.executescript(EMAIL_SENT_TABLE_SQL)
50
+
51
+
52
+ def _connect(db_path: str | Path | None = None) -> sqlite3.Connection:
53
+ path = Path(db_path) if db_path else sent_email_db_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ conn = sqlite3.connect(path)
56
+ conn.row_factory = sqlite3.Row
57
+ ensure_sent_email_table(conn)
58
+ return conn
59
+
60
+
61
+ def _clean(value: object, limit: int = 500) -> str:
62
+ text = " ".join(str(value or "").split())
63
+ if len(text) <= limit:
64
+ return text
65
+ return text[: limit - 3].rstrip() + "..."
66
+
67
+
68
+ def _safe_meta(meta: dict[str, Any] | None) -> str:
69
+ try:
70
+ return json.dumps(meta or {}, ensure_ascii=True, sort_keys=True)
71
+ except Exception:
72
+ return "{}"
73
+
74
+
75
+ def _record_cognitive_memory(event: dict[str, str]) -> None:
76
+ try:
77
+ import cognitive
78
+
79
+ to_value = event.get("to_addrs", "")
80
+ subject = event.get("subject", "")
81
+ message_id = event.get("message_id", "")
82
+ content = (
83
+ "Sent email recorded by NEXO. "
84
+ f"To: {to_value}. Subject: {subject}. Message-ID: {message_id}."
85
+ )
86
+ cognitive.ingest_to_ltm(
87
+ content,
88
+ source_type="email_sent",
89
+ source_id=message_id or f"{to_value}:{subject}",
90
+ source_title=subject,
91
+ domain="email",
92
+ tags="email,sent,continuity",
93
+ bypass_gate=True,
94
+ )
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ def record_sent_email(
100
+ *,
101
+ message_id: str = "",
102
+ sender: str = "",
103
+ to_addrs: str = "",
104
+ cc_addrs: str = "",
105
+ subject: str = "",
106
+ in_reply_to: str = "",
107
+ references_header: str = "",
108
+ source: str = "",
109
+ status: str = "sent",
110
+ meta: dict[str, Any] | None = None,
111
+ db_path: str | Path | None = None,
112
+ record_memory: bool = True,
113
+ ) -> dict[str, str]:
114
+ event = {
115
+ "message_id": _clean(message_id, 300),
116
+ "sender": _clean(sender, 300),
117
+ "to_addrs": _clean(to_addrs, 800),
118
+ "cc_addrs": _clean(cc_addrs, 800),
119
+ "subject": _clean(subject, 500),
120
+ "in_reply_to": _clean(in_reply_to, 300),
121
+ "references_header": _clean(references_header, 1000),
122
+ "source": _clean(source or "unknown", 120),
123
+ "status": _clean(status or "sent", 80),
124
+ "meta": _safe_meta(meta),
125
+ }
126
+ conn = _connect(db_path)
127
+ try:
128
+ conn.execute(
129
+ """
130
+ INSERT OR REPLACE INTO sent_email_events (
131
+ message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
132
+ references_header, source, status, meta
133
+ )
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
135
+ """,
136
+ (
137
+ event["message_id"],
138
+ event["sender"],
139
+ event["to_addrs"],
140
+ event["cc_addrs"],
141
+ event["subject"],
142
+ event["in_reply_to"],
143
+ event["references_header"],
144
+ event["source"],
145
+ event["status"],
146
+ event["meta"],
147
+ ),
148
+ )
149
+ conn.commit()
150
+ finally:
151
+ conn.close()
152
+
153
+ if record_memory:
154
+ _record_cognitive_memory(event)
155
+ return event
156
+
157
+
158
+ def recent_sent_emails(
159
+ *,
160
+ hours: int = 24,
161
+ limit: int = 10,
162
+ db_path: str | Path | None = None,
163
+ ) -> list[dict[str, str]]:
164
+ cutoff = (datetime.now() - timedelta(hours=max(1, int(hours)))).strftime("%Y-%m-%d %H:%M:%S")
165
+ conn = _connect(db_path)
166
+ try:
167
+ rows = conn.execute(
168
+ """
169
+ SELECT message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
170
+ references_header, source, status, sent_at, meta
171
+ FROM sent_email_events
172
+ WHERE sent_at >= ?
173
+ ORDER BY sent_at DESC
174
+ LIMIT ?
175
+ """,
176
+ (cutoff, max(1, int(limit))),
177
+ ).fetchall()
178
+ return [dict(row) for row in rows]
179
+ finally:
180
+ conn.close()
181
+
182
+
183
+ def find_sent_email(
184
+ *,
185
+ to_addr: str = "",
186
+ subject: str = "",
187
+ since_hours: int = 72,
188
+ db_path: str | Path | None = None,
189
+ ) -> dict[str, str] | None:
190
+ target_to = _clean(to_addr).lower()
191
+ target_subject = _clean(subject).lower()
192
+ if not target_to or not target_subject:
193
+ return None
194
+ for event in recent_sent_emails(hours=since_hours, limit=100, db_path=db_path):
195
+ if target_to in str(event.get("to_addrs") or "").lower() and target_subject in str(event.get("subject") or "").lower():
196
+ return event
197
+ return None
198
+
199
+
200
+ def format_recent_sent_email_block(*, hours: int = 24, limit: int = 8) -> str:
201
+ rows = recent_sent_emails(hours=hours, limit=limit)
202
+ if not rows:
203
+ return ""
204
+ lines = [f"== {RECENT_SENT_EMAILS_TITLE} =="]
205
+ for row in rows:
206
+ sent_at = str(row.get("sent_at") or "")
207
+ to_value = _clean(row.get("to_addrs"), 120)
208
+ subject = _clean(row.get("subject"), 160)
209
+ source = _clean(row.get("source"), 80)
210
+ lines.append(f"- {sent_at} | to: {to_value} | subject: {subject} | source: {source}")
211
+ return "\n".join(lines)
212
+
213
+
214
+ __all__ = [
215
+ "RECENT_SENT_EMAILS_TITLE",
216
+ "ensure_sent_email_table",
217
+ "find_sent_email",
218
+ "format_recent_sent_email_block",
219
+ "recent_sent_emails",
220
+ "record_sent_email",
221
+ "sent_email_db_path",
222
+ ]
@@ -230,24 +230,55 @@ conn.execute('''
230
230
  ''', (sid, decisions, pending, context_next, summary))
231
231
  conn.commit()
232
232
 
233
- # Layer 3: structured auto-flush for continuity and inspectability
233
+ conn.close()
234
+ " 2>/dev/null || true
235
+
236
+ # Layer 3: structured auto-flush is useful but non-critical. Keep it
237
+ # best-effort in the background so pre-compaction never blocks on heavy
238
+ # imports, vector backends, or ingestion latency after the emergency diary
239
+ # has already been committed.
240
+ (
241
+ NEXO_PRECOMPACT_SID="$TARGET_SID" \
242
+ NEXO_PRECOMPACT_LOG_FILE="$LOG_FILE" \
243
+ HOOK_DIR="$HOOK_DIR" \
244
+ python3 - <<'PY'
245
+ import os
246
+ import sqlite3
247
+ import sys
248
+
249
+ sid = os.environ.get("NEXO_PRECOMPACT_SID", "")
250
+ log_file = os.environ.get("NEXO_PRECOMPACT_LOG_FILE", "")
251
+ hook_dir = os.environ.get("HOOK_DIR", "")
252
+ if not sid or not hook_dir:
253
+ sys.exit(0)
254
+
255
+ sys.path.insert(0, os.path.abspath(os.path.join(hook_dir, "..")))
234
256
  try:
235
- import os
236
- sys.path.insert(0, os.path.abspath(os.path.join('$HOOK_DIR', '..')))
257
+ import paths
258
+
259
+ task = ""
260
+ try:
261
+ conn = sqlite3.connect(str(paths.db_path()), timeout=3)
262
+ row = conn.execute("SELECT task FROM sessions WHERE sid = ? LIMIT 1", (sid,)).fetchone()
263
+ task = (row[0] if row else "") or ""
264
+ conn.close()
265
+ except Exception:
266
+ task = ""
267
+
237
268
  import compaction_memory
269
+
238
270
  compaction_memory.record_auto_flush(
239
271
  session_id=sid,
240
272
  task=task,
241
- current_goal='',
273
+ current_goal="",
242
274
  log_file=log_file,
243
- last_diary_ts=last_diary_ts,
244
- source='pre-compact-hook',
275
+ last_diary_ts="",
276
+ source="pre-compact-hook",
245
277
  )
246
278
  except Exception:
247
279
  pass
248
-
249
- conn.close()
250
- " 2>/dev/null || true
280
+ PY
281
+ ) >/dev/null 2>&1 &
251
282
  fi
252
283
 
253
284
  cat << HOOKEOF
@@ -1137,19 +1137,23 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1137
1137
  return msg
1138
1138
 
1139
1139
  new_version = _read_version()
1140
- if old_version == new_version:
1141
- return f"Already up to date (v{old_version}). No changes."
1140
+ version_changed = old_version != new_version
1141
+ if not version_changed:
1142
+ _emit_progress(progress_fn, "Package version unchanged; running idempotent maintenance...")
1142
1143
 
1143
1144
  # 4. Post-npm verification steps
1144
1145
  errors = []
1145
1146
 
1146
1147
  # Reinstall pip deps for new version
1147
- _emit_progress(progress_fn, "Reconciling Python dependencies...")
1148
- pip_err = _reinstall_pip_deps()
1149
- if pip_err:
1150
- errors.append(f"pip deps: {pip_err}")
1151
-
1152
- # Run migrations
1148
+ if version_changed:
1149
+ _emit_progress(progress_fn, "Reconciling Python dependencies...")
1150
+ pip_err = _reinstall_pip_deps()
1151
+ if pip_err:
1152
+ errors.append(f"pip deps: {pip_err}")
1153
+
1154
+ # Run migrations even when npm reports the same package version. Several
1155
+ # cleanup hooks are idempotent and may need to repair an install after a
1156
+ # previous interrupted update without waiting for the next version bump.
1153
1157
  _emit_progress(progress_fn, "Running runtime migrations...")
1154
1158
  mig_err = _run_migrations()
1155
1159
  if mig_err:
@@ -1217,7 +1221,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1217
1221
  if not clients_ok:
1218
1222
  client_sync_warning = client_sync_error or "unknown client sync error"
1219
1223
 
1220
- if old_version != new_version:
1224
+ if version_changed:
1221
1225
  _emit_progress(progress_fn, "Reloading LaunchAgents after version bump...")
1222
1226
  try:
1223
1227
  launchagent_reload_summary = _reload_launch_agents_after_bump()
@@ -1235,7 +1239,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1235
1239
  mcp_code_changed = False
1236
1240
  force_restart = False
1237
1241
  new_fingerprint = ""
1238
- if old_version != new_version:
1242
+ if version_changed:
1239
1243
  try:
1240
1244
  _emit_progress(progress_fn, "Activating versioned runtime snapshot...")
1241
1245
  versioned_runtime_summary = activate_versioned_runtime_snapshot(
@@ -1308,8 +1312,13 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1308
1312
  return "\n".join(lines)
1309
1313
 
1310
1314
  lines = ["UPDATE SUCCESSFUL (packaged install)"]
1311
- lines.append(f" Version: {old_version} -> {new_version}")
1315
+ if version_changed:
1316
+ lines.append(f" Version: {old_version} -> {new_version}")
1317
+ else:
1318
+ lines.append(f" Version: {old_version} (unchanged; idempotent maintenance)")
1312
1319
  lines.append(f" Backup: {backup_dir}")
1320
+ if not version_changed:
1321
+ lines.append(" Python deps: unchanged")
1313
1322
  if not cron_sync_warning:
1314
1323
  lines.append(" Crons: synced with manifest")
1315
1324
  else:
@@ -1340,7 +1349,11 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1340
1349
  if restart_marker_summary:
1341
1350
  lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
1342
1351
  lines.append("")
1343
- if old_version != new_version and not mcp_code_changed and not force_restart:
1352
+ if not version_changed:
1353
+ lines.append(
1354
+ "Package version unchanged; maintenance completed without an MCP restart."
1355
+ )
1356
+ elif not mcp_code_changed and not force_restart:
1344
1357
  # Doc-only / blog-only / changelog-only release. The bytes the running
1345
1358
  # MCP imports are byte-identical to what's now on disk, so existing
1346
1359
  # MCP clients keep working without a forced restart.
@@ -1432,16 +1445,20 @@ def handle_update(
1432
1445
  new_req_hash = _requirements_hash()
1433
1446
  deps_changed = old_req_hash != new_req_hash
1434
1447
 
1448
+ no_changes_pulled = pull_out == "Already up to date."
1449
+
1435
1450
  # Step 5: Reinstall pip dependencies if requirements.txt changed
1436
- if deps_changed or version_changed:
1451
+ if deps_changed:
1437
1452
  _emit_progress(progress_fn, "Reconciling Python dependencies...")
1438
1453
  pip_err = _reinstall_pip_deps()
1439
1454
  if pip_err:
1440
1455
  raise RuntimeError(f"Pip install failed: {pip_err}")
1441
1456
  steps_done.append("pip-deps")
1442
1457
 
1443
- # Step 6: Run migrations if version changed
1444
- if version_changed:
1458
+ # Step 6: Run idempotent migrations/cleanup even on same-version
1459
+ # updates. This repairs interrupted installs and pending cleanup
1460
+ # without waiting for a future version bump.
1461
+ if version_changed or no_changes_pulled:
1445
1462
  _emit_progress(progress_fn, "Running runtime migrations...")
1446
1463
  mig_err = _run_migrations()
1447
1464
  if mig_err:
@@ -1607,9 +1624,17 @@ def handle_update(
1607
1624
 
1608
1625
  # Build result
1609
1626
  dep_summary_lines = _format_dep_results(dep_results)
1610
- if pull_out == "Already up to date.":
1611
- msg = f"Already up to date (v{old_version}). No changes pulled."
1627
+ if no_changes_pulled:
1628
+ msg = f"Already up to date (v{old_version}). Idempotent maintenance completed."
1612
1629
  trailing = [*dep_summary_lines, *external_cli_lines]
1630
+ if "migrations" in steps_done:
1631
+ trailing.insert(0, " Migrations: checked/applied")
1632
+ if "hook-sync" in steps_done:
1633
+ trailing.insert(1 if trailing else 0, " Hooks: synced to NEXO_HOME")
1634
+ if "cron-sync" in steps_done:
1635
+ trailing.insert(2 if len(trailing) >= 2 else len(trailing), " Crons: synced with manifest")
1636
+ if "client-sync" in steps_done:
1637
+ trailing.append(" Clients: configured client targets synced")
1613
1638
  if trailing:
1614
1639
  msg += "\n" + "\n".join(trailing)
1615
1640
  return msg
@@ -1623,7 +1648,7 @@ def handle_update(
1623
1648
  lines.append(f" Backup: {backup_dir}")
1624
1649
  if "pip-deps" in steps_done:
1625
1650
  lines.append(" Python deps: reinstalled")
1626
- if version_changed:
1651
+ if "migrations" in steps_done:
1627
1652
  lines.append(" Migrations: applied")
1628
1653
  if "cron-sync" in steps_done:
1629
1654
  lines.append(" Crons: synced with manifest")
@@ -1424,9 +1424,38 @@ def sync_personal_scripts(prune_missing: bool = True) -> dict:
1424
1424
  })
1425
1425
  result["schedule_audit"] = schedule_audit
1426
1426
  result["missing_declared_schedules"] = missing_declared
1427
+ result["marker_warnings"] = _schedule_marker_warnings(schedule_audit)
1427
1428
  return result
1428
1429
 
1429
1430
 
1431
+ def _schedule_marker_warnings(schedule_audit: dict) -> list[dict]:
1432
+ """Report LaunchAgent marker drift without silently blessing it.
1433
+
1434
+ Managed personal schedules must be both declared in inline metadata and
1435
+ carry the managed marker written by the official schedule flow.
1436
+ """
1437
+ warnings: list[dict] = []
1438
+ for record in (schedule_audit or {}).get("schedules", []) or []:
1439
+ marker = bool(record.get("managed_marker"))
1440
+ declared = bool(record.get("schedule_declared"))
1441
+ matches = bool(record.get("schedule_matches_declared"))
1442
+ if marker and not declared:
1443
+ reason = "managed marker present but no valid declared schedule"
1444
+ elif marker and declared and not matches:
1445
+ reason = "managed marker present but schedule drifts from declaration"
1446
+ elif not marker and declared:
1447
+ reason = "declared schedule discovered without managed marker"
1448
+ else:
1449
+ continue
1450
+ warnings.append({
1451
+ "cron_id": str(record.get("cron_id") or ""),
1452
+ "script_path": str(record.get("script_path") or ""),
1453
+ "plist_path": str(record.get("plist_path") or ""),
1454
+ "reason": reason,
1455
+ })
1456
+ return warnings
1457
+
1458
+
1430
1459
  def _schedule_matches(existing: dict, declared: dict) -> bool:
1431
1460
  if not existing or not declared.get("valid"):
1432
1461
  return False
@@ -1583,6 +1612,7 @@ def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
1583
1612
  "dry_run": dry_run,
1584
1613
  "renamed_legacy_filenames": renamed_result,
1585
1614
  "sync": sync_result,
1615
+ "marker_warnings": sync_result.get("marker_warnings", []),
1586
1616
  "ensure_schedules": ensure_result,
1587
1617
  "classification": ensure_result.get("classification", sync_result.get("classification", {})),
1588
1618
  }
@@ -21,6 +21,7 @@ if str(NEXO_CODE) not in sys.path:
21
21
  sys.path.insert(0, str(NEXO_CODE))
22
22
 
23
23
  import paths
24
+ from email_sent_events import find_sent_email
24
25
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
25
26
  from core_prompts import render_core_prompt
26
27
  try:
@@ -38,6 +39,9 @@ class ContextChecker:
38
39
 
39
40
  def check_email_sent(self, to_addr, subject, since_hours=72):
40
41
  """Check if email was already sent to address with subject."""
42
+ if find_sent_email(to_addr=to_addr, subject=subject, since_hours=since_hours):
43
+ return True
44
+
41
45
  sent_path = Path.home() / "mail" / ".nexo-sent" / ".Sent"
42
46
  if not sent_path.exists():
43
47
  return False
@@ -55,6 +55,7 @@ from automation_controls import (
55
55
  )
56
56
  from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
57
57
  from core_prompts import render_core_prompt
58
+ from email_sent_events import format_recent_sent_email_block, recent_sent_emails
58
59
  import db as nexo_db
59
60
  from paths import data_dir, logs_dir, operations_dir
60
61
  from runtime_home import export_resolved_nexo_home
@@ -188,6 +189,23 @@ def _serialize_diaries(*, limit: int) -> list[dict]:
188
189
  return result
189
190
 
190
191
 
192
+ def _serialize_recent_sent_emails(*, limit: int = 8) -> list[dict]:
193
+ result: list[dict] = []
194
+ try:
195
+ rows = recent_sent_emails(hours=24, limit=limit)
196
+ except Exception:
197
+ return result
198
+ for row in rows:
199
+ result.append({
200
+ "sent_at": str(row.get("sent_at") or ""),
201
+ "to": _clean_text(row.get("to_addrs"), limit=180),
202
+ "subject": _clean_text(row.get("subject"), limit=220),
203
+ "source": str(row.get("source") or ""),
204
+ "message_id": str(row.get("message_id") or ""),
205
+ })
206
+ return result
207
+
208
+
191
209
  def collect_context(profile: dict) -> dict:
192
210
  nexo_db.init_db()
193
211
  due_followups = _serialize_followups("due", limit=MAX_DUE_ITEMS)
@@ -204,6 +222,7 @@ def collect_context(profile: dict) -> dict:
204
222
  for row in _serialize_reminders("active", limit=MAX_ACTIVE_ITEMS + MAX_DUE_ITEMS)
205
223
  if row["id"] not in due_reminder_ids
206
224
  ][:MAX_ACTIVE_ITEMS]
225
+ recent_sent = _serialize_recent_sent_emails()
207
226
  return {
208
227
  "generated_at": datetime.now().astimezone().isoformat(),
209
228
  "today": date.today().isoformat(),
@@ -220,15 +239,27 @@ def collect_context(profile: dict) -> dict:
220
239
  "due_followups": due_followups,
221
240
  "active_followups": active_followups,
222
241
  "recent_diaries": _serialize_diaries(limit=MAX_DIARY_ITEMS),
242
+ "recent_sent_emails_24h": recent_sent,
223
243
  "counts": {
224
244
  "due_reminders": len(due_reminders),
225
245
  "active_reminders": len(active_reminders),
226
246
  "due_followups": len(due_followups),
227
247
  "active_followups": len(active_followups),
248
+ "recent_sent_emails_24h": len(recent_sent),
228
249
  },
229
250
  }
230
251
 
231
252
 
253
+ def append_recent_sent_email_block(body: str) -> str:
254
+ try:
255
+ block = format_recent_sent_email_block(hours=24, limit=8)
256
+ except Exception:
257
+ block = ""
258
+ if not block or "EMAILS ENVIADOS ULTIMAS 24H" in body:
259
+ return body
260
+ return body.rstrip() + "\n\n" + block + "\n"
261
+
262
+
232
263
  def build_prompt(context: dict, *, extra_instructions_block: str = "") -> str:
233
264
  operator = context.get("operator") if isinstance(context.get("operator"), dict) else {}
234
265
  assistant = context.get("assistant") if isinstance(context.get("assistant"), dict) else {}
@@ -390,6 +421,7 @@ def main(argv: list[str] | None = None) -> int:
390
421
  extra_instructions_block=format_operator_extra_instructions_block("morning-agent"),
391
422
  )
392
423
  subject, body = generate_briefing(prompt)
424
+ body = append_recent_sent_email_block(body)
393
425
  write_latest_briefing(recipient=recipient or "[dry-run]", subject=subject, body=body)
394
426
 
395
427
  if args.dry_run:
@@ -46,6 +46,7 @@ if str(_repo_src) not in sys.path:
46
46
 
47
47
  from paths import nexo_email_dir
48
48
  from runtime_home import export_resolved_nexo_home
49
+ from email_sent_events import record_sent_email
49
50
 
50
51
  NEXO_HOME = export_resolved_nexo_home()
51
52
  EMAIL_BASE_DIR = nexo_email_dir()
@@ -501,11 +502,12 @@ def main(argv=None):
501
502
  args.in_reply_to, args.references,
502
503
  attachments=args.attach
503
504
  )
505
+ sent_copy_saved = False
504
506
  try:
505
- save_to_sent(config, raw_message)
507
+ sent_copy_saved = bool(save_to_sent(config, raw_message))
506
508
  except Exception as sent_exc:
507
509
  print(f"WARN: sent copy not saved to IMAP Sent: {sent_exc}", file=sys.stderr)
508
- record_reply_lifecycle(
510
+ lifecycle_event = record_reply_lifecycle(
509
511
  args.in_reply_to,
510
512
  args.references,
511
513
  reply_body,
@@ -514,6 +516,24 @@ def main(argv=None):
514
516
  cc=args.cc,
515
517
  message_id=msg_id,
516
518
  )
519
+ try:
520
+ record_sent_email(
521
+ message_id=msg_id,
522
+ sender=str(config.get("email") or ""),
523
+ to_addrs=args.to,
524
+ cc_addrs=args.cc,
525
+ subject=args.subject,
526
+ in_reply_to=args.in_reply_to,
527
+ references_header=args.references,
528
+ source="nexo-send-reply",
529
+ meta={
530
+ "sent_copy_saved": sent_copy_saved,
531
+ "lifecycle_event": lifecycle_event,
532
+ "account_label": (args.account_label or "").strip(),
533
+ },
534
+ )
535
+ except Exception as sent_event_exc:
536
+ print(f"WARN: sent email continuity tracking failed: {sent_event_exc}", file=sys.stderr)
517
537
  print(f"OK:{msg_id}")
518
538
  except Exception as e:
519
539
  print(f"FAIL:{e}", file=sys.stderr)
@@ -19,10 +19,12 @@ Stage B — Dreaming (automation backend):
19
19
  """
20
20
 
21
21
  import fcntl
22
+ import atexit
22
23
  import json
23
24
  import os
24
25
  import re
25
26
  import shutil
27
+ import signal
26
28
  import sqlite3
27
29
  import subprocess
28
30
  import sys
@@ -73,6 +75,8 @@ PROCESS_LOCK = COORD_DIR / "sleep-process.lock"
73
75
  TODAY = date.today()
74
76
  NOW = datetime.now()
75
77
  TIMESTAMP = NOW.strftime("%Y-%m-%d %H:%M")
78
+ _PROCESS_LOCK_FD = None
79
+ _PROCESS_LOCK_CLEANED = False
76
80
 
77
81
 
78
82
  # ─── Run-once & resume logic (unchanged from v1) ──────────────────────────────
@@ -128,6 +132,42 @@ def mark_complete():
128
132
  LOCK_FILE.unlink(missing_ok=True)
129
133
 
130
134
 
135
+ def _cleanup_process_lock():
136
+ global _PROCESS_LOCK_FD, _PROCESS_LOCK_CLEANED
137
+
138
+ if _PROCESS_LOCK_CLEANED:
139
+ return
140
+ _PROCESS_LOCK_CLEANED = True
141
+ try:
142
+ if _PROCESS_LOCK_FD is not None:
143
+ fcntl.flock(_PROCESS_LOCK_FD, fcntl.LOCK_UN)
144
+ _PROCESS_LOCK_FD.close()
145
+ except Exception:
146
+ pass
147
+ finally:
148
+ _PROCESS_LOCK_FD = None
149
+ try:
150
+ PROCESS_LOCK.unlink(missing_ok=True)
151
+ except Exception:
152
+ pass
153
+
154
+
155
+ def _handle_shutdown_signal(signum, _frame):
156
+ log(f"Received shutdown signal {signum}; cleaning sleep process lock.")
157
+ _cleanup_process_lock()
158
+ raise SystemExit(128 + signum)
159
+
160
+
161
+ def _register_process_lock_cleanup(lock_fd):
162
+ global _PROCESS_LOCK_FD, _PROCESS_LOCK_CLEANED
163
+
164
+ _PROCESS_LOCK_FD = lock_fd
165
+ _PROCESS_LOCK_CLEANED = False
166
+ atexit.register(_cleanup_process_lock)
167
+ signal.signal(signal.SIGINT, _handle_shutdown_signal)
168
+ signal.signal(signal.SIGTERM, _handle_shutdown_signal)
169
+
170
+
131
171
  # ─── Helpers ──────────────────────────────────────────────────────────────────
132
172
 
133
173
  def log(msg: str):
@@ -518,6 +558,7 @@ def main():
518
558
  fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
519
559
  lock_fd.write(str(os.getpid()))
520
560
  lock_fd.flush()
561
+ _register_process_lock_cleanup(lock_fd)
521
562
  except (IOError, OSError):
522
563
  log("Another sleep instance running. Exiting.")
523
564
  sys.exit(0)
@@ -587,12 +628,7 @@ def main():
587
628
  pass
588
629
 
589
630
  finally:
590
- try:
591
- fcntl.flock(lock_fd, fcntl.LOCK_UN)
592
- lock_fd.close()
593
- PROCESS_LOCK.unlink(missing_ok=True)
594
- except Exception:
595
- pass
631
+ _cleanup_process_lock()
596
632
 
597
633
 
598
634
  if __name__ == "__main__":
@@ -1073,6 +1073,13 @@ def handle_smart_startup_query() -> str:
1073
1073
  from db import get_db
1074
1074
  conn = get_db()
1075
1075
  query_parts = []
1076
+ sent_email_block = ""
1077
+ try:
1078
+ from email_sent_events import format_recent_sent_email_block
1079
+
1080
+ sent_email_block = format_recent_sent_email_block(hours=24, limit=8)
1081
+ except Exception:
1082
+ sent_email_block = ""
1076
1083
 
1077
1084
  # 1. Pending followups (what NEXO needs to do)
1078
1085
  followups = conn.execute(
@@ -1099,6 +1106,8 @@ def handle_smart_startup_query() -> str:
1099
1106
  pass
1100
1107
 
1101
1108
  if not query_parts:
1109
+ if sent_email_block:
1110
+ return sent_email_block
1102
1111
  return "No pending context to pre-load."
1103
1112
 
1104
1113
  # Search per-part to avoid diffuse centroid that matches everything
@@ -1123,6 +1132,8 @@ def handle_smart_startup_query() -> str:
1123
1132
  results = sorted(all_results, key=lambda x: x["score"], reverse=True)[:10]
1124
1133
  composite_query = " | ".join(query_parts[:6])
1125
1134
  if not results:
1135
+ if sent_email_block:
1136
+ return "Smart startup query: no relevant memories found.\n\n" + sent_email_block
1126
1137
  return "Smart startup query: no relevant memories found."
1127
1138
 
1128
1139
  lines = [f"SMART STARTUP — {len(results)} memories pre-loaded from composite query:"]
@@ -1138,6 +1149,10 @@ def handle_smart_startup_query() -> str:
1138
1149
  except Exception:
1139
1150
  pass
1140
1151
 
1152
+ if sent_email_block:
1153
+ lines.append("")
1154
+ lines.append(sent_email_block)
1155
+
1141
1156
  # Session tone from Deep Sleep (emotional intelligence layer)
1142
1157
  tone = _load_session_tone()
1143
1158
  if tone: