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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/nexo-brain.js +76 -5
- package/package.json +1 -1
- package/src/auto_update.py +54 -6
- package/src/cli.py +35 -1
- package/src/client_sync.py +34 -6
- package/src/dashboard/app.py +81 -12
- package/src/desktop_bridge.py +10 -4
- package/src/email_sent_events.py +222 -0
- package/src/hooks/pre-compact.sh +40 -9
- package/src/plugins/update.py +43 -18
- package/src/script_registry.py +30 -0
- package/src/scripts/check-context.py +4 -0
- package/src/scripts/nexo-morning-agent.py +32 -0
- package/src/scripts/nexo-send-reply.py +22 -2
- package/src/scripts/nexo-sleep.py +42 -6
- package/src/tools_sessions.py +15 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.12.
|
|
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.
|
|
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
|
-
|
|
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
|
|
3444
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -1233,11 +1233,9 @@ def _reload_launch_agents_after_bump() -> dict:
|
|
|
1233
1233
|
"errors": [{plist, stderr}],
|
|
1234
1234
|
}
|
|
1235
1235
|
|
|
1236
|
-
Linux
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
#
|
|
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
|
|
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"
|
package/src/client_sync.py
CHANGED
|
@@ -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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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)
|
package/src/dashboard/app.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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=
|
|
2032
|
+
uvicorn.run(app, host=bind_host, port=args.port, log_level="info")
|
|
1964
2033
|
|
|
1965
2034
|
|
|
1966
2035
|
if __name__ == "__main__":
|
package/src/desktop_bridge.py
CHANGED
|
@@ -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": "
|
|
409
|
-
"en": "
|
|
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
|
-
|
|
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,
|
|
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
|
+
]
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -230,24 +230,55 @@ conn.execute('''
|
|
|
230
230
|
''', (sid, decisions, pending, context_next, summary))
|
|
231
231
|
conn.commit()
|
|
232
232
|
|
|
233
|
-
|
|
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
|
|
236
|
-
|
|
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=
|
|
244
|
-
source=
|
|
275
|
+
last_diary_ts="",
|
|
276
|
+
source="pre-compact-hook",
|
|
245
277
|
)
|
|
246
278
|
except Exception:
|
|
247
279
|
pass
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
" 2>/dev/null || true
|
|
280
|
+
PY
|
|
281
|
+
) >/dev/null 2>&1 &
|
|
251
282
|
fi
|
|
252
283
|
|
|
253
284
|
cat << HOOKEOF
|
package/src/plugins/update.py
CHANGED
|
@@ -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
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1444
|
-
|
|
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
|
|
1611
|
-
msg = f"Already up to date (v{old_version}).
|
|
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
|
|
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")
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
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__":
|
package/src/tools_sessions.py
CHANGED
|
@@ -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:
|