nexo-brain 5.10.2 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.10.2",
3
+ "version": "6.0.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
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 `5.10.2` is the current packaged-runtime line: auto-bootstraps `brain/profile.json` from `brain/calibration.json` on `nexo update` when the profile file is missing, empty, or corrupt AND calibration carries at least one of `meta.role`, `meta.technical_level`, `name`, `language`. NEXO Desktop's *Preferencias Avanzado* tab used to render an empty `{}` for that block when the onboarding flow had been interrupted; now it either shows the seeded profile or a friendly explanation of what each file is for, paired with Desktop `v0.11.2` which adds header descriptions to both JSON blocks. Never overwrites a populated profile, never raises, idempotent. Also fixes a latent host-filesystem leak in `test_user_facing_caller_with_no_user_default_uses_alto` exposed by the v5.10.1 migration.
21
+ Version `6.0.0` is the current packaged-runtime line: **BREAKING** tier-only setup. Onboarding asks for one resonance tier (`maximo`/`alto`/`medio`/`bajo`) and that choice drives every backend via `src/resonance_tiers.json`; the per-backend model/effort prompts are gone and the legacy `client_runtime_profiles.{claude_code,codex}.{model,reasoning_effort}` are silently purged from `schedule.json` on upgrade. Protocol strictness is no longer configurable interactive TTY sessions run `strict`, non-TTY (crons, pipes, tests) run `lenient`; `NEXO_PROTOCOL_STRICTNESS` env, `preferences.protocol_strictness`, and the `default/normal/off/warn/soft` aliases are all removed. `preferences.show_pending_at_start` moves to NEXO Desktop's electron-store. The seven core hooks are now unified behind `src/hooks/manifest.json` (plugin and npm modes read the same file), two new hooks ship (`Notification` for live-session activity and `SubagentStop` for auto-closing stale `protocol_tasks`), and `auto_capture.py` is wired to both `UserPromptSubmit` and `PostToolUse` with a persistent 1h dedup table plus an automatic `nexo_learning_add` on correction matches. `~/.nexo/hooks_status.json` is published after every `registerAllCoreHooks()` so NEXO Desktop ≥0.12.0 can render Hooks activos X/Y. New `nexo-brain --skip` flag aliases `--yes`/`--defaults`. Full suite 1057 passed, 1 skipped.
22
+
23
+ Previously in `5.10.2`: auto-bootstraps `brain/profile.json` from `brain/calibration.json` on `nexo update` when the profile file is missing, empty, or corrupt AND calibration carries at least one of `meta.role`, `meta.technical_level`, `name`, `language`. NEXO Desktop's *Preferencias → Avanzado* tab used to render an empty `{}` for that block when the onboarding flow had been interrupted; now it either shows the seeded profile or a friendly explanation of what each file is for, paired with Desktop `v0.11.2` which adds header descriptions to both JSON blocks. Never overwrites a populated profile, never raises, idempotent. Also fixes a latent host-filesystem leak in `test_user_facing_caller_with_no_user_default_uses_alto` exposed by the v5.10.1 migration.
22
24
 
23
25
  Previously in `5.10.1`: silent, one-shot migration that recovers legacy `reasoning_effort="max"` (written by `nexo preferences --reasoning-effort max` before v5.9.0) into the new `preferences.default_resonance` map — any user who had configured `max` before v5.9.0 and never touched the new selector was silently falling back to `DEFAULT_RESONANCE="alto"` on interactive calls since the v5.10.0 update. `_run_runtime_post_sync()` runs `_migrate_effort_to_resonance()` exactly once: `max→maximo`, `xhigh→alto`, `high→medio`, `medium→bajo`. No-op when calibration or schedule already declares an explicit `default_resonance`; idempotent; conservative; never raises.
24
26
 
package/bin/nexo-brain.js CHANGED
@@ -60,6 +60,38 @@ const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _MODEL_DEFAULTS.claude_code.reasoni
60
60
  const DEFAULT_CODEX_MODEL = _MODEL_DEFAULTS.codex.model;
61
61
  const DEFAULT_CODEX_REASONING_EFFORT = _MODEL_DEFAULTS.codex.reasoning_effort || "";
62
62
 
63
+ // v6.0.0 — Hook manifest is the single source of truth for which hook
64
+ // handlers get registered. Both plugin mode (hooks/hooks.json) and npm
65
+ // mode (this installer's registerAllCoreHooks) read from the same file.
66
+ const HOOKS_MANIFEST_PATH = path.join(__dirname, "..", "src", "hooks", "manifest.json");
67
+ function _loadHooksManifest() {
68
+ try {
69
+ const raw = JSON.parse(fs.readFileSync(HOOKS_MANIFEST_PATH, "utf8"));
70
+ if (raw && Array.isArray(raw.hooks)) {
71
+ return raw;
72
+ }
73
+ } catch (_) {}
74
+ return { version: "1.0", hooks: [] };
75
+ }
76
+ const _HOOKS_MANIFEST = _loadHooksManifest();
77
+
78
+ // v6.0.0 — Resonance tiers JSON holds the (tier → backend → model+effort)
79
+ // mapping. The installer only reads ``default_tier`` and ``tiers`` keys;
80
+ // the real resolution happens on the Python side via resonance_map.py.
81
+ const RESONANCE_TIERS_PATH = path.join(__dirname, "..", "src", "resonance_tiers.json");
82
+ function _loadResonanceTiers() {
83
+ try {
84
+ const raw = JSON.parse(fs.readFileSync(RESONANCE_TIERS_PATH, "utf8"));
85
+ if (raw && raw.tiers && typeof raw.tiers === "object") {
86
+ return raw;
87
+ }
88
+ } catch (_) {}
89
+ return { tiers: {}, default_tier: "alto" };
90
+ }
91
+ const _RESONANCE_TIERS = _loadResonanceTiers();
92
+ const RESONANCE_TIER_NAMES = ["maximo", "alto", "medio", "bajo"];
93
+ const DEFAULT_RESONANCE_TIER = _RESONANCE_TIERS.default_tier || "alto";
94
+
63
95
  function isEphemeralInstall(nexoHome) {
64
96
  const homeDir = require("os").homedir();
65
97
  const allowEphemeral = process.env.NEXO_ALLOW_EPHEMERAL_INSTALL === "1";
@@ -203,6 +235,7 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
203
235
  "runtime_power.py",
204
236
  "requirements.txt",
205
237
  "model_defaults.json",
238
+ "resonance_tiers.json",
206
239
  ];
207
240
  const discoveredRootModules = fs.existsSync(srcDir)
208
241
  ? fs.readdirSync(srcDir)
@@ -494,73 +527,106 @@ const ALL_PROCESSES = [
494
527
  * key: unique identifier to detect if already registered (avoids duplicates)
495
528
  * timeout: seconds before Claude Code kills the hook (prevents hangs)
496
529
  */
497
- const ALL_CORE_HOOKS = [
498
- { event: "SessionStart", key: "session-start-ts", commandTemplate: (nexoHome) =>
499
- `date +%s > ${path.join(nexoHome, "operations", ".session-start-ts")}`,
500
- timeout: 2, purpose: "Session timing" },
501
- { event: "SessionStart", key: "daily-briefing-check.sh", script: "daily-briefing-check.sh",
502
- timeout: 5, purpose: "Briefing schedule check" },
503
- { event: "SessionStart", key: "session-start.sh", script: "session-start.sh",
504
- timeout: 35, purpose: "Briefing + context" },
505
- { event: "Stop", key: "session-stop.sh", script: "session-stop.sh",
506
- timeout: 10, purpose: "POSTMORTEM — the most important" },
507
- { event: "PostToolUse", key: "capture-tool-logs.sh", script: "capture-tool-logs.sh",
508
- timeout: 5, purpose: "Operation capture" },
509
- { event: "PostToolUse", key: "capture-session.sh", script: "capture-session.sh",
510
- timeout: 3, purpose: "Sensory register (session_buffer.jsonl)" },
511
- { event: "PostToolUse", key: "inbox-hook.sh", script: "inbox-hook.sh",
512
- timeout: 5, purpose: "Inter-session messaging" },
513
- { event: "PreCompact", key: "pre-compact.sh", script: "pre-compact.sh",
514
- timeout: 10, purpose: "Memory preservation" },
515
- { event: "PostCompact", key: "post-compact.sh", script: "post-compact.sh",
516
- timeout: 10, purpose: "Memory restoration" },
517
- ];
530
+ // v6.0.0 Core hook list is driven entirely by src/hooks/manifest.json.
531
+ // Each entry declares the Claude Code event and the relative path to the
532
+ // .py handler inside the installed runtime. Every handler receives a
533
+ // short alias key used to detect existing registrations in settings.hooks.
534
+ const HOOK_TIMEOUTS = {
535
+ SessionStart: 40,
536
+ Stop: 15,
537
+ PreCompact: 15,
538
+ PostCompact: 15,
539
+ UserPromptSubmit: 5,
540
+ PostToolUse: 20,
541
+ Notification: 3,
542
+ SubagentStop: 10,
543
+ };
544
+
545
+ function _manifestHookEntries() {
546
+ return (_HOOKS_MANIFEST.hooks || []).map((entry) => {
547
+ const handlerRel = String(entry.handler || "").trim();
548
+ const handlerBase = handlerRel.split("/").pop() || handlerRel;
549
+ return {
550
+ event: entry.event,
551
+ handler: handlerRel,
552
+ key: handlerBase,
553
+ critical: Boolean(entry.critical),
554
+ timeout: HOOK_TIMEOUTS[entry.event] || 10,
555
+ };
556
+ }).filter((h) => h.event && h.handler);
557
+ }
558
+
559
+ function _hookCommand(hook, hooksDir, nexoHome) {
560
+ // Resolve handler path under the installed runtime. hooksDir points to
561
+ // ~/.nexo/hooks, which is the copy of src/hooks/ at install time.
562
+ const handlerFile = path.basename(hook.handler);
563
+ const runtimePath = path.join(hooksDir, handlerFile);
564
+ return `NEXO_HOME=${nexoHome} python3 ${runtimePath}`;
565
+ }
566
+
567
+ function _writeHooksStatus(nexoHome, manifestEntries, registrations) {
568
+ // Publish ~/.nexo/hooks_status.json so NEXO Desktop can render the
569
+ // "Hooks activos X/Y" widget without peeking into settings.json.
570
+ try {
571
+ const now = new Date();
572
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
573
+ const total = manifestEntries.length;
574
+ const registered = registrations.filter((r) => r.status === "active").length;
575
+ const healthy = total > 0 && registered === total;
576
+ const payload = {
577
+ generated_at: now.toISOString().replace(/\.\d+Z$/, "Z"),
578
+ nexo_version: pkgJson.version || "unknown",
579
+ total,
580
+ registered,
581
+ healthy,
582
+ hooks: registrations,
583
+ };
584
+ fs.mkdirSync(nexoHome, { recursive: true });
585
+ fs.writeFileSync(
586
+ path.join(nexoHome, "hooks_status.json"),
587
+ JSON.stringify(payload, null, 2) + "\n",
588
+ );
589
+ } catch (_) {}
590
+ }
518
591
 
519
592
  /**
520
- * Register all 8 core hooks in settings.hooks.
521
- * Additive + auto-migrate: adds missing hooks, updates stale paths, never removes user's custom ones.
593
+ * Register every hook declared by src/hooks/manifest.json into the
594
+ * Claude Code settings file. Idempotent, never removes user-owned hooks.
595
+ * Writes ~/.nexo/hooks_status.json after each run so NEXO Desktop can
596
+ * display hook health without parsing settings.json.
522
597
  */
523
598
  function registerAllCoreHooks(settings, hooksDir, nexoHome) {
524
599
  if (!settings.hooks) settings.hooks = {};
525
600
 
526
- // Ensure operations dir exists for timestamp file
527
- const opsDir = path.join(nexoHome, "operations");
528
- fs.mkdirSync(opsDir, { recursive: true });
601
+ // Ensure operations dir exists for any hook that wants to drop a file there
602
+ // (session-start.py writes .session-start-ts here).
603
+ fs.mkdirSync(path.join(nexoHome, "operations"), { recursive: true });
529
604
 
530
- for (const hook of ALL_CORE_HOOKS) {
531
- if (!settings.hooks[hook.event]) settings.hooks[hook.event] = [];
605
+ const manifestEntries = _manifestHookEntries();
606
+ const registrations = [];
532
607
 
533
- // Build the canonical command for this hook
534
- let command;
535
- if (hook.commandTemplate) {
536
- command = hook.commandTemplate(nexoHome);
537
- } else {
538
- command = `NEXO_HOME=${nexoHome} bash ${path.join(hooksDir, hook.script)}`;
539
- }
608
+ for (const hook of manifestEntries) {
609
+ if (!settings.hooks[hook.event]) settings.hooks[hook.event] = [];
540
610
 
541
- // Claude Code settings.hooks supports two formats:
542
- // Flat: [{type:"command", command:"..."}]
543
- // Nested: [{matcher:"*", hooks:[{type:"command", command:"..."}]}]
544
- // We need to search and update in both formats.
611
+ const command = _hookCommand(hook, hooksDir, nexoHome);
612
+ let status = "active";
545
613
  let found = false;
546
614
 
547
615
  for (let idx = 0; idx < settings.hooks[hook.event].length; idx++) {
548
616
  const entry = settings.hooks[hook.event][idx];
549
617
  if (entry.hooks && Array.isArray(entry.hooks)) {
550
- // Nested format: {matcher, hooks: [...]}
551
618
  if (!entry.matcher) entry.matcher = "*";
552
619
  const subIdx = entry.hooks.findIndex(
553
- (h) => h.command && h.command.includes(hook.key)
620
+ (h) => h.command && h.command.includes(hook.key),
554
621
  );
555
622
  if (subIdx !== -1) {
556
623
  const existing = entry.hooks[subIdx];
557
624
  if (existing.command !== command) existing.command = command;
558
- if (hook.timeout && !existing.timeout) existing.timeout = hook.timeout;
625
+ if (hook.timeout) existing.timeout = hook.timeout;
559
626
  found = true;
560
627
  break;
561
628
  }
562
629
  } else if (entry.command && entry.command.includes(hook.key)) {
563
- // Legacy flat format: migrate to nested matcher+hooks.
564
630
  const migrated = { type: "command", command };
565
631
  if (hook.timeout) migrated.timeout = hook.timeout;
566
632
  settings.hooks[hook.event][idx] = {
@@ -580,6 +646,66 @@ function registerAllCoreHooks(settings, hooksDir, nexoHome) {
580
646
  hooks: [newHook],
581
647
  });
582
648
  }
649
+
650
+ // Confirm the handler file exists on disk; if not, mark error.
651
+ const handlerAbs = path.join(hooksDir, path.basename(hook.handler));
652
+ if (!fs.existsSync(handlerAbs)) {
653
+ status = "error";
654
+ }
655
+
656
+ registrations.push({
657
+ event: hook.event,
658
+ handler: path.basename(hook.handler),
659
+ status,
660
+ });
661
+ }
662
+
663
+ _writeHooksStatus(nexoHome, manifestEntries, registrations);
664
+
665
+ // v6.0.0 — also purge any stale v5.x hook commands that referenced the
666
+ // old .sh scripts directly (post-compact.sh, heartbeat-user-msg.sh,
667
+ // protocol-guardrail.sh, etc.) so a pre-existing install migrates
668
+ // cleanly to the manifest-driven world. Only removes NEXO-owned
669
+ // entries, leaves user-custom hooks alone.
670
+ const LEGACY_KEYS = [
671
+ "daily-briefing-check.sh",
672
+ "capture-tool-logs.sh",
673
+ "capture-session.sh",
674
+ "inbox-hook.sh",
675
+ "heartbeat-posttool.sh",
676
+ "heartbeat-user-msg.sh",
677
+ "protocol-guardrail.sh",
678
+ "protocol-pretool-guardrail.sh",
679
+ "post-compact.sh",
680
+ ".session-start-ts",
681
+ ];
682
+ for (const event of Object.keys(settings.hooks)) {
683
+ const entries = settings.hooks[event];
684
+ if (!Array.isArray(entries)) continue;
685
+ const manifestEventHandlers = new Set(
686
+ manifestEntries.filter((h) => h.event === event).map((h) => h.key),
687
+ );
688
+ for (let i = entries.length - 1; i >= 0; i--) {
689
+ const entry = entries[i];
690
+ if (entry && entry.hooks && Array.isArray(entry.hooks)) {
691
+ entry.hooks = entry.hooks.filter((h) => {
692
+ const cmd = String(h.command || "");
693
+ // Keep anything the manifest owns.
694
+ if (Array.from(manifestEventHandlers).some((k) => cmd.includes(k))) {
695
+ return true;
696
+ }
697
+ // Drop strictly-legacy NEXO-owned commands.
698
+ if (LEGACY_KEYS.some((legacy) => cmd.includes(legacy))) {
699
+ return false;
700
+ }
701
+ return true;
702
+ });
703
+ if (entry.hooks.length === 0) {
704
+ entries.splice(i, 1);
705
+ }
706
+ }
707
+ }
708
+ if (entries.length === 0) delete settings.hooks[event];
583
709
  }
584
710
  }
585
711
 
@@ -620,15 +746,14 @@ function getDefaultSchedule(timezone) {
620
746
  default_terminal_client: "claude_code",
621
747
  automation_enabled: true,
622
748
  automation_backend: "claude_code",
749
+ // v6.0.0 — model/reasoning_effort have moved to src/resonance_tiers.json
750
+ // keyed by the operator's preferences.default_resonance. The shape
751
+ // below stays so that downstream readers that iterate the profile
752
+ // dict do not need a guard, but the concrete values no longer live
753
+ // in schedule.json.
623
754
  client_runtime_profiles: {
624
- claude_code: {
625
- model: DEFAULT_CLAUDE_CODE_MODEL,
626
- reasoning_effort: DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
627
- },
628
- codex: {
629
- model: DEFAULT_CODEX_MODEL,
630
- reasoning_effort: DEFAULT_CODEX_REASONING_EFFORT,
631
- },
755
+ claude_code: {},
756
+ codex: {},
632
757
  },
633
758
  client_install_preferences: {
634
759
  claude_code: "ask",
@@ -796,15 +921,12 @@ async function askChoice(question, options, defaultValue) {
796
921
  }
797
922
 
798
923
  function defaultClientRuntimeProfiles() {
924
+ // v6.0.0 — no more model/reasoning_effort here. The resonance tier
925
+ // (preferences.default_resonance in calibration.json) plus
926
+ // src/resonance_tiers.json drive the actual model and effort at runtime.
799
927
  return {
800
- claude_code: {
801
- model: DEFAULT_CLAUDE_CODE_MODEL,
802
- reasoning_effort: DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
803
- },
804
- codex: {
805
- model: DEFAULT_CODEX_MODEL,
806
- reasoning_effort: DEFAULT_CODEX_REASONING_EFFORT,
807
- },
928
+ claude_code: {},
929
+ codex: {},
808
930
  };
809
931
  }
810
932
 
@@ -820,85 +942,23 @@ function formatRuntimeProfile(profile = {}) {
820
942
  return effort ? `${model}/${effort}` : model;
821
943
  }
822
944
 
823
- function runtimeProfileCatalog(lang, client) {
945
+ // v6.0.0 Tier-only setup. Onboarding asks the operator for one resonance
946
+ // tier (maximo / alto / medio / bajo) and that choice drives every backend
947
+ // via src/resonance_tiers.json. No more model or effort questions.
948
+ async function askResonanceTier(lang, currentTier) {
824
949
  const recommended = lang === "es" ? " (recomendado)" : " (recommended)";
825
- if (client === "claude_code") {
826
- return {
827
- modelQuestion: ` ¿Qué modelo debe usar ${runtimeClientLabel(client)} para chat y background cuando sea el cliente/backend activo?`,
828
- modelQuestionEn: ` Which model should ${runtimeClientLabel(client)} use for chat and background when it is the active client/backend?`,
829
- effortQuestion: ` ¿Qué nivel de esfuerzo debe usar ${runtimeClientLabel(client)}?`,
830
- effortQuestionEn: ` Which effort level should ${runtimeClientLabel(client)} use?`,
831
- customModelQuestion: ` Escribe el alias/nombre de modelo para ${runtimeClientLabel(client)} > `,
832
- customModelQuestionEn: ` Enter the model alias/name for ${runtimeClientLabel(client)} > `,
833
- customEffortQuestion: ` Escribe el effort para ${runtimeClientLabel(client)} (vacío = default) > `,
834
- customEffortQuestionEn: ` Enter the effort for ${runtimeClientLabel(client)} (blank = default) > `,
835
- modelDefault: DEFAULT_CLAUDE_CODE_MODEL,
836
- effortDefault: "",
837
- modelOptions: [
838
- { value: DEFAULT_CLAUDE_CODE_MODEL, label: `Opus 4.6 with 1M context${recommended}` },
839
- { value: "claude-opus-4-6", label: "Opus 4.6" },
840
- { value: "sonnet", label: "Sonnet latest" },
841
- { value: "custom", label: lang === "es" ? "Modelo personalizado" : "Custom model" },
842
- ],
843
- effortOptions: [
844
- { value: "", label: lang === "es" ? `Effort por defecto${recommended}` : `Default effort${recommended}` },
845
- { value: "high", label: "high" },
846
- { value: "max", label: "max" },
847
- { value: "custom", label: lang === "es" ? "Effort personalizado" : "Custom effort" },
848
- ],
849
- };
850
- }
851
-
852
- return {
853
- modelQuestion: ` ¿Qué modelo debe usar ${runtimeClientLabel(client)} para chat y background cuando sea el cliente/backend activo?`,
854
- modelQuestionEn: ` Which model should ${runtimeClientLabel(client)} use for chat and background when it is the active client/backend?`,
855
- effortQuestion: ` ¿Qué razonamiento debe usar ${runtimeClientLabel(client)}?`,
856
- effortQuestionEn: ` Which reasoning effort should ${runtimeClientLabel(client)} use?`,
857
- customModelQuestion: ` Escribe el nombre del modelo para ${runtimeClientLabel(client)} > `,
858
- customModelQuestionEn: ` Enter the model name for ${runtimeClientLabel(client)} > `,
859
- customEffortQuestion: ` Escribe el reasoning effort para ${runtimeClientLabel(client)} > `,
860
- customEffortQuestionEn: ` Enter the reasoning effort for ${runtimeClientLabel(client)} > `,
861
- modelDefault: DEFAULT_CODEX_MODEL,
862
- effortDefault: DEFAULT_CODEX_REASONING_EFFORT,
863
- modelOptions: [
864
- { value: "gpt-5.4", label: `GPT-5.4${recommended}` },
865
- { value: "gpt-5.4-pro", label: "GPT-5.4 Pro" },
866
- { value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
867
- { value: "custom", label: lang === "es" ? "Modelo personalizado" : "Custom model" },
868
- ],
869
- effortOptions: [
870
- { value: "xhigh", label: `xhigh${recommended}` },
871
- { value: "high", label: "high" },
872
- { value: "medium", label: "medium" },
873
- { value: "low", label: "low" },
874
- { value: "none", label: "none" },
875
- { value: "custom", label: lang === "es" ? "Effort personalizado" : "Custom effort" },
876
- ],
877
- };
878
- }
879
-
880
- async function askClientRuntimeProfile({ lang, client, currentProfile }) {
881
- const catalog = runtimeProfileCatalog(lang, client);
882
- const modelQuestion = lang === "es" ? catalog.modelQuestion : catalog.modelQuestionEn;
883
- const effortQuestion = lang === "es" ? catalog.effortQuestion : catalog.effortQuestionEn;
884
- const customModelQuestion = lang === "es" ? catalog.customModelQuestion : catalog.customModelQuestionEn;
885
- const customEffortQuestion = lang === "es" ? catalog.customEffortQuestion : catalog.customEffortQuestionEn;
886
- let model = await askChoice(modelQuestion, catalog.modelOptions, currentProfile.model || catalog.modelDefault);
887
- if (model === "custom") {
888
- model = (await ask(customModelQuestion)).trim() || catalog.modelDefault;
889
- }
890
- let reasoningEffort = await askChoice(
891
- effortQuestion,
892
- catalog.effortOptions,
893
- currentProfile.reasoning_effort ?? catalog.effortDefault,
894
- );
895
- if (reasoningEffort === "custom") {
896
- reasoningEffort = (await ask(customEffortQuestion)).trim();
897
- }
898
- return {
899
- model,
900
- reasoning_effort: reasoningEffort,
901
- };
950
+ const question = lang === "es"
951
+ ? " ¿Qué nivel de potencia quieres por defecto para tus conversaciones?"
952
+ : " Which default power level do you want for your conversations?";
953
+ const options = [
954
+ { value: "maximo", label: lang === "es" ? "máximo" : "maximum" },
955
+ { value: "alto", label: (lang === "es" ? "alto" : "high") + recommended },
956
+ { value: "medio", label: lang === "es" ? "medio" : "medium" },
957
+ { value: "bajo", label: lang === "es" ? "bajo" : "low" },
958
+ ];
959
+ const fallback = RESONANCE_TIER_NAMES.includes(currentTier) ? currentTier : DEFAULT_RESONANCE_TIER;
960
+ const chosen = await askChoice(question, options, fallback);
961
+ return RESONANCE_TIER_NAMES.includes(chosen) ? chosen : DEFAULT_RESONANCE_TIER;
902
962
  }
903
963
 
904
964
  function defaultClientSetup(detected) {
@@ -1063,28 +1123,13 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
1063
1123
  log(strings.desktopManual);
1064
1124
  }
1065
1125
 
1066
- if (!useDefaults) {
1067
- const activeRuntimeClients = Array.from(new Set([
1068
- setup.default_terminal_client,
1069
- ...(setup.automation_enabled && setup.automation_backend !== "none" ? [setup.automation_backend] : []),
1070
- ].filter(Boolean)));
1071
- for (const client of activeRuntimeClients) {
1072
- setup.client_runtime_profiles[client] = await askClientRuntimeProfile({
1073
- lang,
1074
- client,
1075
- currentProfile: setup.client_runtime_profiles[client] || defaultClientRuntimeProfiles()[client] || {},
1076
- });
1077
- }
1078
- }
1126
+ // v6.0.0 — no per-client model/effort prompts. A single tier question
1127
+ // (asked by the main installer flow, not here) will write
1128
+ // preferences.default_resonance into calibration.json. All runtime
1129
+ // resolution then flows through src/resonance_tiers.json.
1079
1130
 
1080
- const defaultProfile = formatRuntimeProfile(
1081
- setup.client_runtime_profiles[setup.default_terminal_client] || defaultClientRuntimeProfiles()[setup.default_terminal_client] || {}
1082
- );
1083
- const backendProfile = setup.automation_enabled && setup.automation_backend !== "none"
1084
- ? formatRuntimeProfile(
1085
- setup.client_runtime_profiles[setup.automation_backend] || defaultClientRuntimeProfiles()[setup.automation_backend] || {}
1086
- )
1087
- : "";
1131
+ const defaultProfile = "tier";
1132
+ const backendProfile = setup.automation_enabled && setup.automation_backend !== "none" ? "tier" : "";
1088
1133
  log(strings.summary(setup.default_terminal_client, defaultProfile, setup.automation_backend, backendProfile, setup.automation_enabled));
1089
1134
  return { setup, detected };
1090
1135
  }
@@ -1508,8 +1553,12 @@ WantedBy=timers.target
1508
1553
  }
1509
1554
 
1510
1555
  async function main() {
1511
- // Non-interactive mode: --defaults or --yes skips all prompts
1512
- const useDefaults = process.argv.includes("--defaults") || process.argv.includes("--yes") || process.argv.includes("-y");
1556
+ // Non-interactive mode: --defaults, --yes, --skip, or -y all skip prompts
1557
+ // and apply the recommended defaults end-to-end (v6.0.0 adds --skip).
1558
+ const useDefaults = process.argv.includes("--defaults")
1559
+ || process.argv.includes("--yes")
1560
+ || process.argv.includes("--skip")
1561
+ || process.argv.includes("-y");
1513
1562
 
1514
1563
  console.log("");
1515
1564
  console.log(
@@ -2158,13 +2207,16 @@ async function main() {
2158
2207
  console.log("");
2159
2208
  }
2160
2209
 
2161
- // Step 2: User's name (P2)
2162
- let userName = "";
2210
+ // Step 2: User's name (P2) — v6.0.0 empty input falls through to "Usuario"
2211
+ // instead of keeping an empty string. The calibration file always ships
2212
+ // with a concrete user.name so downstream tooling does not need guards.
2213
+ let userName = "Usuario";
2163
2214
  if (!useDefaults) {
2164
2215
  const nameInput = await ask(t.askUserName);
2165
- userName = nameInput.trim();
2166
- if (userName) {
2167
- log(t.userGreet(userName));
2216
+ const trimmedName = nameInput.trim();
2217
+ userName = trimmedName || "Usuario";
2218
+ if (trimmedName) {
2219
+ log(t.userGreet(trimmedName));
2168
2220
  console.log("");
2169
2221
  }
2170
2222
  }
@@ -2175,6 +2227,17 @@ async function main() {
2175
2227
  log(t.agentConfirm(operatorName));
2176
2228
  console.log("");
2177
2229
 
2230
+ // Step 3b (v6.0.0): Resonance tier — the ONE power-level question. Drives
2231
+ // every runtime call for both Claude Code and Codex via resonance_tiers.json.
2232
+ let resonanceTier = DEFAULT_RESONANCE_TIER;
2233
+ if (!useDefaults) {
2234
+ resonanceTier = await askResonanceTier(lang, DEFAULT_RESONANCE_TIER);
2235
+ log(lang === "es"
2236
+ ? `Potencia por defecto: ${resonanceTier}.`
2237
+ : `Default power: ${resonanceTier}.`);
2238
+ console.log("");
2239
+ }
2240
+
2178
2241
  // Step 4: Personality Calibration (P4-P8)
2179
2242
  let autonomyLevel = "full", communicationStyle = "concise", honestyLevel = "firm-pushback", proactivityLevel = "proactive", errorHandling = "brief-fix";
2180
2243
 
@@ -2203,16 +2266,31 @@ async function main() {
2203
2266
  log(`Calibrated: autonomy=${autonomyLevel}, communication=${communicationStyle}, honesty=${honestyLevel}, proactivity=${proactivityLevel}, errors=${errorHandling}`);
2204
2267
  console.log("");
2205
2268
 
2206
- // Save calibration
2269
+ // Save calibration (v6.0.0 — canonical nested shape with
2270
+ // preferences.default_resonance as the one knob for tier-only setup).
2207
2271
  const calibration = {
2208
- language: lang,
2209
- user_name: userName,
2210
- autonomy: autonomyLevel,
2211
- communication: communicationStyle,
2212
- honesty: honestyLevel,
2213
- proactivity: proactivityLevel,
2214
- error_handling: errorHandling,
2215
- auto_install: "ask", // default, updated later if user answers P11
2272
+ version: 1,
2273
+ created: new Date().toISOString().slice(0, 10),
2274
+ user: {
2275
+ name: userName,
2276
+ language: lang,
2277
+ assistant_name: operatorName,
2278
+ },
2279
+ personality: {
2280
+ autonomy: autonomyLevel,
2281
+ communication: communicationStyle,
2282
+ honesty: honestyLevel,
2283
+ proactivity: proactivityLevel,
2284
+ error_handling: errorHandling,
2285
+ },
2286
+ preferences: {
2287
+ menu_on_demand: true,
2288
+ default_resonance: resonanceTier,
2289
+ report_style: "essentials_only",
2290
+ execution_first: true,
2291
+ },
2292
+ meta: {},
2293
+ auto_install: "ask", // updated later if user answers P11
2216
2294
  calibrated_at: new Date().toISOString(),
2217
2295
  };
2218
2296
  // Ensure NEXO_HOME and brain dir exist before writing calibration
@@ -2223,41 +2301,61 @@ async function main() {
2223
2301
  JSON.stringify(calibration, null, 2)
2224
2302
  );
2225
2303
 
2226
- // Step 5: Deep scan (P9)
2227
- let doScan = false;
2228
- let doCaffeinate = false;
2229
- let doDashboard = false;
2304
+ // Step 5: Deep scan (P9) — v6.0.0 defaults flip to ON when running in
2305
+ // --yes/--skip mode; the interactive prompt below defaults to "yes" too
2306
+ // so a bare ENTER keeps the recommended setup.
2307
+ let doScan = useDefaults;
2308
+ let doCaffeinate = useDefaults && platform === "darwin";
2309
+ let doDashboard = useDefaults;
2230
2310
  let autoInstall = useDefaults ? "auto" : "ask";
2311
+ // v6.0.0 — bare ENTER on each of these prompts is interpreted as "yes"
2312
+ // because the recommended defaults are all on. An explicit "2" or "n"
2313
+ // turns the feature off.
2314
+ const answerIsYesDefault = (answer) => {
2315
+ const trimmed = String(answer || "").trim().toLowerCase();
2316
+ if (!trimmed) return true;
2317
+ if (trimmed === "1" || trimmed.startsWith("y") || trimmed.startsWith("s")) return true;
2318
+ return false;
2319
+ };
2231
2320
  if (!useDefaults) {
2232
2321
  const scanAnswer = await ask(t.scanQ);
2233
- doScan = scanAnswer.trim() === "1" || scanAnswer.trim().toLowerCase().startsWith("y") || scanAnswer.trim().toLowerCase().startsWith("s");
2322
+ doScan = answerIsYesDefault(scanAnswer);
2234
2323
  console.log("");
2235
2324
 
2236
2325
  // Step 6: Caffeinate (P10) — macOS only
2237
2326
  if (platform === "darwin") {
2238
2327
  const caffeinateAnswer = await ask(t.caffeinateQ);
2239
- doCaffeinate = caffeinateAnswer.trim() === "1" || caffeinateAnswer.trim().toLowerCase().startsWith("y") || caffeinateAnswer.trim().toLowerCase().startsWith("s");
2328
+ doCaffeinate = answerIsYesDefault(caffeinateAnswer);
2240
2329
  log(doCaffeinate ? `✓ ${t.caffYes}` : t.caffNo);
2241
2330
  console.log("");
2242
2331
  }
2243
2332
 
2244
2333
  // Step 6b: Dashboard — always-on web UI
2245
2334
  const dashAnswer = await ask(t.dashboardQ);
2246
- doDashboard = dashAnswer.trim() === "1" || dashAnswer.trim().toLowerCase().startsWith("y") || dashAnswer.trim().toLowerCase().startsWith("s");
2335
+ doDashboard = answerIsYesDefault(dashAnswer);
2247
2336
  log(doDashboard ? `✓ ${t.dashYes}` : t.dashNo);
2248
2337
  console.log("");
2249
2338
 
2250
2339
  // Step 7: Auto-install permission (P11)
2251
2340
  const autoInstallAnswer = await ask(t.autoInstallQ);
2252
- autoInstall = (autoInstallAnswer.trim() === "1" || autoInstallAnswer.trim().toLowerCase().startsWith("y") || autoInstallAnswer.trim().toLowerCase().startsWith("s")) ? "auto" : "ask";
2341
+ autoInstall = answerIsYesDefault(autoInstallAnswer) ? "auto" : "ask";
2253
2342
  calibration.auto_install = autoInstall;
2254
2343
  log(`✓ ${autoInstall === "auto" ? t.autoInstallYes : t.autoInstallNo}`);
2255
2344
  console.log("");
2256
2345
  } else {
2257
- log("Skipping interactive setup (non-interactive mode).");
2346
+ log("Skipping interactive setup (non-interactive mode, defaults applied).");
2347
+ calibration.auto_install = autoInstall;
2258
2348
  console.log("");
2259
2349
  }
2260
2350
 
2351
+ // Persist the updated calibration (auto_install may have changed post-write above).
2352
+ try {
2353
+ fs.writeFileSync(
2354
+ path.join(NEXO_HOME, "brain", "calibration.json"),
2355
+ JSON.stringify(calibration, null, 2)
2356
+ );
2357
+ } catch (_) {}
2358
+
2261
2359
  const clientConfig = await configureClientSetup({
2262
2360
  lang,
2263
2361
  useDefaults,