nexo-brain 7.12.2 → 7.12.3

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.2",
3
+ "version": "7.12.3",
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/bin/nexo-brain.js CHANGED
@@ -111,6 +111,10 @@ const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _MODEL_DEFAULTS.claude_code.reasoni
111
111
  const DEFAULT_CODEX_MODEL = _MODEL_DEFAULTS.codex.model;
112
112
  const DEFAULT_CODEX_REASONING_EFFORT = _MODEL_DEFAULTS.codex.reasoning_effort || "";
113
113
 
114
+ function isDesktopManagedInstall() {
115
+ return String(process.env.NEXO_DESKTOP_MANAGED || "").trim() === "1";
116
+ }
117
+
114
118
  // v6.0.0 — Hook manifest is the single source of truth for which hook
115
119
  // handlers get registered. Both plugin mode (hooks/hooks.json) and npm
116
120
  // mode (this installer's registerAllCoreHooks) read from the same file.
@@ -223,6 +227,62 @@ function run(cmd, opts = {}) {
223
227
 
224
228
  function log(msg) {
225
229
  console.log(` ${msg}`);
230
+ try {
231
+ const logPath = path.join(
232
+ NEXO_HOME,
233
+ "runtime",
234
+ "bootstrap",
235
+ "logs",
236
+ "brain-install.log"
237
+ );
238
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
239
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${msg}\n`);
240
+ } catch {}
241
+ }
242
+
243
+ let _stagedRuntimeBundleRoot = null;
244
+ let _stagedRuntimeCleanup = null;
245
+
246
+ function getRuntimeBundleRoot() {
247
+ if (_stagedRuntimeBundleRoot) {
248
+ return _stagedRuntimeBundleRoot;
249
+ }
250
+ const bundleRoot = path.resolve(__dirname, "..");
251
+ if (process.platform !== "linux") {
252
+ return bundleRoot;
253
+ }
254
+ const normalizedRoot = bundleRoot.replace(/\\/g, "/");
255
+ if (!normalizedRoot.startsWith("/mnt/")) {
256
+ return bundleRoot;
257
+ }
258
+
259
+ const tmpRoot = fs.mkdtempSync(path.join(require("os").tmpdir(), "nexo-brain-stage-"));
260
+ const stagedRoot = path.join(tmpRoot, path.basename(bundleRoot));
261
+ fs.mkdirSync(stagedRoot, { recursive: true });
262
+
263
+ log("Staging bundled runtime into local Linux storage...");
264
+ const tarCmd =
265
+ `tar --exclude=__pycache__ --exclude=node_modules --exclude='*.pyc' ` +
266
+ `--exclude='*.db' --exclude=.git ` +
267
+ `-C ${JSON.stringify(bundleRoot)} -cf - . | ` +
268
+ `tar -C ${JSON.stringify(stagedRoot)} -xf -`;
269
+ const stageResult = spawnSync("/bin/sh", ["-c", tarCmd], {
270
+ encoding: "utf8",
271
+ stdio: ["ignore", "pipe", "pipe"],
272
+ timeout: 10 * 60 * 1000,
273
+ });
274
+ if (stageResult.status !== 0) {
275
+ const detail = (stageResult.stderr || stageResult.stdout || "").trim() || `exit ${stageResult.status}`;
276
+ throw new Error(`Failed to stage bundled runtime from Windows storage: ${detail}`);
277
+ }
278
+ log(` Runtime bundle staged at ${stagedRoot}`);
279
+ _stagedRuntimeBundleRoot = stagedRoot;
280
+ _stagedRuntimeCleanup = () => {
281
+ try {
282
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
283
+ } catch {}
284
+ };
285
+ return _stagedRuntimeBundleRoot;
226
286
  }
227
287
 
228
288
  function readPackageJson() {
@@ -500,6 +560,15 @@ function runMandatoryModelWarmup(pythonPath, nexoHome = NEXO_HOME, { reason = "i
500
560
  log("Local model warmup complete.");
501
561
  }
502
562
 
563
+ function runDesktopAwareModelWarmup(pythonPath, nexoHome = NEXO_HOME, options = {}) {
564
+ const reason = String((options && options.reason) || "install");
565
+ if (isDesktopManagedInstall()) {
566
+ log(`Desktop-managed runtime detected — local model warmup deferred during ${reason}.`);
567
+ return;
568
+ }
569
+ runMandatoryModelWarmup(pythonPath, nexoHome, options);
570
+ }
571
+
503
572
  async function runWarmupModelsCommand(args) {
504
573
  const dryRun = args.includes("--dry-run");
505
574
  const json = args.includes("--json");
@@ -729,6 +798,10 @@ function resolveRuntimeSchedulePath(nexoHome) {
729
798
 
730
799
  function finalizeF06Layout(python, nexoHome = NEXO_HOME) {
731
800
  try {
801
+ // auto_update lives in NEXO_CODE (e.g. ~/.nexo/core or ~/.nexo/core/current),
802
+ // not directly in NEXO_HOME, so PYTHONPATH must point at the runtime code
803
+ // directory or `import auto_update` fails with ModuleNotFoundError.
804
+ const codeDir = runtimeCodeDir(nexoHome);
732
805
  const result = spawnSync(
733
806
  python,
734
807
  [
@@ -744,8 +817,8 @@ function finalizeF06Layout(python, nexoHome = NEXO_HOME) {
744
817
  env: {
745
818
  ...process.env,
746
819
  NEXO_HOME: nexoHome,
747
- NEXO_CODE: runtimeCodeDir(nexoHome),
748
- PYTHONPATH: nexoHome,
820
+ NEXO_CODE: codeDir,
821
+ PYTHONPATH: codeDir,
749
822
  },
750
823
  encoding: "utf8",
751
824
  },
@@ -1451,7 +1524,7 @@ function getDefaultSchedule(timezone) {
1451
1524
  }
1452
1525
 
1453
1526
  function writeDesktopProductMode(nexoHome) {
1454
- if (String(process.env.NEXO_DESKTOP_MANAGED || "").trim() !== "1") return;
1527
+ if (!isDesktopManagedInstall()) return;
1455
1528
  const configDir = resolveRuntimeConfigDir(nexoHome);
1456
1529
  fs.mkdirSync(configDir, { recursive: true });
1457
1530
  const target = path.join(configDir, "product-mode.json");
@@ -1478,7 +1551,7 @@ function ensureEvolutionObjectiveForCurrentProductMode(nexoHome) {
1478
1551
  const brainDir = resolveRuntimeBrainDir(nexoHome);
1479
1552
  fs.mkdirSync(brainDir, { recursive: true });
1480
1553
  const evoObjectivePath = path.join(brainDir, "evolution-objective.json");
1481
- const desktopManaged = String(process.env.NEXO_DESKTOP_MANAGED || "").trim() === "1";
1554
+ const desktopManaged = isDesktopManagedInstall();
1482
1555
  let payload = null;
1483
1556
  if (fs.existsSync(evoObjectivePath)) {
1484
1557
  try {
@@ -1801,6 +1874,29 @@ function installClaudeCodeCli(platform) {
1801
1874
  const desktopNode = String(process.env.NEXO_DESKTOP_NODE || "").trim();
1802
1875
  const bundledNpmCli = String(process.env.NEXO_DESKTOP_NPM_CLI || "").trim();
1803
1876
  const managedPrefix = managedClaudePrefix();
1877
+ const desktopManaged = isDesktopManagedInstall();
1878
+
1879
+ // OFFLINE-FIRST: detect bundled claude-code .tgz and install from it.
1880
+ // Path: resources/brain-bundle/claude-code/claude-code-X.Y.Z.tgz (relative
1881
+ // to bin/, so __dirname/../claude-code/). Falls back to PyPI/npm if absent.
1882
+ const bundledClaudeDir = path.join(__dirname, "..", "claude-code");
1883
+ if (fs.existsSync(bundledClaudeDir)) {
1884
+ const tgzFiles = fs.readdirSync(bundledClaudeDir).filter((f) => f.endsWith(".tgz"));
1885
+ if (tgzFiles.length > 0) {
1886
+ const tgzPath = path.join(bundledClaudeDir, tgzFiles[0]);
1887
+ log(" Installing claude-code from bundled tarball (offline)...");
1888
+ spawnSync(
1889
+ "npm",
1890
+ ["install", "-g", "--prefix", managedPrefix, tgzPath],
1891
+ { stdio: "inherit", env: installEnv },
1892
+ );
1893
+ claudeInstalled = detectInstalledClients().claude_code.path || "";
1894
+ if (claudeInstalled) {
1895
+ persistClaudeCliPath(claudeInstalled);
1896
+ return { installed: true, path: claudeInstalled };
1897
+ }
1898
+ }
1899
+ }
1804
1900
 
1805
1901
  if (desktopNode && bundledNpmCli) {
1806
1902
  spawnSync(
@@ -1818,6 +1914,23 @@ function installClaudeCodeCli(platform) {
1818
1914
  }
1819
1915
  }
1820
1916
 
1917
+ if (desktopManaged) {
1918
+ spawnSync(
1919
+ "npm",
1920
+ ["install", "-g", "--prefix", managedPrefix, "@anthropic-ai/claude-code"],
1921
+ {
1922
+ stdio: "inherit",
1923
+ env: installEnv,
1924
+ },
1925
+ );
1926
+ claudeInstalled = detectInstalledClients().claude_code.path || "";
1927
+ if (claudeInstalled) {
1928
+ persistClaudeCliPath(claudeInstalled);
1929
+ return { installed: true, path: claudeInstalled };
1930
+ }
1931
+ return { installed: false, path: "" };
1932
+ }
1933
+
1821
1934
  spawnSync("npx", ["-y", "@anthropic-ai/claude-code", "--version"], {
1822
1935
  stdio: "pipe",
1823
1936
  timeout: 60000,
@@ -1847,6 +1960,7 @@ function installCodexCli() {
1847
1960
  async function configureClientSetup({ lang, useDefaults, autoInstall, detected }) {
1848
1961
  const strings = clientSetupStrings(lang);
1849
1962
  const setup = defaultClientSetup(detected);
1963
+ const desktopManaged = String(process.env.NEXO_DESKTOP_MANAGED || "").trim() === "1";
1850
1964
  setup.client_install_preferences = {
1851
1965
  claude_code: autoInstall === "auto" ? "auto" : "ask",
1852
1966
  codex: autoInstall === "auto" ? "auto" : "ask",
@@ -1898,6 +2012,10 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
1898
2012
  const required = requiredCliClients(setup);
1899
2013
  for (const client of required) {
1900
2014
  if (detected[client] && detected[client].installed) continue;
2015
+ if (desktopManaged && client === "claude_code") {
2016
+ log("Claude Code install deferred to Desktop final sync.");
2017
+ continue;
2018
+ }
1901
2019
  let shouldInstall = useDefaults || autoInstall === "auto";
1902
2020
  if (!shouldInstall && process.stdin.isTTY && process.stdout.isTTY) {
1903
2021
  const question = client === "claude_code" ? strings.installClaudeQ : strings.installCodexQ;
@@ -1926,6 +2044,10 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
1926
2044
  }
1927
2045
 
1928
2046
  if (setup.automation_enabled && setup.automation_backend !== "none" && !detected[setup.automation_backend]?.installed) {
2047
+ if (desktopManaged && setup.automation_backend === "claude_code") {
2048
+ log("Claude Code will be provisioned by Desktop after the core runtime is ready.");
2049
+ return { setup, detected };
2050
+ }
1929
2051
  const label = setup.automation_backend === "claude_code" ? "Claude Code" : "Codex";
1930
2052
  log(strings.automationDisabled(label));
1931
2053
  setup.automation_enabled = false;
@@ -2448,6 +2570,15 @@ async function runSetup() {
2448
2570
  process.exit(1);
2449
2571
  }
2450
2572
 
2573
+ const bundleRoot = getRuntimeBundleRoot();
2574
+ const bundleSrcDir = path.join(bundleRoot, "src");
2575
+ const bundleTemplatesDir = path.join(bundleRoot, "templates");
2576
+ process.on("exit", () => {
2577
+ if (_stagedRuntimeCleanup) {
2578
+ _stagedRuntimeCleanup();
2579
+ }
2580
+ });
2581
+
2451
2582
  const onboardingMigration = ensureOnboardingCompletionMarker(NEXO_HOME);
2452
2583
  if (onboardingMigration.changed) {
2453
2584
  log("Migrated legacy calibration completion marker.");
@@ -2479,7 +2610,7 @@ async function runSetup() {
2479
2610
  }
2480
2611
 
2481
2612
  // Recursive copy helper (skips __pycache__, .pyc, .db files)
2482
- const srcDir = path.join(__dirname, "..", "src");
2613
+ const srcDir = bundleSrcDir;
2483
2614
  const copyDirRec = (src, dest) => {
2484
2615
  fs.mkdirSync(dest, { recursive: true });
2485
2616
  fs.readdirSync(src).forEach(item => {
@@ -2552,7 +2683,7 @@ async function runSetup() {
2552
2683
  }
2553
2684
 
2554
2685
  const migPythonForWarmup = findVenvPython(NEXO_HOME) || "python3";
2555
- runMandatoryModelWarmup(migPythonForWarmup, NEXO_HOME, { reason: "update", installRuntimeDeps: false });
2686
+ runDesktopAwareModelWarmup(migPythonForWarmup, NEXO_HOME, { reason: "update", installRuntimeDeps: false });
2556
2687
 
2557
2688
  // Update plugins (all .py files in plugins/)
2558
2689
  const pluginsSrc = path.join(srcDir, "plugins");
@@ -2607,7 +2738,7 @@ async function runSetup() {
2607
2738
  // hand-edited template under ~/.nexo/templates/ is replaced on
2608
2739
  // upgrade. Keep local forks under personal/ or outside the runtime
2609
2740
  // home to avoid silent loss.
2610
- const migTemplatesSrc = path.join(__dirname, "..", "templates");
2741
+ const migTemplatesSrc = bundleTemplatesDir;
2611
2742
  const migTemplatesDest = path.join(NEXO_HOME, "templates");
2612
2743
  if (fs.existsSync(migTemplatesSrc)) {
2613
2744
  copyDirRec(migTemplatesSrc, migTemplatesDest);
@@ -2656,7 +2787,7 @@ async function runSetup() {
2656
2787
  ? { runtime_repaired_from: activeRuntimeVersion }
2657
2788
  : {}),
2658
2789
  }, null, 2));
2659
- syncRuntimePackageMetadata(path.join(__dirname, ".."), NEXO_HOME);
2790
+ syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
2660
2791
  log("Finalizing F0.6 runtime layout...");
2661
2792
  const migLayoutFinalize = finalizeF06Layout(migPython, NEXO_HOME);
2662
2793
  if (!migLayoutFinalize.ok) {
@@ -2670,7 +2801,7 @@ async function runSetup() {
2670
2801
 
2671
2802
  // Keep the rendered template in-memory for version tracking, but do
2672
2803
  // not drop a loose reference file in NEXO_HOME root.
2673
- const templateSrc = path.join(__dirname, "..", "templates", "CLAUDE.md.template");
2804
+ const templateSrc = path.join(bundleTemplatesDir, "CLAUDE.md.template");
2674
2805
  if (fs.existsSync(templateSrc)) {
2675
2806
  const operatorName = installed.operator_name || DEFAULT_ASSISTANT_NAME;
2676
2807
  let claudeMd = fs.readFileSync(templateSrc, "utf8")
@@ -2742,7 +2873,7 @@ async function runSetup() {
2742
2873
  // Same version — backfill crons/ if missing (for installs before crons was shipped)
2743
2874
  const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
2744
2875
  const cronsDest = resolveRuntimeCronsDir(NEXO_HOME);
2745
- const cronsSrc = path.join(__dirname, "..", "src", "crons");
2876
+ const cronsSrc = path.join(bundleSrcDir, "crons");
2746
2877
  if (fs.existsSync(cronsSrc)) {
2747
2878
  const copyDirRec2 = (src, dest) => {
2748
2879
  fs.mkdirSync(dest, { recursive: true });
@@ -2767,7 +2898,7 @@ async function runSetup() {
2767
2898
 
2768
2899
  // Same version — refresh packaged core skills/templates/runtime helpers too.
2769
2900
  const skillsCoreDest = path.join(NEXO_HOME, "core", "skills");
2770
- const skillsCoreSrc = path.join(__dirname, "..", "src", "skills");
2901
+ const skillsCoreSrc = path.join(bundleSrcDir, "skills");
2771
2902
  if (fs.existsSync(skillsCoreSrc)) {
2772
2903
  const copyDirRec3 = (src, dest) => {
2773
2904
  fs.mkdirSync(dest, { recursive: true });
@@ -2784,16 +2915,16 @@ async function runSetup() {
2784
2915
  }
2785
2916
 
2786
2917
  ["skills_runtime.py"].forEach((fname) => {
2787
- const srcFile = path.join(__dirname, "..", "src", fname);
2918
+ const srcFile = path.join(bundleSrcDir, fname);
2788
2919
  const destFile = path.join(NEXO_HOME, "core", fname);
2789
2920
  if (fs.existsSync(srcFile)) {
2790
2921
  fs.mkdirSync(path.dirname(destFile), { recursive: true });
2791
2922
  fs.copyFileSync(srcFile, destFile);
2792
2923
  }
2793
2924
  });
2794
- syncRuntimePackageMetadata(path.join(__dirname, ".."), NEXO_HOME);
2925
+ syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
2795
2926
 
2796
- const templatesSrc = path.join(__dirname, "..", "templates");
2927
+ const templatesSrc = bundleTemplatesDir;
2797
2928
  const templatesDest = path.join(NEXO_HOME, "templates");
2798
2929
  if (fs.existsSync(templatesSrc)) {
2799
2930
  fs.mkdirSync(templatesDest, { recursive: true });
@@ -2821,7 +2952,7 @@ async function runSetup() {
2821
2952
  throw new Error(`F0.6 layout finalization failed: ${syncLayoutFinalize.error}`);
2822
2953
  }
2823
2954
 
2824
- runMandatoryModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
2955
+ runDesktopAwareModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
2825
2956
  logMacPermissionsNotice(NEXO_HOME, syncPython);
2826
2957
 
2827
2958
  log(`Already at v${currentVersion}. No migration needed.`);
@@ -3316,11 +3447,22 @@ async function runSetup() {
3316
3447
  // Use venv python if available, otherwise fall back to system python with --break-system-packages
3317
3448
  const pipPython = fs.existsSync(venvPython) ? venvPython : python;
3318
3449
  const requirementsFile = path.join(__dirname, "..", "src", "requirements.txt");
3319
- const pipArgs = ["-m", "pip", "install", "--quiet", "-r", requirementsFile];
3450
+ // Detect bundled wheels in resources/python-wheels (offline-first). If
3451
+ // present, pip uses --no-index --find-links to install without internet.
3452
+ // Falls back to PyPI if bundle not found.
3453
+ const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
3454
+ const useBundle = fs.existsSync(bundledWheelsDir);
3455
+ const pipArgs = useBundle
3456
+ ? ["-m", "pip", "install", "--no-index", "--find-links", bundledWheelsDir, "--progress-bar", "off", "-r", requirementsFile]
3457
+ : ["-m", "pip", "install", "-v", "--progress-bar", "off", "--default-timeout=60", "-r", requirementsFile];
3320
3458
  if (!fs.existsSync(venvPython)) {
3321
- pipArgs.push("--break-system-packages"); // Fallback for systems without venv
3459
+ pipArgs.push("--break-system-packages");
3460
+ }
3461
+ if (useBundle) {
3462
+ log(" Installing Python deps from bundled wheels (offline)...");
3463
+ } else {
3464
+ log(" Installing Python deps from PyPI (online)...");
3322
3465
  }
3323
-
3324
3466
  const pipInstall = spawnSync(pipPython, pipArgs, { stdio: "inherit" });
3325
3467
  if (pipInstall.status !== 0) {
3326
3468
  log("Failed to install Python dependencies.");
@@ -3332,7 +3474,54 @@ async function runSetup() {
3332
3474
  python = venvPython;
3333
3475
  }
3334
3476
  log("Dependencies installed.");
3335
- runMandatoryModelWarmup(python, NEXO_HOME, { reason: "install", installRuntimeDeps: false });
3477
+
3478
+ // OFFLINE-FIRST: copy bundled LLM models to runtime/models BEFORE warmup,
3479
+ // so fastembed finds them locally and skips the ~217MB HuggingFace download.
3480
+ // Bundle layout: resources/brain-bundle/models/<source-repo-name>/<all files>.
3481
+ // Target layout: <NEXO_HOME>/runtime/models/<spec.name slugified>/<revision>/<files>.
3482
+ // We map by source_repo basename to match local_model_manifest.json.
3483
+ const bundledModelsDir = path.join(__dirname, "..", "models");
3484
+ if (fs.existsSync(bundledModelsDir)) {
3485
+ try {
3486
+ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "src", "local_model_manifest.json"), "utf8"));
3487
+ const runtimeModelsDir = path.join(NEXO_HOME, "runtime", "models");
3488
+ let modelsCopied = 0;
3489
+ for (const spec of manifest.models || []) {
3490
+ // Bundle layout supports either model_id basename (e.g.
3491
+ // "bge-base-en-v1.5" from "BAAI/bge-base-en-v1.5") or source_repo
3492
+ // basename (e.g. "bge-base-en-v1.5-onnx-q" from "qdrant/...").
3493
+ const modelIdName = (spec.model_id || "").split("/").pop();
3494
+ const sourceRepoName = (spec.source_repo || "").split("/").pop();
3495
+ let sourceDir = path.join(bundledModelsDir, modelIdName);
3496
+ if (!fs.existsSync(sourceDir)) {
3497
+ sourceDir = path.join(bundledModelsDir, sourceRepoName);
3498
+ }
3499
+ if (!fs.existsSync(sourceDir)) continue;
3500
+ const slug = (spec.name || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3501
+ const targetDir = path.join(runtimeModelsDir, slug, spec.revision);
3502
+ fs.mkdirSync(targetDir, { recursive: true });
3503
+ for (const f of (spec.required_files || [])) {
3504
+ const src = path.join(sourceDir, f.path);
3505
+ const dst = path.join(targetDir, f.path);
3506
+ if (fs.existsSync(src) && !fs.existsSync(dst)) {
3507
+ fs.copyFileSync(src, dst);
3508
+ }
3509
+ }
3510
+ // Write the lock file to match revision (avoids re-download).
3511
+ fs.writeFileSync(path.join(targetDir, ".nexo-model-lock.json"), JSON.stringify({
3512
+ name: spec.name, kind: spec.kind, model_id: spec.model_id,
3513
+ source_repo: spec.source_repo, revision: spec.revision, model_file: spec.model_file,
3514
+ required_files: spec.required_files,
3515
+ }, null, 2));
3516
+ modelsCopied++;
3517
+ }
3518
+ if (modelsCopied > 0) log(` Copied ${modelsCopied} pre-bundled LLM model(s) (offline).`);
3519
+ } catch (err) {
3520
+ log(` WARN: bundled models copy failed: ${err.message}`);
3521
+ }
3522
+ }
3523
+
3524
+ runDesktopAwareModelWarmup(python, NEXO_HOME, { reason: "install", installRuntimeDeps: false });
3336
3525
 
3337
3526
  // Step 4: Create ~/.nexo/
3338
3527
  log("Setting up NEXO home...");
@@ -3381,15 +3570,15 @@ async function runSetup() {
3381
3570
  files_updated: 0,
3382
3571
  }, null, 2)
3383
3572
  );
3384
- syncRuntimePackageMetadata(path.join(__dirname, ".."), NEXO_HOME);
3573
+ syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
3385
3574
 
3386
3575
  // Copy source files
3387
3576
  log("Copying core runtime files...");
3388
- const srcDir = path.join(__dirname, "..", "src");
3577
+ const srcDir = bundleSrcDir;
3389
3578
  const pluginsSrcDir = path.join(srcDir, "plugins");
3390
3579
  const scriptsSrcDir = path.join(srcDir, "scripts");
3391
3580
  const skillsSrcDir = path.join(srcDir, "skills");
3392
- const templateDir = path.join(__dirname, "..", "templates");
3581
+ const templateDir = bundleTemplatesDir;
3393
3582
 
3394
3583
  // Recursive copy helper (skips __pycache__, .pyc, .db files)
3395
3584
  const copyDirRecursive = (src, dest) => {
@@ -3407,7 +3596,7 @@ async function runSetup() {
3407
3596
  };
3408
3597
 
3409
3598
  // Core flat files (single .py files in src/)
3410
- const coreFiles = getCoreRuntimeFlatFiles();
3599
+ const coreFiles = getCoreRuntimeFlatFiles(srcDir);
3411
3600
  coreFiles.forEach((f) => {
3412
3601
  const src = path.join(srcDir, f);
3413
3602
  if (fs.existsSync(src)) {
@@ -4429,6 +4618,10 @@ See ~/.nexo/ for configuration.
4429
4618
  console.log(` \u255A${"═".repeat(bw - 2)}\u255D`);
4430
4619
  console.log("");
4431
4620
 
4621
+ if (_stagedRuntimeCleanup) {
4622
+ _stagedRuntimeCleanup();
4623
+ _stagedRuntimeCleanup = null;
4624
+ }
4432
4625
  closeReadline();
4433
4626
  }
4434
4627
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.2",
3
+ "version": "7.12.3",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
package/src/cli.py CHANGED
@@ -2518,6 +2518,7 @@ def _skills_compose(args):
2518
2518
  def _uninstall(args):
2519
2519
  """Stop all crons, remove MCP config and hooks, preserve user data."""
2520
2520
  from pathlib import Path
2521
+ from windows_runtime import cleanup_windows_host_artifacts, running_inside_wsl, running_from_windows_host
2521
2522
 
2522
2523
  nexo_home = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo"))
2523
2524
  dry_run = args.dry_run
@@ -2566,6 +2567,20 @@ def _uninstall(args):
2566
2567
  if not dry_run:
2567
2568
  subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
2568
2569
 
2570
+ if running_inside_wsl() or running_from_windows_host():
2571
+ host_cleanup = cleanup_windows_host_artifacts(
2572
+ delete_data=delete_data,
2573
+ dry_run=dry_run,
2574
+ )
2575
+ for action in host_cleanup.get("actions", []):
2576
+ log_action(
2577
+ action.get("category", "remove-win-host-artifact"),
2578
+ action.get("detail", ""),
2579
+ action.get("path", ""),
2580
+ )
2581
+ for error in host_cleanup.get("errors", []):
2582
+ errors.append(f"Windows host cleanup: {error}")
2583
+
2569
2584
  # ── 2. Remove MCP server and hooks from Claude Code settings ──
2570
2585
  claude_settings = Path.home() / ".claude" / "settings.json"
2571
2586
  if claude_settings.exists():
@@ -25,7 +25,7 @@ import time
25
25
  from pathlib import Path
26
26
  from typing import Any
27
27
 
28
- from windows_runtime import running_inside_wsl, windows_runtime_status
28
+ from windows_runtime import query_windows_host_tasks, running_inside_wsl, windows_runtime_status
29
29
 
30
30
 
31
31
  def _nexo_home() -> Path:
@@ -83,17 +83,53 @@ def _check_database() -> dict:
83
83
 
84
84
  def _check_crons() -> dict:
85
85
  out: dict[str, Any] = {}
86
- # macOS LaunchAgents
87
- agents_dir = Path.home() / "Library" / "LaunchAgents"
88
- if agents_dir.is_dir():
86
+ system = platform.system()
87
+ release = platform.release()
88
+
89
+ if system == "Darwin":
90
+ agents_dir = Path.home() / "Library" / "LaunchAgents"
91
+ out["platform"] = "macos"
92
+ if agents_dir.is_dir():
93
+ try:
94
+ plists = [p for p in agents_dir.glob("com.nexo.*.plist")]
95
+ out["launch_agents"] = len(plists)
96
+ except Exception as exc:
97
+ out["error"] = str(exc)
98
+ out["status"] = "degraded"
99
+ return out
100
+ out["status"] = "ok"
101
+ return out
102
+
103
+ if system == "Linux":
104
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
105
+ inside_wsl = running_inside_wsl(system=system, release=release)
106
+ out["platform"] = "wsl" if inside_wsl else "linux"
107
+ out["inside_wsl"] = inside_wsl
89
108
  try:
90
- plists = [p for p in agents_dir.glob("com.nexo.*.plist")]
91
- out["launch_agents"] = len(plists)
92
- out["platform"] = "macos"
109
+ services = sorted(unit_dir.glob("nexo-*.service")) if unit_dir.is_dir() else []
110
+ timers = sorted(unit_dir.glob("nexo-*.timer")) if unit_dir.is_dir() else []
111
+ out["systemd_services"] = len(services)
112
+ out["systemd_timers"] = len(timers)
93
113
  except Exception as exc:
94
114
  out["error"] = str(exc)
95
- else:
96
- out["platform"] = "unknown"
115
+ out["status"] = "degraded"
116
+ return out
117
+ if inside_wsl:
118
+ out["windows_host_tasks"] = query_windows_host_tasks()
119
+ out["status"] = "ok" if (out.get("systemd_services", 0) or out.get("systemd_timers", 0)) else "degraded"
120
+ if out["status"] == "degraded":
121
+ out["reason"] = "no_nexo_systemd_units_detected"
122
+ return out
123
+
124
+ if system == "Windows":
125
+ out["platform"] = "windows"
126
+ out["windows_host_tasks"] = query_windows_host_tasks()
127
+ out["status"] = "ok" if out["windows_host_tasks"].get("available") else "degraded"
128
+ if out["status"] == "degraded":
129
+ out["reason"] = "windows_task_scheduler_unavailable"
130
+ return out
131
+
132
+ out["platform"] = "unknown"
97
133
  out["status"] = "ok"
98
134
  return out
99
135
 
@@ -12,8 +12,11 @@ tomli; python_version < "3.11"
12
12
  anthropic>=0.80.0
13
13
  openai>=2.20.0
14
14
 
15
- # Embedding model (optional but recommended for cognitive features)
16
- fastembed
15
+ # Embedding model (optional but recommended for cognitive features).
16
+ # Pin >=0.8.0: older releases require Python <3.12 and pip iterates each
17
+ # obsolete version for ~10 min on Ubuntu 24.04 (Python 3.12) before finding
18
+ # a compatible one. Verified empirically during Win11 clean install bootstrap.
19
+ fastembed>=0.8.0
17
20
 
18
21
  # Dashboard (optional, only needed for `python -m dashboard.app`)
19
22
  fastapi
@@ -20,7 +20,7 @@ import paths
20
20
  from doctor.formatters import format_report
21
21
  from doctor.orchestrator import run_doctor
22
22
  from health_check import collect as collect_health
23
- from windows_runtime import running_inside_wsl, windows_runtime_status
23
+ from windows_runtime import query_windows_host_tasks, running_inside_wsl, windows_runtime_status
24
24
 
25
25
 
26
26
  def _nexo_home() -> Path:
@@ -125,6 +125,9 @@ def collect_snapshot(*, log_lines: int = 80, include_doctor: bool = False) -> di
125
125
  "is_wsl": running_inside_wsl(system=system, release=release),
126
126
  },
127
127
  "windows_runtime": windows_runtime_status(_nexo_home(), system=system, release=release),
128
+ "windows_host": {
129
+ "tasks": query_windows_host_tasks(),
130
+ },
128
131
  "paths": _path_status(),
129
132
  "health": collect_health(),
130
133
  "logs": _recent_logs(log_lines),
@@ -1,8 +1,13 @@
1
1
  """Helpers for Windows/WSL runtime diagnostics."""
2
2
  from __future__ import annotations
3
3
 
4
+ import csv
5
+ import io
6
+ import json
4
7
  import os
5
8
  import platform
9
+ import shutil
10
+ import subprocess
6
11
  from pathlib import Path
7
12
  from typing import Any
8
13
 
@@ -36,6 +41,257 @@ def is_windows_mount_path(candidate: Path) -> bool:
36
41
  return normalized.startswith("/mnt/")
37
42
 
38
43
 
44
+ def _default_windows_runner(args: list[str], *, timeout: int = 20) -> subprocess.CompletedProcess[str]:
45
+ return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
46
+
47
+
48
+ def resolve_windows_host_binary(
49
+ command: str,
50
+ *,
51
+ which_func=shutil.which,
52
+ ) -> str:
53
+ direct = str(which_func(command) or "").strip()
54
+ if direct:
55
+ return direct
56
+ fallbacks = {
57
+ "powershell.exe": [
58
+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
59
+ "/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
60
+ ],
61
+ "schtasks.exe": [
62
+ "/mnt/c/Windows/System32/schtasks.exe",
63
+ "/c/Windows/System32/schtasks.exe",
64
+ ],
65
+ }
66
+ for candidate in fallbacks.get(command.lower(), ()):
67
+ if Path(candidate).exists():
68
+ return candidate
69
+ return ""
70
+
71
+
72
+ def windows_host_interop_available(*, which_func=shutil.which) -> bool:
73
+ return bool(
74
+ resolve_windows_host_binary("powershell.exe", which_func=which_func)
75
+ or resolve_windows_host_binary("schtasks.exe", which_func=which_func)
76
+ )
77
+
78
+
79
+ def query_windows_host_special_folders(
80
+ *,
81
+ runner=_default_windows_runner,
82
+ which_func=shutil.which,
83
+ ) -> dict[str, Any]:
84
+ powershell = resolve_windows_host_binary("powershell.exe", which_func=which_func)
85
+ if not powershell:
86
+ return {
87
+ "available": False,
88
+ "error": "powershell_missing",
89
+ "folders": {},
90
+ }
91
+
92
+ script = (
93
+ "$obj = [ordered]@{"
94
+ "LocalApplicationData=[Environment]::GetFolderPath('LocalApplicationData');"
95
+ "ApplicationData=[Environment]::GetFolderPath('ApplicationData');"
96
+ "Programs=[Environment]::GetFolderPath('Programs')"
97
+ "};"
98
+ "$obj | ConvertTo-Json -Compress"
99
+ )
100
+ try:
101
+ result = runner([powershell, "-NoProfile", "-Command", script], timeout=20)
102
+ except Exception as exc:
103
+ return {
104
+ "available": False,
105
+ "error": str(exc),
106
+ "folders": {},
107
+ }
108
+ raw = str(result.stdout or "").strip()
109
+ if result.returncode != 0 or not raw:
110
+ return {
111
+ "available": False,
112
+ "error": str(result.stderr or result.stdout or "windows_special_folders_failed").strip(),
113
+ "folders": {},
114
+ }
115
+ try:
116
+ payload = json.loads(raw)
117
+ except Exception as exc:
118
+ return {
119
+ "available": False,
120
+ "error": f"invalid_json: {exc}",
121
+ "folders": {},
122
+ }
123
+ if not isinstance(payload, dict):
124
+ return {
125
+ "available": False,
126
+ "error": "invalid_payload",
127
+ "folders": {},
128
+ }
129
+ folders = {str(key): str(value or "").strip() for key, value in payload.items()}
130
+ return {
131
+ "available": True,
132
+ "folders": folders,
133
+ }
134
+
135
+
136
+ def query_windows_host_tasks(
137
+ *,
138
+ runner=_default_windows_runner,
139
+ which_func=shutil.which,
140
+ ) -> dict[str, Any]:
141
+ schtasks = resolve_windows_host_binary("schtasks.exe", which_func=which_func)
142
+ if not schtasks:
143
+ return {
144
+ "available": False,
145
+ "error": "schtasks_missing",
146
+ "tasks": [],
147
+ }
148
+
149
+ try:
150
+ result = runner([schtasks, "/Query", "/FO", "CSV", "/NH"], timeout=30)
151
+ except Exception as exc:
152
+ return {
153
+ "available": False,
154
+ "error": str(exc),
155
+ "tasks": [],
156
+ }
157
+
158
+ if result.returncode != 0:
159
+ return {
160
+ "available": False,
161
+ "error": str(result.stderr or result.stdout or "schtasks_query_failed").strip(),
162
+ "tasks": [],
163
+ }
164
+
165
+ tasks: list[str] = []
166
+ try:
167
+ reader = csv.reader(io.StringIO(str(result.stdout or "")))
168
+ for row in reader:
169
+ if not row:
170
+ continue
171
+ name = str(row[0] or "").strip()
172
+ if name and "nexo" in name.lower():
173
+ tasks.append(name)
174
+ except Exception as exc:
175
+ return {
176
+ "available": False,
177
+ "error": f"invalid_csv: {exc}",
178
+ "tasks": [],
179
+ }
180
+
181
+ return {
182
+ "available": True,
183
+ "tasks": sorted(set(tasks)),
184
+ }
185
+
186
+
187
+ def build_windows_host_cleanup_plan(
188
+ *,
189
+ delete_data: bool = False,
190
+ runner=_default_windows_runner,
191
+ which_func=shutil.which,
192
+ ) -> dict[str, Any]:
193
+ folders_payload = query_windows_host_special_folders(runner=runner, which_func=which_func)
194
+ tasks_payload = query_windows_host_tasks(runner=runner, which_func=which_func)
195
+ folders = folders_payload.get("folders", {}) if folders_payload.get("available") else {}
196
+ local = str(folders.get("LocalApplicationData", "")).strip()
197
+ roaming = str(folders.get("ApplicationData", "")).strip()
198
+ programs = str(folders.get("Programs", "")).strip()
199
+
200
+ runtime_paths = [
201
+ path for path in (
202
+ f"{local}\\Programs\\NEXO Desktop" if local else "",
203
+ f"{local}\\Programs\\NEXO Desktop Support" if local else "",
204
+ ) if path
205
+ ]
206
+ data_paths = [
207
+ path for path in (
208
+ f"{roaming}\\NEXO Desktop" if roaming else "",
209
+ f"{roaming}\\NEXO Desktop Support" if roaming else "",
210
+ f"{local}\\NEXO Desktop" if local else "",
211
+ f"{local}\\NEXO Desktop Support" if local else "",
212
+ ) if path
213
+ ]
214
+ shortcut_paths = [
215
+ path for path in (
216
+ f"{programs}\\NEXO Desktop.lnk" if programs else "",
217
+ f"{programs}\\NEXO Desktop Support.lnk" if programs else "",
218
+ ) if path
219
+ ]
220
+ return {
221
+ "available": bool(folders_payload.get("available") or tasks_payload.get("available")),
222
+ "folders": folders,
223
+ "runtime_paths": runtime_paths,
224
+ "data_paths": data_paths if delete_data else [],
225
+ "shortcut_paths": shortcut_paths,
226
+ "tasks": list(tasks_payload.get("tasks", [])),
227
+ "errors": [
228
+ value for value in (
229
+ folders_payload.get("error"),
230
+ tasks_payload.get("error"),
231
+ ) if value
232
+ ],
233
+ }
234
+
235
+
236
+ def cleanup_windows_host_artifacts(
237
+ *,
238
+ delete_data: bool = False,
239
+ dry_run: bool = False,
240
+ runner=_default_windows_runner,
241
+ which_func=shutil.which,
242
+ ) -> dict[str, Any]:
243
+ powershell = resolve_windows_host_binary("powershell.exe", which_func=which_func)
244
+ schtasks = resolve_windows_host_binary("schtasks.exe", which_func=which_func)
245
+ plan = build_windows_host_cleanup_plan(
246
+ delete_data=delete_data,
247
+ runner=runner,
248
+ which_func=which_func,
249
+ )
250
+ actions: list[dict[str, str]] = []
251
+ errors = list(plan.get("errors", []))
252
+
253
+ for path in plan["runtime_paths"]:
254
+ actions.append({"category": "remove-win-host-app", "detail": Path(path).name, "path": path})
255
+ for path in plan["shortcut_paths"]:
256
+ actions.append({"category": "remove-win-host-shortcut", "detail": Path(path).name, "path": path})
257
+ for path in plan["data_paths"]:
258
+ actions.append({"category": "remove-win-host-data", "detail": Path(path).name, "path": path})
259
+ for task_name in plan["tasks"]:
260
+ actions.append({"category": "remove-win-host-task", "detail": task_name, "path": task_name})
261
+
262
+ if dry_run or not plan["available"]:
263
+ return {"available": plan["available"], "actions": actions, "errors": errors}
264
+
265
+ if powershell:
266
+ remove_targets = plan["runtime_paths"] + plan["shortcut_paths"] + plan["data_paths"]
267
+ if remove_targets:
268
+ quoted = ", ".join(json.dumps(target) for target in remove_targets)
269
+ script = (
270
+ f"$targets = @({quoted}); "
271
+ "foreach ($target in $targets) { "
272
+ "if (Test-Path -LiteralPath $target) { "
273
+ "Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue "
274
+ "} }"
275
+ )
276
+ try:
277
+ result = runner([powershell, "-NoProfile", "-Command", script], timeout=60)
278
+ if result.returncode != 0:
279
+ errors.append(str(result.stderr or result.stdout or "windows_remove_failed").strip())
280
+ except Exception as exc:
281
+ errors.append(str(exc))
282
+
283
+ if schtasks:
284
+ for task_name in plan["tasks"]:
285
+ try:
286
+ result = runner([schtasks, "/Delete", "/TN", task_name, "/F"], timeout=20)
287
+ if result.returncode != 0:
288
+ errors.append(str(result.stderr or result.stdout or f"task_delete_failed:{task_name}").strip())
289
+ except Exception as exc:
290
+ errors.append(f"{task_name}: {exc}")
291
+
292
+ return {"available": plan["available"], "actions": actions, "errors": errors}
293
+
294
+
39
295
  def windows_runtime_status(nexo_home: Path, *, system: str | None = None, release: str | None = None) -> dict[str, Any]:
40
296
  resolved_system = str(system or platform.system() or "").strip()
41
297
  resolved_release = str(release or platform.release() or "").strip()
@@ -63,6 +319,7 @@ def windows_runtime_status(nexo_home: Path, *, system: str | None = None, releas
63
319
  "inside_wsl": inside_wsl,
64
320
  "windows_host_bridge": running_from_windows_host(),
65
321
  "bridge_mode": bridge_mode(),
322
+ "windows_host_interop": windows_host_interop_available(),
66
323
  "wsl_distro": str(os.environ.get("WSL_DISTRO_NAME", "")).strip(),
67
324
  "wsl_interop": bool(str(os.environ.get("WSL_INTEROP", "")).strip()),
68
325
  "nexo_home_on_windows_mount": on_windows_mount,