nexo-brain 7.9.2 → 7.9.4

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.9.2",
3
+ "version": "7.9.4",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.9.2` is the current packaged-runtime line. Patch release that completes the Brain semantic-router site migration: the remaining decision callers now route through `semantic_router.route(...)` with named `decision_kind` policies (`r20_constant_change`, `r34_identity_coherence`, `t4_r15`, `t4_r23e`, `t4_r23f`, `t4_r23h`, `followup_operator_attention`, `drive_signal_type`, `drive_area`, `reply_event_type`, `query_intent`, and `sentiment_intent`). Brain now owns model choice, thresholds, and fallback behaviour centrally instead of each caller carrying its own classifier policy. The patch also fixes packaged headless Guardian map loading: `enforcement_engine` and `agent_runner` now check the installed core directory (`~/.nexo/core/tool-enforcement-map.json`) so followup-runner, morning-agent, digest, and email-monitor load the map instead of falling back to unguarded subprocess execution. Targeted verification: 100 semantic/router/enforcer tests, 125 Drive/cognitive/productization tests, and release-readiness passing.
21
+ Version `7.9.4` is the current packaged-runtime line. Patch release that blocks the Brain 7.9.3 + Desktop 0.28.2 diary regression: canonical lifecycle plans now require real `session_diary` evidence (`wait_for_diary_write`) before `stop_session`, and canonical completion is rejected/retryable without that diary row. It also fixes npm CLI onboarding so `nexo-brain --version` and subcommands never launch the wizard when legacy/v2 calibration is already valid, commits setup calibration atomically only after the wizard completes, and adds `nexo-brain warmup-models` so install/update paths predownload the local mDeBERTa/BGE/reranker models. Verification: full Brain pytest (`2189 passed, 3 skipped, 1 xfailed, 5 xpassed`), release-readiness, npm pack dry-run, and coordinated Desktop v0.28.3 checks.
22
+
23
+ Previously in `7.9.3`: patch release that hardens Brain's canonical lifecycle plan for Desktop close/archive/delete/app-exit diary guarantees: `canonical_actions` now publish the v2 canonical shape (`type` plus `payload.prompt`) while keeping one-release compatibility mirrors (`kind` plus top-level `prompt`) for older Desktop clients. This lets Desktop execute resume → diary prompt → stop with one exact owner per lifecycle event and preserve Brain-side dedupe by event id. Targeted verification: `pytest tests/test_lifecycle_events.py` (25 passing) plus release-readiness after artifact sync.
24
+
25
+ Previously in `7.9.2`: patch release that completes the Brain semantic-router site migration: the remaining decision callers now route through `semantic_router.route(...)` with named `decision_kind` policies (`r20_constant_change`, `r34_identity_coherence`, `t4_r15`, `t4_r23e`, `t4_r23f`, `t4_r23h`, `followup_operator_attention`, `drive_signal_type`, `drive_area`, `reply_event_type`, `query_intent`, and `sentiment_intent`). Brain now owns model choice, thresholds, and fallback behaviour centrally instead of each caller carrying its own classifier policy. The patch also fixes packaged headless Guardian map loading: `enforcement_engine` and `agent_runner` now check the installed core directory (`~/.nexo/core/tool-enforcement-map.json`) so followup-runner, morning-agent, digest, and email-monitor load the map instead of falling back to unguarded subprocess execution. Targeted verification: 100 semantic/router/enforcer tests, 125 Drive/cognitive/productization tests, and release-readiness passing.
22
26
 
23
27
  Previously in `7.9.0`: minor release that ships the foundation of the semantic stack (router + reasoner + CLI) under the ONEPASS LLM Coverage plan, plus two product-bug fixes observed in the wild on 2026-04-23. New `src/semantic_router.py` exposes 18 named `decision_kinds` (13 textual + 5 code-aware) with a per-kind policy table and the layer chain `fast_local → semantic_reasoner → remote_fallback`. New `src/semantic_reasoner.py` adds Mode A (`multipass_local`: reuses the mDeBERTa pin with three prompt-perturbed passes + majority vote + 0.75 floor) and Mode B (`cached_llm`: wrapper over `call_model_raw` with a pid+uuid atomic-write 24h-TTL disk cache at `~/.nexo/runtime/operations/semantic-reasoner-cache.json`, SHA-256 keyed by `decision_kind` + normalized input, LRU-bounded at 2000 entries, corrupt entries dropped on read). New `scripts/semantic-classify.py` JSON-in JSON-out CLI lets external MCP clients (including the closed-source NEXO Desktop companion) query Brain as the single semantic authority. New `NEXO_SEMANTIC_REASONER` kill switch (`0`/`off`/`false`/`no`/`disable`/`disabled`) honours the plan mandate for a runtime opt-out separate from `NEXO_LOCAL_CLASSIFIER`. Bug fixes: `bin/nexo-brain.js` upgrade flow now copies `templates/` root the same way fresh install and same-version refresh already did (Maria iMac 7.1.10→7.8.1 upgrade had lost 27 core-prompts templates and broken post-update import verification); and `tool-enforcement-map.json` `nexo_startup.enforcement.inject_prompt` now instructs the model to preload the 13 `mcp__nexo__*` protocol tools via `ToolSearch` before calling `nexo_startup` when the host MCP client defers tool schemas (Claude Code with many MCPs installed). Audit-driven hardening: router/reasoner defensively use `getattr` over the `call_model_raw` module and add a trailing `except Exception` so provider errors degrade with `remote_error` instead of propagating; cache writes use pid+uuid tmp + `fsync` + `os.replace` to survive concurrent writers; `NEXO_SEMANTIC_REASONER_TTL` parse tolerates malformed values. Tests: +50 (22 router, 20 reasoner, 8 CLI). Per-site migration of existing callers (`session_end_intent`, `r14`, `r16`, `r17`, `r20`, `r34`, T4 gates, `tools_drive`, `nexo-followup-runner`) is explicitly deferred to follow-up patch releases and tracked as `NF-SEMANTIC-ROUTER-SITE-MIGRATION`; nothing in this release changes the behaviour of the existing callers. Companion coordinated release: NEXO Desktop v0.28.0.
24
28
 
package/bin/nexo-brain.js CHANGED
@@ -177,13 +177,27 @@ function isEphemeralInstall(nexoHome) {
177
177
  .some((candidate) => Array.from(tempRoots).some((root) => isWithin(candidate, root)));
178
178
  }
179
179
 
180
- const rl = readline.createInterface({
181
- input: process.stdin,
182
- output: process.stdout,
183
- });
180
+ let rl = null;
181
+
182
+ function getReadline() {
183
+ if (!rl) {
184
+ rl = readline.createInterface({
185
+ input: process.stdin,
186
+ output: process.stdout,
187
+ });
188
+ }
189
+ return rl;
190
+ }
184
191
 
185
192
  function ask(question) {
186
- return new Promise((resolve) => rl.question(question, resolve));
193
+ return new Promise((resolve) => getReadline().question(question, resolve));
194
+ }
195
+
196
+ function closeReadline() {
197
+ if (rl) {
198
+ rl.close();
199
+ rl = null;
200
+ }
187
201
  }
188
202
 
189
203
  function run(cmd, opts = {}) {
@@ -198,6 +212,277 @@ function log(msg) {
198
212
  console.log(` ${msg}`);
199
213
  }
200
214
 
215
+ function readPackageJson() {
216
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
217
+ }
218
+
219
+ function writeJsonAtomic(targetPath, payload) {
220
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
221
+ const tmpPath = path.join(
222
+ path.dirname(targetPath),
223
+ `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`
224
+ );
225
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n");
226
+ fs.renameSync(tmpPath, targetPath);
227
+ }
228
+
229
+ function readJsonFile(filePath) {
230
+ try {
231
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ function calibrationPathCandidates(nexoHome) {
238
+ return [
239
+ path.join(nexoHome, "personal", "brain", "calibration.json"),
240
+ path.join(nexoHome, "brain", "calibration.json"),
241
+ ];
242
+ }
243
+
244
+ function readRuntimeCalibration(nexoHome = NEXO_HOME) {
245
+ for (const filePath of calibrationPathCandidates(nexoHome)) {
246
+ if (!fs.existsSync(filePath)) continue;
247
+ const payload = readJsonFile(filePath);
248
+ if (payload && typeof payload === "object") {
249
+ return { path: filePath, payload };
250
+ }
251
+ }
252
+ return { path: null, payload: null };
253
+ }
254
+
255
+ function nonEmptyString(value) {
256
+ return typeof value === "string" && value.trim().length > 0;
257
+ }
258
+
259
+ function isPlaceholderUserName(value) {
260
+ const normalized = String(value || "").trim().toLowerCase();
261
+ return normalized === "" || normalized === "usuario";
262
+ }
263
+
264
+ function isOnboardingComplete(calibration) {
265
+ if (!calibration || typeof calibration !== "object") return false;
266
+ const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
267
+ const name = user.name;
268
+ const language = user.language || calibration.language;
269
+ const meta = calibration.meta && typeof calibration.meta === "object" ? calibration.meta : {};
270
+
271
+ if (meta.onboarding_completed === true) {
272
+ return nonEmptyString(name) && nonEmptyString(language);
273
+ }
274
+
275
+ // Legacy fallback: v7.8/v7.9-era calibration files may not carry the
276
+ // current schema marker, but a real operator name + language is enough to
277
+ // prove the setup was completed. Placeholder defaults stay incomplete.
278
+ return nonEmptyString(name) && !isPlaceholderUserName(name) && nonEmptyString(language);
279
+ }
280
+
281
+ function hasPartialPlaceholderCalibration(calibration) {
282
+ if (!calibration || typeof calibration !== "object") return false;
283
+ const meta = calibration.meta && typeof calibration.meta === "object" ? calibration.meta : {};
284
+ if (meta.onboarding_completed === true) return false;
285
+ const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
286
+ const name = user.name;
287
+ const language = user.language || calibration.language;
288
+ return isPlaceholderUserName(name) || !nonEmptyString(language);
289
+ }
290
+
291
+ function ensureOnboardingCompletionMarker(nexoHome = NEXO_HOME) {
292
+ const record = readRuntimeCalibration(nexoHome);
293
+ if (!record.path || !isOnboardingComplete(record.payload)) {
294
+ return { changed: false, complete: false, path: record.path };
295
+ }
296
+ const meta = record.payload.meta && typeof record.payload.meta === "object"
297
+ ? { ...record.payload.meta }
298
+ : {};
299
+ if (meta.onboarding_completed === true) {
300
+ return { changed: false, complete: true, path: record.path };
301
+ }
302
+ const next = {
303
+ ...record.payload,
304
+ version: Math.max(Number(record.payload.version) || 1, 2),
305
+ meta: {
306
+ ...meta,
307
+ onboarding_completed: true,
308
+ onboarding_completed_at: meta.onboarding_completed_at || new Date().toISOString(),
309
+ migrated_from_legacy_calibration: true,
310
+ },
311
+ };
312
+ writeJsonAtomic(record.path, next);
313
+ return { changed: true, complete: true, path: record.path };
314
+ }
315
+
316
+ const WARMUP_SCRIPT = path.join(__dirname, "..", "src", "model_warmup.py");
317
+ const WARMUP_PIP_PACKAGES = [
318
+ "transformers",
319
+ "torch",
320
+ "sentencepiece",
321
+ "sentence-transformers",
322
+ ];
323
+ const WARMUP_TIMEOUT_MS = 60 * 60 * 1000;
324
+
325
+ function shouldSkipModelWarmup() {
326
+ const flag = String(process.env.NEXO_SKIP_MODEL_WARMUP || "").trim().toLowerCase();
327
+ return ["1", "true", "yes", "on"].includes(flag);
328
+ }
329
+
330
+ function resolveSystemPython() {
331
+ return run("which python3") || run("which python") || "python3";
332
+ }
333
+
334
+ function ensureWarmupPython(nexoHome = NEXO_HOME) {
335
+ const existing = findVenvPython(nexoHome);
336
+ if (existing) return existing;
337
+
338
+ const basePython = resolveSystemPython();
339
+ const venvPath = path.join(nexoHome, ".venv");
340
+ const venvPython = process.platform === "win32"
341
+ ? path.join(venvPath, "Scripts", "python.exe")
342
+ : path.join(venvPath, "bin", "python3");
343
+ fs.mkdirSync(nexoHome, { recursive: true });
344
+ if (!fs.existsSync(venvPython)) {
345
+ log(" Creating Python virtual environment for model warmup...");
346
+ const result = spawnSync(basePython, ["-m", "venv", venvPath], { stdio: "inherit", timeout: 120000 });
347
+ if (result.status !== 0) {
348
+ throw new Error("could not create Python virtual environment for model warmup");
349
+ }
350
+ }
351
+ return venvPython;
352
+ }
353
+
354
+ function installWarmupPythonDependencies(pythonPath, { quiet = false, installRuntimeDeps = true } = {}) {
355
+ const requirementsFile = path.join(__dirname, "..", "src", "requirements.txt");
356
+ const pipCommon = ["-m", "pip", "install"];
357
+ if (quiet) pipCommon.push("--quiet");
358
+ const stdio = quiet ? "pipe" : "inherit";
359
+
360
+ if (installRuntimeDeps && fs.existsSync(requirementsFile)) {
361
+ const reqResult = spawnSync(
362
+ pythonPath,
363
+ [...pipCommon, "-r", requirementsFile],
364
+ { stdio, timeout: WARMUP_TIMEOUT_MS }
365
+ );
366
+ if (reqResult.status !== 0) {
367
+ throw new Error("failed to install runtime Python dependencies for model warmup");
368
+ }
369
+ }
370
+
371
+ const classifierResult = spawnSync(
372
+ pythonPath,
373
+ [...pipCommon, ...WARMUP_PIP_PACKAGES],
374
+ { stdio, timeout: WARMUP_TIMEOUT_MS }
375
+ );
376
+ if (classifierResult.status !== 0) {
377
+ throw new Error("failed to install local classifier dependencies for model warmup");
378
+ }
379
+ }
380
+
381
+ function runModelWarmup(pythonPath, {
382
+ nexoHome = NEXO_HOME,
383
+ dryRun = false,
384
+ json = false,
385
+ strict = true,
386
+ quiet = false,
387
+ } = {}) {
388
+ if (!fs.existsSync(WARMUP_SCRIPT)) {
389
+ throw new Error(`model warmup script not found: ${WARMUP_SCRIPT}`);
390
+ }
391
+ const args = [WARMUP_SCRIPT];
392
+ if (dryRun) args.push("--dry-run");
393
+ if (json) args.push("--json");
394
+ if (strict) args.push("--strict");
395
+ const result = spawnSync(pythonPath, args, {
396
+ cwd: path.join(__dirname, "..", "src"),
397
+ env: { ...process.env, NEXO_HOME: nexoHome, NEXO_CODE: path.join(__dirname, "..", "src") },
398
+ stdio: json ? "pipe" : (quiet ? "pipe" : "inherit"),
399
+ encoding: json || quiet ? "utf8" : undefined,
400
+ timeout: WARMUP_TIMEOUT_MS,
401
+ });
402
+ if (json && result.stdout) process.stdout.write(result.stdout);
403
+ if (json && result.stderr) process.stderr.write(result.stderr);
404
+ if (result.status !== 0) {
405
+ const details = json || quiet
406
+ ? String(result.stderr || result.stdout || "").trim()
407
+ : "";
408
+ throw new Error(details || `model warmup failed with exit ${result.status}`);
409
+ }
410
+ }
411
+
412
+ function runMandatoryModelWarmup(pythonPath, nexoHome = NEXO_HOME, { reason = "install", installRuntimeDeps = true } = {}) {
413
+ if (shouldSkipModelWarmup()) {
414
+ log(`Model warmup skipped by NEXO_SKIP_MODEL_WARMUP during ${reason}.`);
415
+ return;
416
+ }
417
+ log(`Warming up local Brain/Desktop models (${reason})...`);
418
+ installWarmupPythonDependencies(pythonPath, { quiet: false, installRuntimeDeps });
419
+ runModelWarmup(pythonPath, { nexoHome, strict: true });
420
+ log("Local model warmup complete.");
421
+ }
422
+
423
+ async function runWarmupModelsCommand(args) {
424
+ const dryRun = args.includes("--dry-run");
425
+ const json = args.includes("--json");
426
+ const force = args.includes("--force");
427
+ const quiet = args.includes("--postinstall") || args.includes("--quiet");
428
+
429
+ if (shouldSkipModelWarmup() && !force) {
430
+ if (json) {
431
+ process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason: "NEXO_SKIP_MODEL_WARMUP" }) + "\n");
432
+ } else {
433
+ log("Model warmup skipped by NEXO_SKIP_MODEL_WARMUP.");
434
+ }
435
+ return;
436
+ }
437
+
438
+ const pythonPath = dryRun ? resolveSystemPython() : ensureWarmupPython(NEXO_HOME);
439
+ if (!dryRun) {
440
+ installWarmupPythonDependencies(pythonPath, { quiet });
441
+ }
442
+ runModelWarmup(pythonPath, {
443
+ nexoHome: NEXO_HOME,
444
+ dryRun,
445
+ json,
446
+ strict: !args.includes("--best-effort"),
447
+ quiet,
448
+ });
449
+ }
450
+
451
+ function printHelp() {
452
+ const version = readPackageJson().version;
453
+ console.log(`nexo-brain ${version}`);
454
+ console.log("");
455
+ console.log("Usage:");
456
+ console.log(" nexo-brain [--yes|--skip|--defaults]");
457
+ console.log(" nexo-brain --version");
458
+ console.log(" nexo-brain warmup-models [--dry-run] [--json] [--force]");
459
+ }
460
+
461
+ async function maybeHandleTopLevelCommand(argv = process.argv.slice(2)) {
462
+ if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
463
+ console.log(`nexo-brain ${readPackageJson().version}`);
464
+ return true;
465
+ }
466
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
467
+ printHelp();
468
+ return true;
469
+ }
470
+ if (argv[0] === "warmup-models") {
471
+ await runWarmupModelsCommand(argv.slice(1));
472
+ return true;
473
+ }
474
+
475
+ const setupFlags = new Set(["--defaults", "--yes", "--skip", "-y"]);
476
+ const unknownCommand = argv.find((arg) => !setupFlags.has(arg));
477
+ if (unknownCommand && !unknownCommand.startsWith("-")) {
478
+ console.error(`Unknown nexo-brain command: ${unknownCommand}`);
479
+ console.error("Run 'nexo-brain --help' for usage.");
480
+ process.exitCode = 2;
481
+ return true;
482
+ }
483
+ return false;
484
+ }
485
+
201
486
  function duplicateArtifactCanonicalName(name) {
202
487
  const ext = path.extname(name);
203
488
  const stem = ext ? name.slice(0, -ext.length) : name;
@@ -1946,7 +2231,7 @@ WantedBy=timers.target
1946
2231
  }
1947
2232
  }
1948
2233
 
1949
- async function main() {
2234
+ async function runSetup() {
1950
2235
  // Non-interactive mode: --defaults, --yes, --skip, or -y all skip prompts
1951
2236
  // and apply the recommended defaults end-to-end (v6.0.0 adds --skip).
1952
2237
  const useDefaults = process.argv.includes("--defaults")
@@ -1993,12 +2278,22 @@ async function main() {
1993
2278
  process.exit(1);
1994
2279
  }
1995
2280
 
2281
+ const onboardingMigration = ensureOnboardingCompletionMarker(NEXO_HOME);
2282
+ if (onboardingMigration.changed) {
2283
+ log("Migrated legacy calibration completion marker.");
2284
+ } else {
2285
+ const calibrationRecord = readRuntimeCalibration(NEXO_HOME);
2286
+ if (hasPartialPlaceholderCalibration(calibrationRecord.payload)) {
2287
+ log("Incomplete calibration detected; restarting onboarding cleanly.");
2288
+ }
2289
+ }
2290
+
1996
2291
  // Auto-migration: detect existing installation
1997
2292
  const versionFile = path.join(NEXO_HOME, "version.json");
1998
2293
  if (fs.existsSync(versionFile)) {
1999
2294
  try {
2000
2295
  const installed = JSON.parse(fs.readFileSync(versionFile, "utf8"));
2001
- const currentPkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
2296
+ const currentPkg = readPackageJson();
2002
2297
  const installedVersion = installed.version || "0.0.0";
2003
2298
  const currentVersion = currentPkg.version;
2004
2299
 
@@ -2079,6 +2374,9 @@ async function main() {
2079
2374
  log(" Python dependencies reconciled.");
2080
2375
  }
2081
2376
 
2377
+ const migPythonForWarmup = findVenvPython(NEXO_HOME) || "python3";
2378
+ runMandatoryModelWarmup(migPythonForWarmup, NEXO_HOME, { reason: "update", installRuntimeDeps: false });
2379
+
2082
2380
  // Update plugins (all .py files in plugins/)
2083
2381
  const pluginsSrc = path.join(srcDir, "plugins");
2084
2382
  const pluginsDest = path.join(NEXO_HOME, "core", "plugins");
@@ -2246,11 +2544,12 @@ async function main() {
2246
2544
  log(`Migration complete: v${installedVersion} → v${currentVersion}`);
2247
2545
  log("Your data (memories, learnings, preferences) is untouched.");
2248
2546
  console.log("");
2249
- rl.close();
2547
+ closeReadline();
2250
2548
  return;
2251
2549
  }
2252
2550
 
2253
2551
  // Same version — backfill crons/ if missing (for installs before crons was shipped)
2552
+ const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
2254
2553
  const cronsDest = resolveRuntimeCronsDir(NEXO_HOME);
2255
2554
  const cronsSrc = path.join(__dirname, "..", "src", "crons");
2256
2555
  if (fs.existsSync(cronsSrc)) {
@@ -2268,7 +2567,6 @@ async function main() {
2268
2567
  log("Refreshed crons/ directory.");
2269
2568
 
2270
2569
  const cronSyncPath = path.join(cronsSrc, "sync.py");
2271
- const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
2272
2570
  if (fs.existsSync(cronSyncPath)) {
2273
2571
  const syncResult = spawnSync(syncPython, [cronSyncPath], {
2274
2572
  env: { ...process.env, NEXO_HOME, NEXO_CODE: path.join(__dirname, "..", "src") },
@@ -2340,10 +2638,11 @@ async function main() {
2340
2638
  throw new Error(`F0.6 layout finalization failed: ${syncLayoutFinalize.error}`);
2341
2639
  }
2342
2640
 
2641
+ runMandatoryModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
2343
2642
  logMacPermissionsNotice(NEXO_HOME, syncPython);
2344
2643
 
2345
2644
  log(`Already at v${currentVersion}. No migration needed.`);
2346
- rl.close();
2645
+ closeReadline();
2347
2646
  return;
2348
2647
  } catch (e) {
2349
2648
  // Version file corrupt — proceed with fresh install
@@ -2732,18 +3031,14 @@ async function main() {
2732
3031
  report_style: "essentials_only",
2733
3032
  execution_first: true,
2734
3033
  },
2735
- meta: {},
3034
+ meta: {
3035
+ onboarding_completed: true,
3036
+ onboarding_completed_at: new Date().toISOString(),
3037
+ },
2736
3038
  auto_install: "ask", // updated later if user answers P11
2737
3039
  calibrated_at: new Date().toISOString(),
2738
3040
  };
2739
- // Ensure NEXO_HOME and brain dir exist before writing calibration
2740
3041
  const runtimeBrainDir = resolveRuntimeBrainDir(NEXO_HOME);
2741
- fs.mkdirSync(NEXO_HOME, { recursive: true });
2742
- fs.mkdirSync(runtimeBrainDir, { recursive: true });
2743
- fs.writeFileSync(
2744
- path.join(runtimeBrainDir, "calibration.json"),
2745
- JSON.stringify(calibration, null, 2)
2746
- );
2747
3042
 
2748
3043
  // Step 5: Deep scan (P9) — v6.0.0 defaults flip to ON when running in
2749
3044
  // --yes/--skip mode; the interactive prompt below defaults to "yes" too
@@ -2792,28 +3087,18 @@ async function main() {
2792
3087
  console.log("");
2793
3088
  }
2794
3089
 
2795
- // Persist the updated calibration (auto_install may have changed post-write above).
2796
- try {
2797
- fs.writeFileSync(
2798
- path.join(runtimeBrainDir, "calibration.json"),
2799
- JSON.stringify(calibration, null, 2)
2800
- );
2801
- } catch (_) {}
3090
+ // Commit calibration only after the wizard completed. This prevents Ctrl-C
3091
+ // from leaving placeholder defaults that look like real onboarding.
3092
+ fs.mkdirSync(NEXO_HOME, { recursive: true });
3093
+ writeJsonAtomic(path.join(runtimeBrainDir, "calibration.json"), calibration);
2802
3094
 
2803
3095
  if (smokeTestMode) {
2804
3096
  // Pytest fresh-install smoke only needs to prove that the non-interactive
2805
3097
  // onboarding path writes the current calibration shape. Skip the rest of
2806
3098
  // the heavy bootstrap (client installs, pip, scan, LaunchAgents) so the
2807
3099
  // smoke does not sit on long dependency timeouts inside sandboxes.
2808
- try {
2809
- fs.mkdirSync(runtimeBrainDir, { recursive: true });
2810
- fs.writeFileSync(
2811
- path.join(runtimeBrainDir, "calibration.json"),
2812
- JSON.stringify(calibration, null, 2)
2813
- );
2814
- } catch (_) {}
2815
3100
  log("Smoke test mode detected — wrote calibration and skipped heavy bootstrap.");
2816
- rl.close();
3101
+ closeReadline();
2817
3102
  return;
2818
3103
  }
2819
3104
 
@@ -2862,6 +3147,7 @@ async function main() {
2862
3147
  python = venvPython;
2863
3148
  }
2864
3149
  log("Dependencies installed.");
3150
+ runMandatoryModelWarmup(python, NEXO_HOME, { reason: "install", installRuntimeDeps: false });
2865
3151
 
2866
3152
  // Step 4: Create ~/.nexo/
2867
3153
  log("Setting up NEXO home...");
@@ -3915,10 +4201,18 @@ See ~/.nexo/ for configuration.
3915
4201
  console.log(` \u255A${"═".repeat(bw - 2)}\u255D`);
3916
4202
  console.log("");
3917
4203
 
3918
- rl.close();
4204
+ closeReadline();
4205
+ }
4206
+
4207
+ async function main() {
4208
+ const handled = await maybeHandleTopLevelCommand();
4209
+ if (!handled) {
4210
+ await runSetup();
4211
+ }
3919
4212
  }
3920
4213
 
3921
4214
  main().catch((err) => {
4215
+ closeReadline();
3922
4216
  console.error("Setup failed:", err.message);
3923
4217
  process.exit(1);
3924
4218
  });
@@ -8,15 +8,29 @@
8
8
 
9
9
  const fs = require("fs");
10
10
  const path = require("path");
11
+ const { execFileSync } = require("child_process");
11
12
 
12
13
  const NEXO_HOME = process.env.NEXO_HOME || path.join(require("os").homedir(), ".nexo");
13
14
  const VERSION_FILE = path.join(NEXO_HOME, "version.json");
15
+ const INSTALLER = path.join(__dirname, "nexo-brain.js");
14
16
 
15
17
  if (process.env.NEXO_SKIP_POSTINSTALL === "1") {
16
18
  // Called during rollback — skip migration to avoid loops
17
19
  process.exit(0);
18
20
  }
19
21
 
22
+ function runModelWarmup(reason) {
23
+ if (process.env.NEXO_SKIP_MODEL_WARMUP === "1") {
24
+ console.log(`\n NEXO Brain: model warmup skipped by NEXO_SKIP_MODEL_WARMUP (${reason}).`);
25
+ return;
26
+ }
27
+ console.log(`\n NEXO Brain: warming up local models (${reason})...`);
28
+ execFileSync(process.execPath, [INSTALLER, "warmup-models", "--postinstall"], {
29
+ stdio: "inherit",
30
+ env: { ...process.env, NEXO_POSTINSTALL: "1", NEXO_HOME: NEXO_HOME }
31
+ });
32
+ }
33
+
20
34
  if (fs.existsSync(VERSION_FILE)) {
21
35
  // Existing installation — run auto-migration silently
22
36
  const installed = JSON.parse(fs.readFileSync(VERSION_FILE, "utf8"));
@@ -32,9 +46,8 @@ if (fs.existsSync(VERSION_FILE)) {
32
46
  // Run the main installer in --yes mode (non-interactive)
33
47
  // It will detect the existing version and do migration only
34
48
  // Let errors propagate so npm reports the failure correctly
35
- const { execFileSync } = require("child_process");
36
49
  try {
37
- execFileSync(process.execPath, [path.join(__dirname, "nexo-brain.js"), "--yes"], {
50
+ execFileSync(process.execPath, [INSTALLER, "--yes"], {
38
51
  stdio: "inherit",
39
52
  env: { ...process.env, NEXO_POSTINSTALL: "1", NEXO_HOME: NEXO_HOME }
40
53
  });
@@ -44,6 +57,14 @@ if (fs.existsSync(VERSION_FILE)) {
44
57
  process.exit(1);
45
58
  }
46
59
  } else {
60
+ try {
61
+ runModelWarmup("fresh install");
62
+ } catch (e) {
63
+ console.error(`\n NEXO Brain: model warmup FAILED — ${e.message}`);
64
+ console.error(" Set NEXO_SKIP_MODEL_WARMUP=1 only for CI/offline testing, then run 'nexo-brain warmup-models'.");
65
+ process.exit(1);
66
+ }
67
+
47
68
  // Fresh install — just show instructions
48
69
  console.log("\n ╔════════════════════════════════════════════╗");
49
70
  console.log(" ║ NEXO Brain installed successfully! ║");
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.2",
3
+ "version": "7.9.4",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
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",
7
7
  "bin": {
8
- "nexo-brain": "./bin/nexo-brain.js",
9
- "nexo": "./bin/nexo.js"
8
+ "nexo-brain": "bin/nexo-brain.js",
9
+ "nexo": "bin/nexo.js"
10
10
  },
11
11
  "keywords": [
12
12
  "claude-code",
package/src/cli.py CHANGED
@@ -3104,6 +3104,14 @@ def main():
3104
3104
  help="JSON array of per-action outcomes, e.g. '[{\"action_id\":\"a1\",\"status\":\"ok\"}]'",
3105
3105
  )
3106
3106
 
3107
+ lwait_p = lifecycle_sub.add_parser(
3108
+ "wait-for-diary",
3109
+ help="v7.9: wait for concrete session_diary evidence before canonical stop_session",
3110
+ )
3111
+ lwait_p.add_argument("--event-id", required=True)
3112
+ lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
3113
+ lwait_p.add_argument("--poll-ms", type=int, default=500)
3114
+
3107
3115
  # Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
3108
3116
  quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
3109
3117
  quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
@@ -3370,6 +3378,23 @@ def main():
3370
3378
  if status == "retryable_error":
3371
3379
  return 2
3372
3380
  return 3
3381
+ if args.lifecycle_command == "wait-for-diary":
3382
+ out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_diary(
3383
+ event_id=args.event_id,
3384
+ timeout_ms=args.timeout_ms,
3385
+ poll_ms=args.poll_ms,
3386
+ )
3387
+ print(out)
3388
+ try:
3389
+ parsed = _json.loads(out)
3390
+ status = str(parsed.get("status", ""))
3391
+ except Exception:
3392
+ status = ""
3393
+ if status == "ok":
3394
+ return 0
3395
+ if status == "retryable_error":
3396
+ return 2
3397
+ return 3
3373
3398
  lifecycle_parser.print_help()
3374
3399
  return 1
3375
3400
  elif args.command in ("schema", "identity", "onboard", "scan-profile"):
@@ -51,6 +51,7 @@ Actions that carry a canonical plan: ``close``, ``delete``, ``archive``,
51
51
  from __future__ import annotations
52
52
 
53
53
  import json
54
+ import time
54
55
  from typing import Any, Dict, Optional
55
56
 
56
57
  from db import get_db
@@ -83,24 +84,102 @@ def _normalise_payload(obj: Any) -> str:
83
84
  return "{}"
84
85
 
85
86
 
86
- def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str]) -> bool:
87
- """True if session_diary has a row for ``session_id`` created after
88
- ``dispatched_at``. Used by the canonical-authority idempotency
89
- guard: if the live session already produced a diary since the
90
- plan was handed out, we must NOT re-dispatch it.
91
- """
92
- if not session_id or not dispatched_at:
93
- return False
87
+ def _max_session_diary_id(conn, session_id: str) -> int:
88
+ if not session_id:
89
+ return 0
94
90
  try:
95
91
  row = conn.execute(
96
- "SELECT 1 FROM session_diary "
97
- "WHERE session_id = ? AND created_at > ? LIMIT 1",
98
- (str(session_id), str(dispatched_at)),
92
+ "SELECT COALESCE(MAX(id), 0) FROM session_diary WHERE session_id = ?",
93
+ (str(session_id),),
99
94
  ).fetchone()
95
+ except Exception:
96
+ return 0
97
+ try:
98
+ return int(row[0] or 0) if row else 0
99
+ except Exception:
100
+ return 0
101
+
102
+
103
+ def _diary_checkpoint_from_actions_json(actions_json: Optional[str]) -> int:
104
+ if not actions_json:
105
+ return 0
106
+ try:
107
+ actions = json.loads(actions_json)
108
+ except Exception:
109
+ return 0
110
+ if not isinstance(actions, list):
111
+ return 0
112
+ for action in actions:
113
+ if not isinstance(action, dict):
114
+ continue
115
+ action_type = action.get("type") or action.get("kind")
116
+ if action_type != "wait_for_diary_write":
117
+ continue
118
+ payload = action.get("payload") if isinstance(action.get("payload"), dict) else {}
119
+ raw = payload.get("after_session_diary_id", action.get("after_session_diary_id", 0))
120
+ try:
121
+ return int(raw or 0)
122
+ except Exception:
123
+ return 0
124
+ return 0
125
+
126
+
127
+ def _attach_diary_checkpoint(plan: Dict[str, Any], checkpoint_id: int) -> Dict[str, Any]:
128
+ """Store the session_diary high-water mark inside the persisted plan.
129
+
130
+ ``created_at`` only has second precision in old installs. The
131
+ checkpoint makes diary confirmation robust even when dispatch and
132
+ diary write happen in the same second.
133
+ """
134
+ actions = []
135
+ for action in list((plan or {}).get("canonical_actions") or []):
136
+ item = dict(action or {})
137
+ action_type = item.get("type") or item.get("kind")
138
+ if action_type == "wait_for_diary_write":
139
+ payload = dict(item.get("payload") or {})
140
+ payload["after_session_diary_id"] = int(checkpoint_id or 0)
141
+ item["payload"] = payload
142
+ item["after_session_diary_id"] = int(checkpoint_id or 0)
143
+ actions.append(item)
144
+ updated = dict(plan or {})
145
+ updated["canonical_actions"] = actions
146
+ return updated
147
+
148
+
149
+ def _session_diary_evidence(
150
+ conn,
151
+ session_id: str,
152
+ dispatched_at: Optional[str],
153
+ actions_json: Optional[str] = None,
154
+ ) -> Optional[Dict[str, Any]]:
155
+ """Return the concrete session_diary row that satisfies a plan."""
156
+ if not session_id or not dispatched_at:
157
+ return None
158
+ checkpoint_id = _diary_checkpoint_from_actions_json(actions_json)
159
+ try:
160
+ if checkpoint_id > 0:
161
+ row = conn.execute(
162
+ "SELECT id, created_at FROM session_diary "
163
+ "WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT 1",
164
+ (str(session_id), int(checkpoint_id)),
165
+ ).fetchone()
166
+ else:
167
+ row = conn.execute(
168
+ "SELECT id, created_at FROM session_diary "
169
+ "WHERE session_id = ? AND created_at >= ? ORDER BY created_at ASC, id ASC LIMIT 1",
170
+ (str(session_id), str(dispatched_at)),
171
+ ).fetchone()
100
172
  except Exception:
101
173
  # Missing table on a minimal test harness — treat as "no diary".
102
- return False
103
- return row is not None
174
+ return None
175
+ if row is None:
176
+ return None
177
+ return {"session_diary_id": row[0], "created_at": row[1]}
178
+
179
+
180
+ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], actions_json: Optional[str] = None) -> bool:
181
+ """True if session_diary has evidence satisfying the canonical plan."""
182
+ return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
104
183
 
105
184
 
106
185
  def record_lifecycle_event(
@@ -145,6 +224,8 @@ def record_lifecycle_event(
145
224
  session_id=str(session_id) if session_id else None,
146
225
  payload_snapshot=payload_snapshot or {},
147
226
  )
227
+ if plan is not None and session_id:
228
+ plan = _attach_diary_checkpoint(plan, _max_session_diary_id(conn, str(session_id)))
148
229
 
149
230
  if existing is not None:
150
231
  status = str(existing[0] or "")
@@ -171,7 +252,12 @@ def record_lifecycle_event(
171
252
  # the intent has already been satisfied by the model and we
172
253
  # must NOT ask Desktop to re-run the plan.
173
254
  if prior_plan_id and prior_dispatched_at and not prior_done_at:
174
- if session_id and _session_diary_since(conn, str(session_id), str(prior_dispatched_at)):
255
+ if session_id and _session_diary_since(
256
+ conn,
257
+ str(session_id),
258
+ str(prior_dispatched_at),
259
+ str(prior_actions_json or ""),
260
+ ):
175
261
  conn.execute(
176
262
  "UPDATE lifecycle_events "
177
263
  "SET delivery_status = 'already_processed', "
@@ -340,7 +426,8 @@ def record_complete_canonical(
340
426
 
341
427
  conn = get_db()
342
428
  row = conn.execute(
343
- "SELECT delivery_status, canonical_plan_id, canonical_done_at "
429
+ "SELECT delivery_status, canonical_plan_id, canonical_done_at, "
430
+ "action, session_id, canonical_dispatched_at, canonical_actions_json "
344
431
  "FROM lifecycle_events WHERE event_id = ?",
345
432
  (str(event_id),),
346
433
  ).fetchone()
@@ -349,6 +436,10 @@ def record_complete_canonical(
349
436
  current_status = str(row[0] or "")
350
437
  expected_plan = row[1]
351
438
  already_done_at = row[2]
439
+ action = str(row[3] or "")
440
+ session_id = str(row[4] or "")
441
+ dispatched_at = row[5]
442
+ actions_json = row[6]
352
443
 
353
444
  if expected_plan and canonical_plan_id != expected_plan:
354
445
  return {
@@ -369,18 +460,27 @@ def record_complete_canonical(
369
460
  str((r or {}).get("status", "")).lower() not in {"ok", "success", "already_processed"}
370
461
  for r in results_list
371
462
  )
372
- effective = "retryable_error" if any_failure else "canonical_done"
463
+ diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
464
+ diary_required = action in _DIARY_TRIGGERING and bool(session_id)
465
+ diary_missing = diary_required and diary_evidence is None
466
+ effective = "retryable_error" if (any_failure or diary_missing) else "canonical_done"
467
+ last_error = None
468
+ if any_failure:
469
+ last_error = "one-or-more-actions-failed"
470
+ elif diary_missing:
471
+ last_error = "canonical-diary-not-confirmed"
373
472
  conn.execute(
374
473
  "UPDATE lifecycle_events "
375
474
  "SET delivery_status = ?, "
376
- " canonical_done_at = datetime('now'), "
475
+ " canonical_done_at = CASE WHEN ? = 'canonical_done' THEN datetime('now') ELSE NULL END, "
377
476
  " canonical_done_results = ?, "
378
477
  " last_error = ? "
379
478
  "WHERE event_id = ?",
380
479
  (
480
+ effective,
381
481
  effective,
382
482
  json.dumps(results_list, ensure_ascii=False),
383
- "one-or-more-actions-failed" if any_failure else None,
483
+ last_error,
384
484
  str(event_id),
385
485
  ),
386
486
  )
@@ -390,9 +490,58 @@ def record_complete_canonical(
390
490
  "event_id": event_id,
391
491
  "canonical_plan_id": canonical_plan_id,
392
492
  "failed_actions": any_failure,
493
+ "diary_confirmed": diary_evidence is not None,
494
+ "diary_required": diary_required,
495
+ "session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
496
+ "reason": "canonical-diary-not-confirmed" if diary_missing else None,
393
497
  }
394
498
 
395
499
 
500
+ def wait_for_canonical_diary(
501
+ event_id: str,
502
+ timeout_ms: int = 45_000,
503
+ poll_ms: int = 500,
504
+ ) -> Dict[str, Any]:
505
+ """Poll until the lifecycle event has concrete session_diary evidence."""
506
+ if not event_id:
507
+ return {"status": "rejected", "reason": "missing-event-id"}
508
+ timeout_s = max(0.0, float(timeout_ms or 0) / 1000.0)
509
+ poll_s = max(0.05, float(poll_ms or 500) / 1000.0)
510
+ deadline = time.monotonic() + timeout_s
511
+ last_error: Optional[str] = None
512
+
513
+ while True:
514
+ conn = get_db()
515
+ row = conn.execute(
516
+ "SELECT session_id, canonical_dispatched_at, canonical_actions_json "
517
+ "FROM lifecycle_events WHERE event_id = ?",
518
+ (str(event_id),),
519
+ ).fetchone()
520
+ if row is None:
521
+ return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
522
+ session_id = str(row[0] or "")
523
+ if not session_id:
524
+ return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
525
+ evidence = _session_diary_evidence(conn, session_id, row[1], row[2])
526
+ if evidence is not None:
527
+ return {
528
+ "status": "ok",
529
+ "event_id": event_id,
530
+ "session_id": session_id,
531
+ "diary_confirmed": True,
532
+ **evidence,
533
+ }
534
+ if time.monotonic() >= deadline:
535
+ return {
536
+ "status": "retryable_error",
537
+ "event_id": event_id,
538
+ "session_id": session_id,
539
+ "diary_confirmed": False,
540
+ "reason": last_error or "diary-confirm-timeout",
541
+ }
542
+ time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
543
+
544
+
396
545
  def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
397
546
  if not event_id:
398
547
  return None
@@ -19,7 +19,7 @@ import json
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
21
 
22
- PLAN_VERSION = 1
22
+ PLAN_VERSION = 3
23
23
 
24
24
 
25
25
  # Actions that trigger a canonical diary+stop plan. `switch` and
@@ -34,7 +34,8 @@ DIARY_TRIGGERING_ACTIONS = {"close", "delete", "archive", "app-exit"}
34
34
  # executes each action; on timeout it reports status=failed and Brain
35
35
  # flips delivery_status to retryable_error.
36
36
  DEFAULT_RESUME_TIMEOUT_MS = 2_000
37
- DEFAULT_INJECT_TIMEOUT_MS = 6_000
37
+ DEFAULT_INJECT_TIMEOUT_MS = 30_000
38
+ DEFAULT_DIARY_WAIT_TIMEOUT_MS = 45_000
38
39
  DEFAULT_STOP_TIMEOUT_MS = 3_000
39
40
 
40
41
 
@@ -79,6 +80,27 @@ def _diary_prompt_for_action(
79
80
  )
80
81
 
81
82
 
83
+ def _canonical_action(
84
+ action_id: str,
85
+ action_type: str,
86
+ session_id: str,
87
+ timeout_ms: int,
88
+ **extra: Any,
89
+ ) -> Dict[str, Any]:
90
+ """Build one Desktop action with the v2 shape plus one-release mirrors."""
91
+ action: Dict[str, Any] = {
92
+ "id": action_id,
93
+ "type": action_type,
94
+ # Compatibility mirror for Desktop <= 0.28.1. Remove after one
95
+ # release once every supported Desktop consumes `type`.
96
+ "kind": action_type,
97
+ "session_id": str(session_id),
98
+ "timeout_ms": timeout_ms,
99
+ }
100
+ action.update(extra)
101
+ return action
102
+
103
+
82
104
  def build_canonical_plan(
83
105
  event_id: str,
84
106
  action: str,
@@ -106,26 +128,28 @@ def build_canonical_plan(
106
128
  prompt = _diary_prompt_for_action(action, conversation_id, payload_snapshot)
107
129
 
108
130
  actions: List[Dict[str, Any]] = [
109
- {
110
- "id": "a1",
111
- "kind": "resume_session",
112
- "session_id": str(session_id),
113
- "timeout_ms": DEFAULT_RESUME_TIMEOUT_MS,
114
- },
115
- {
116
- "id": "a2",
117
- "kind": "inject_prompt",
118
- "session_id": str(session_id),
119
- "prompt": prompt,
120
- "expected_tool_call": "nexo_session_diary_write",
121
- "timeout_ms": DEFAULT_INJECT_TIMEOUT_MS,
122
- },
123
- {
124
- "id": "a3",
125
- "kind": "stop_session",
126
- "session_id": str(session_id),
127
- "timeout_ms": DEFAULT_STOP_TIMEOUT_MS,
128
- },
131
+ _canonical_action("a1", "resume_session", str(session_id), DEFAULT_RESUME_TIMEOUT_MS),
132
+ _canonical_action(
133
+ "a2",
134
+ "inject_prompt",
135
+ str(session_id),
136
+ DEFAULT_INJECT_TIMEOUT_MS,
137
+ payload={"prompt": prompt},
138
+ # Compatibility mirror for Desktop <= 0.28.1. Remove after one
139
+ # release once every supported Desktop consumes `payload.prompt`.
140
+ prompt=prompt,
141
+ expected_tool_call="nexo_session_diary_write",
142
+ ),
143
+ _canonical_action(
144
+ "a3",
145
+ "wait_for_diary_write",
146
+ str(session_id),
147
+ DEFAULT_DIARY_WAIT_TIMEOUT_MS,
148
+ event_id=str(event_id),
149
+ expected_tool_call="nexo_session_diary_write",
150
+ evidence="session_diary",
151
+ ),
152
+ _canonical_action("a4", "stop_session", str(session_id), DEFAULT_STOP_TIMEOUT_MS),
129
153
  ]
130
154
  return {
131
155
  "canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
@@ -0,0 +1,177 @@
1
+ """Predownload the local models used by NEXO Brain/Desktop flows.
2
+
3
+ The installer invokes this script after Python dependencies are present.
4
+ ``--dry-run`` is intentionally dependency-free so package tests can verify the
5
+ contract without downloading 1+ GB of weights.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ SRC_DIR = Path(__file__).resolve().parent
21
+ if str(SRC_DIR) not in sys.path:
22
+ sys.path.insert(0, str(SRC_DIR))
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class WarmupTarget:
27
+ name: str
28
+ kind: str
29
+ model_id: str
30
+ source: str
31
+ revision: str | None = None
32
+ required: bool = True
33
+
34
+
35
+ def warmup_targets() -> list[WarmupTarget]:
36
+ from classifier_local import MODEL_ID, MODEL_REVISION
37
+
38
+ return [
39
+ WarmupTarget(
40
+ name="local-zero-shot-classifier",
41
+ kind="transformers_sequence_classifier",
42
+ model_id=MODEL_ID,
43
+ revision=MODEL_REVISION,
44
+ source="src/classifier_local.py",
45
+ ),
46
+ WarmupTarget(
47
+ name="bge-base-embeddings",
48
+ kind="fastembed_embedding",
49
+ model_id="BAAI/bge-base-en-v1.5",
50
+ source="src/cognitive/_core.py",
51
+ ),
52
+ WarmupTarget(
53
+ name="bge-small-embeddings",
54
+ kind="fastembed_embedding",
55
+ model_id="BAAI/bge-small-en-v1.5",
56
+ source="src/migrate_embeddings.py",
57
+ ),
58
+ WarmupTarget(
59
+ name="cross-encoder-reranker",
60
+ kind="fastembed_reranker",
61
+ model_id="Xenova/ms-marco-MiniLM-L-6-v2",
62
+ source="src/cognitive/_core.py",
63
+ ),
64
+ ]
65
+
66
+
67
+ def _state_path() -> Path:
68
+ nexo_home = Path(os.environ.get("NEXO_HOME", "~/.nexo")).expanduser()
69
+ return nexo_home / "runtime" / "operations" / "model-warmup-state.json"
70
+
71
+
72
+ def _write_state(payload: dict[str, Any]) -> None:
73
+ path = _state_path()
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
76
+
77
+
78
+ def _warm_transformers(target: WarmupTarget) -> None:
79
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
80
+
81
+ kwargs: dict[str, str] = {}
82
+ if target.revision:
83
+ kwargs["revision"] = target.revision
84
+ AutoTokenizer.from_pretrained(target.model_id, **kwargs)
85
+ AutoModelForSequenceClassification.from_pretrained(target.model_id, **kwargs)
86
+
87
+
88
+ def _warm_fastembed_embedding(target: WarmupTarget) -> None:
89
+ from fastembed import TextEmbedding
90
+
91
+ model = TextEmbedding(target.model_id)
92
+ list(model.embed(["NEXO model warmup"]))
93
+
94
+
95
+ def _warm_fastembed_reranker(target: WarmupTarget) -> None:
96
+ from fastembed.rerank.cross_encoder import TextCrossEncoder
97
+
98
+ TextCrossEncoder(target.model_id)
99
+
100
+
101
+ def warm_target(target: WarmupTarget) -> None:
102
+ if target.kind == "transformers_sequence_classifier":
103
+ _warm_transformers(target)
104
+ return
105
+ if target.kind == "fastembed_embedding":
106
+ _warm_fastembed_embedding(target)
107
+ return
108
+ if target.kind == "fastembed_reranker":
109
+ _warm_fastembed_reranker(target)
110
+ return
111
+ raise ValueError(f"unknown warmup target kind: {target.kind}")
112
+
113
+
114
+ def target_to_json(target: WarmupTarget) -> dict[str, Any]:
115
+ return {key: value for key, value in asdict(target).items() if value is not None}
116
+
117
+
118
+ def run(args: argparse.Namespace) -> int:
119
+ targets = warmup_targets()
120
+ if args.dry_run:
121
+ payload = {
122
+ "ok": True,
123
+ "dry_run": True,
124
+ "targets": [target_to_json(target) for target in targets],
125
+ }
126
+ if args.json:
127
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
128
+ else:
129
+ for target in targets:
130
+ revision = f"@{target.revision}" if target.revision else ""
131
+ print(f"{target.name}: {target.model_id}{revision}")
132
+ return 0
133
+
134
+ started = time.time()
135
+ results: list[dict[str, Any]] = []
136
+ ok = True
137
+ for target in targets:
138
+ item = target_to_json(target)
139
+ item["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
140
+ try:
141
+ if not args.json:
142
+ revision = f"@{target.revision}" if target.revision else ""
143
+ print(f"[model-warmup] {target.name}: {target.model_id}{revision}", flush=True)
144
+ warm_target(target)
145
+ item["ok"] = True
146
+ except Exception as exc: # pragma: no cover - depends on host/network/cache
147
+ item["ok"] = False
148
+ item["error"] = str(exc)
149
+ if target.required:
150
+ ok = False
151
+ if not args.json:
152
+ print(f"[model-warmup] FAILED {target.name}: {exc}", file=sys.stderr, flush=True)
153
+ item["finished_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
154
+ results.append(item)
155
+
156
+ payload = {
157
+ "ok": ok,
158
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started)),
159
+ "finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
160
+ "targets": results,
161
+ }
162
+ _write_state(payload)
163
+ if args.json:
164
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
165
+ return 0 if ok or not args.strict else 1
166
+
167
+
168
+ def main() -> int:
169
+ parser = argparse.ArgumentParser(description="Predownload NEXO local model weights.")
170
+ parser.add_argument("--dry-run", action="store_true", help="List model targets without importing ML dependencies.")
171
+ parser.add_argument("--json", action="store_true", help="Print JSON output.")
172
+ parser.add_argument("--strict", action="store_true", help="Exit non-zero if any required model fails.")
173
+ return run(parser.parse_args())
174
+
175
+
176
+ if __name__ == "__main__":
177
+ raise SystemExit(main())
@@ -147,6 +147,27 @@ def handle_nexo_lifecycle_complete_canonical(
147
147
  return json.dumps(ack, ensure_ascii=False)
148
148
 
149
149
 
150
+ def handle_nexo_lifecycle_wait_for_diary(
151
+ event_id: str,
152
+ timeout_ms: int = 45_000,
153
+ poll_ms: int = 500,
154
+ ) -> str:
155
+ """Wait until a canonical lifecycle event has real diary evidence."""
156
+ try:
157
+ ack = lifecycle_events.wait_for_canonical_diary(
158
+ event_id=str(event_id or ""),
159
+ timeout_ms=int(timeout_ms or 0),
160
+ poll_ms=int(poll_ms or 500),
161
+ )
162
+ except Exception as exc:
163
+ return json.dumps({
164
+ "status": "retryable_error",
165
+ "reason": f"{type(exc).__name__}: {exc}",
166
+ "handler_threw": True,
167
+ }, ensure_ascii=False)
168
+ return json.dumps(ack, ensure_ascii=False)
169
+
170
+
150
171
  TOOLS = [
151
172
  (
152
173
  handle_nexo_lifecycle_event,
@@ -163,4 +184,9 @@ TOOLS = [
163
184
  "nexo_lifecycle_complete_canonical",
164
185
  "Confirm that Desktop finished executing the canonical_actions Brain handed out in a prior nexo_lifecycle_event call. Brain marks canonical_done_at only on this confirmation.",
165
186
  ),
187
+ (
188
+ handle_nexo_lifecycle_wait_for_diary,
189
+ "nexo_lifecycle_wait_for_diary",
190
+ "Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
191
+ ),
166
192
  ]
@@ -1,4 +1,4 @@
1
- <!-- nexo-claude-md-version: 2.1.5 -->
1
+ <!-- nexo-claude-md-version: 2.1.6 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — Cognitive Co-Operator
@@ -20,6 +20,16 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
20
20
  **Presentation:** {{NAME}} speaks first. Conversational greeting adapted to time of day. Tell what you HAVE DONE, not list pending items. Menu only if the user asks.
21
21
  <!-- nexo:end:startup -->
22
22
 
23
+ <!-- nexo:start:tools_at_startup -->
24
+ ## Tools availability at startup
25
+ Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name surfaced, schema not yet loaded). Deferred ≠ unavailable.
26
+
27
+ - If a needed `mcp__nexo__*` tool is not directly callable, run `ToolSearch` with `select:mcp__nexo__<name>[,<name>...]` to load its schema — BEFORE reporting a blocked session or missing capability.
28
+ - If `nexo_startup` itself appears deferred, load it via ToolSearch first — never start a session without a real SID.
29
+ - If ToolSearch still cannot resolve a `nexo_*` tool, then (and only then) treat it as a real runtime gap and surface it as a blocker.
30
+ - Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
31
+ <!-- nexo:end:tools_at_startup -->
32
+
23
33
  ## Protocol (6 rules)
24
34
  1. `nexo_startup` once per session and keep the returned `SID`.
25
35
  2. `nexo_heartbeat` on every user message.
@@ -1,4 +1,4 @@
1
- <!-- nexo-codex-agents-version: 1.2.4 -->
1
+ <!-- nexo-codex-agents-version: 1.2.5 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — NEXO Shared Brain for Codex
@@ -17,6 +17,14 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
17
17
  4. Execute overdue followups silently when appropriate.
18
18
  5. Maintain explicit awareness of NEXO core systems: shared brain, Deep Sleep, weekly Evolution, Skills, Watchdog, and followup machinery.
19
19
 
20
+ ## Tools availability at startup
21
+ Codex (and Claude Code) may list `mcp__nexo__*` tools as **deferred** at session start (name surfaced, schema not yet loaded). Deferred ≠ unavailable.
22
+
23
+ - If a needed `mcp__nexo__*` tool is not directly callable, resolve its schema via the client's tool-discovery path (Claude Code: `ToolSearch` with `select:mcp__nexo__<name>`) BEFORE reporting a blocked session or missing capability.
24
+ - If `nexo_startup` itself appears deferred, load it first — never start a session without a real SID.
25
+ - If discovery still cannot resolve a `nexo_*` tool, then (and only then) treat it as a real runtime gap and surface it as a blocker.
26
+ - Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
27
+
20
28
  ## Protocol (6 rules)
21
29
  1. `nexo_startup` once per session, then keep the returned `SID`.
22
30
  2. `nexo_heartbeat` on every user message.
@@ -1882,6 +1882,19 @@
1882
1882
  },
1883
1883
  "triggers_after": []
1884
1884
  },
1885
+ "nexo_lifecycle_wait_for_diary": {
1886
+ "description": "Wait for real session_diary evidence for a canonical lifecycle event before stop_session is allowed.",
1887
+ "category": "lifecycle",
1888
+ "source": "plugin:lifecycle_events",
1889
+ "requires": [],
1890
+ "provides": [],
1891
+ "internal_calls": [],
1892
+ "enforcement": {
1893
+ "level": "none",
1894
+ "rules": []
1895
+ },
1896
+ "triggers_after": []
1897
+ },
1885
1898
  "nexo_media_memory_add": {
1886
1899
  "description": "Store non-text artifact metadata",
1887
1900
  "category": "media",