nexo-brain 5.8.1 → 5.9.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.8.1",
3
+ "version": "5.9.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,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 `5.8.1` is the current packaged-runtime line: closes a self-reinforcing `launchctl kickstart -k` loop in the watchdog that wedged deep-sleep Phase 2 between 2026-04-14 and 2026-04-17. The cron wrapper now INSERTs an in-flight row (`ended_at=NULL`) at start and traps SIGTERM/INT/HUP to close it with `exit_code=143` instead of vanishing from `cron_runs`. The watchdog interprets in-flight rows as "currently running" and only re-executes after verifying the worker process is dead. `extract.py` classifies CLI failures into transient (`overloaded_error`, rate-limit, timeout, signal retried next run) and deterministic (skipped after `MAX_POISON_ATTEMPTS`), and passes a slim shared-context (200 head lines + metadata) instead of the full 400+ KB dump. A new `auto_update._heal_deep_sleep_runtime()` repairs existing installs silently on the next `nexo update`: poisoned checkpoints, stale locks, dangling `cron_runs` rows, and bloated `.watchdog-fails` counters.
21
+ Version `5.9.0` is the current packaged-runtime line: every Claude/Codex invocation now flows through a central **resonance map** and a **unified session log**. Four tiers (`MAXIMO` / `ALTO` / `MEDIO` / `BAJO`) each resolve to a concrete `(model, reasoning_effort)` pair per backend. User-facing callers (`nexo chat`, Desktop new conversation, interactive `nexo update`) honour the user's `default_resonance` preference; system-owned callers (deep-sleep, evolution, catchup, GBP posts, …) run at a fixed tier chosen per caller in `src/resonance_map.py` the user's preference never downgrades a cron we decided needs `MAXIMO`. Unknown callers raise `UnregisteredCallerError`. Migration #41 adds `caller`, `session_type`, `started_at`, `ended_at`, `pid`, `resonance_tier` to `automation_runs`; interactive sessions record a row at spawn (with `ended_at=NULL`) and update it on close, so the Brain now has a single source of truth for every Claude/Codex call regardless of origin. New `nexo preferences --resonance` CLI. New MCP tools `nexo_session_log_create` / `nexo_session_log_close` let NEXO Desktop (which spawns `claude` directly from its TypeScript process) feed the same log.
22
+
23
+ Previously in `5.8.2`: the Brain core no longer auto-classifies `followups` and `reminders` on behalf of agents. v5.8.0's `classify_task()` heuristic (NEXO-specific ID prefixes `NF-PROTOCOL-*` / `NF-DS-*` / `NF-AUDIT-*`, Spanish user-verbs `debes` / `revisar` / `firmar`, agent keywords `monitor` / `auditoría diaria` / `checkpoint`) was fine for NEXO's own DB but bled convention into every third-party agent plugged into the shared Brain. The core now persists `internal=0` and `owner=NULL` when the caller omits them, and clients that want automatic classification (NEXO Desktop does, via its `_legacyClassifyOwner` helpers) compute it themselves and pass the result. Migration #40 keeps the columns + indexes; rows already backfilled by v5.8.0 keep their values. `normalise_owner` still explicitly rejects the string `"nexo"` so legacy hardcoding cannot sneak back in.
24
+
25
+ Previously in `5.8.1`: closes a self-reinforcing `launchctl kickstart -k` loop in the watchdog that wedged deep-sleep Phase 2 between 2026-04-14 and 2026-04-17. The cron wrapper now INSERTs an in-flight row (`ended_at=NULL`) at start and traps SIGTERM/INT/HUP to close it with `exit_code=143` instead of vanishing from `cron_runs`. The watchdog interprets in-flight rows as "currently running" and only re-executes after verifying the worker process is dead. `extract.py` classifies CLI failures into transient (`overloaded_error`, rate-limit, timeout, signal — retried next run) and deterministic (skipped after `MAX_POISON_ATTEMPTS`), and passes a slim shared-context (200 head lines + metadata) instead of the full 400+ KB dump. A new `auto_update._heal_deep_sleep_runtime()` repairs existing installs silently on the next `nexo update`: poisoned checkpoints, stale locks, dangling `cron_runs` rows, and bloated `.watchdog-fails` counters.
22
26
 
23
27
  Previously in `5.8.0`: first-class `internal` and `owner` columns on `followups` and `reminders`. Migration #40 adds both fields with an idempotent one-shot backfill, so the "who does this task belong to?" classification moves from client-side regex (Desktop) to persistent storage every MCP client shares. Taxonomy is intentionally generic — `owner in {user, waiting, agent, shared}` — so third-party agents plugging into the shared Brain can render whatever assistant label they carry without inheriting NEXO branding. `nexo_reminder_create`, `nexo_reminder_update`, `nexo_followup_create`, and `nexo_followup_update` gain optional `internal` and `owner` parameters that win over the default heuristic.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.8.1",
3
+ "version": "5.9.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -180,68 +180,188 @@ def _append_stderr(stderr: str, message: str) -> str:
180
180
  return "\n".join(bits) + "\n"
181
181
 
182
182
 
183
- def _record_automation_run(
183
+ def _record_automation_start(
184
184
  *,
185
+ caller: str,
185
186
  backend: str,
187
+ session_type: str,
186
188
  task_profile: str,
187
189
  model: str,
188
190
  reasoning_effort: str,
191
+ resonance_tier: str,
189
192
  cwd: Path,
190
193
  output_format: str,
191
194
  prompt: str,
192
- returncode: int,
193
- duration_ms: int,
194
- telemetry: dict,
195
- ) -> tuple[bool, str]:
195
+ pid: int | None = None,
196
+ ) -> tuple[int | None, str]:
197
+ """Insert an automation_runs row at START time with ended_at=NULL.
198
+
199
+ Returns ``(row_id, error_message)``. Row_id is ``None`` if the insert
200
+ fails for any reason — callers should degrade gracefully (telemetry is
201
+ best-effort, never blocking the actual automation call).
202
+ """
196
203
  try:
197
204
  from db._core import get_db
198
205
  except Exception as exc:
199
- return False, f"automation telemetry unavailable: {exc}"
206
+ return None, f"automation telemetry unavailable: {exc}"
200
207
 
201
208
  try:
202
209
  conn = get_db()
203
- usage = telemetry.get("usage") or {}
204
- conn.execute(
210
+ cur = conn.execute(
205
211
  """
206
212
  INSERT INTO automation_runs (
207
- backend, task_profile, model, reasoning_effort, cwd, output_format,
208
- prompt_chars, returncode, duration_ms,
209
- input_tokens, cached_input_tokens, output_tokens,
210
- total_cost_usd, telemetry_source, cost_source, status, metadata
211
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ caller, backend, session_type, task_profile, model,
214
+ reasoning_effort, resonance_tier, cwd, output_format,
215
+ prompt_chars, status, started_at, pid
216
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
212
217
  """,
213
218
  (
219
+ caller or "",
214
220
  backend,
221
+ session_type or "headless",
215
222
  task_profile or "default",
216
- model,
217
- reasoning_effort,
223
+ model or "",
224
+ reasoning_effort or "",
225
+ resonance_tier or "",
218
226
  str(cwd),
219
227
  output_format or "text",
220
228
  len(prompt or ""),
221
- int(returncode),
222
- int(duration_ms),
223
- int(usage.get("input_tokens") or 0),
224
- int(usage.get("cached_input_tokens") or 0),
225
- int(usage.get("output_tokens") or 0),
226
- telemetry.get("total_cost_usd"),
227
- telemetry.get("telemetry_source", ""),
228
- telemetry.get("cost_source", ""),
229
- "ok" if int(returncode) == 0 else "failed",
230
- json.dumps(
231
- {
232
- "warnings": telemetry.get("warnings") or [],
233
- "raw": telemetry.get("raw") or {},
234
- },
235
- ensure_ascii=False,
236
- ),
229
+ int(pid) if pid is not None else None,
237
230
  ),
238
231
  )
239
232
  conn.commit()
233
+ return int(cur.lastrowid), ""
234
+ except Exception as exc:
235
+ return None, f"automation telemetry unavailable: {exc}"
236
+
237
+
238
+ def _record_automation_end(
239
+ *,
240
+ row_id: int | None,
241
+ returncode: int,
242
+ duration_ms: int,
243
+ telemetry: dict,
244
+ ) -> tuple[bool, str]:
245
+ """Close an automation_runs row opened by _record_automation_start.
246
+
247
+ Falls back to a fresh INSERT when ``row_id`` is None so callers that
248
+ never got a start row (start failed, or pre-v5.9.0 code paths) still
249
+ leave a completion trace.
250
+ """
251
+ try:
252
+ from db._core import get_db
253
+ except Exception as exc:
254
+ return False, f"automation telemetry unavailable: {exc}"
255
+
256
+ usage = (telemetry or {}).get("usage") or {}
257
+ metadata = json.dumps(
258
+ {
259
+ "warnings": (telemetry or {}).get("warnings") or [],
260
+ "raw": (telemetry or {}).get("raw") or {},
261
+ },
262
+ ensure_ascii=False,
263
+ )
264
+ status = "ok" if int(returncode) == 0 else "failed"
265
+
266
+ try:
267
+ conn = get_db()
268
+ if row_id is not None:
269
+ conn.execute(
270
+ """
271
+ UPDATE automation_runs
272
+ SET returncode=?, duration_ms=?,
273
+ input_tokens=?, cached_input_tokens=?, output_tokens=?,
274
+ total_cost_usd=?, telemetry_source=?, cost_source=?,
275
+ status=?, metadata=?, ended_at=datetime('now')
276
+ WHERE id=?
277
+ """,
278
+ (
279
+ int(returncode),
280
+ int(duration_ms),
281
+ int(usage.get("input_tokens") or 0),
282
+ int(usage.get("cached_input_tokens") or 0),
283
+ int(usage.get("output_tokens") or 0),
284
+ (telemetry or {}).get("total_cost_usd"),
285
+ (telemetry or {}).get("telemetry_source", ""),
286
+ (telemetry or {}).get("cost_source", ""),
287
+ status,
288
+ metadata,
289
+ int(row_id),
290
+ ),
291
+ )
292
+ else:
293
+ conn.execute(
294
+ """
295
+ INSERT INTO automation_runs (
296
+ backend, task_profile, model, reasoning_effort, cwd,
297
+ output_format, prompt_chars, returncode, duration_ms,
298
+ input_tokens, cached_input_tokens, output_tokens,
299
+ total_cost_usd, telemetry_source, cost_source, status,
300
+ metadata, ended_at
301
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
302
+ """,
303
+ (
304
+ "", "default", "", "", "", "text", 0,
305
+ int(returncode),
306
+ int(duration_ms),
307
+ int(usage.get("input_tokens") or 0),
308
+ int(usage.get("cached_input_tokens") or 0),
309
+ int(usage.get("output_tokens") or 0),
310
+ (telemetry or {}).get("total_cost_usd"),
311
+ (telemetry or {}).get("telemetry_source", ""),
312
+ (telemetry or {}).get("cost_source", ""),
313
+ status,
314
+ metadata,
315
+ ),
316
+ )
317
+ conn.commit()
240
318
  return True, ""
241
319
  except Exception as exc:
242
320
  return False, f"automation telemetry unavailable: {exc}"
243
321
 
244
322
 
323
+ def _record_automation_run(
324
+ *,
325
+ backend: str,
326
+ task_profile: str,
327
+ model: str,
328
+ reasoning_effort: str,
329
+ cwd: Path,
330
+ output_format: str,
331
+ prompt: str,
332
+ returncode: int,
333
+ duration_ms: int,
334
+ telemetry: dict,
335
+ caller: str = "",
336
+ session_type: str = "headless",
337
+ resonance_tier: str = "",
338
+ ) -> tuple[bool, str]:
339
+ """Backwards-compatible facade for code paths that record in a single
340
+ shot (no separate start row). Prefer the split start/end pair going
341
+ forward so interactive sessions and long-running jobs can be seen
342
+ while they are still in flight."""
343
+ row_id, err = _record_automation_start(
344
+ caller=caller,
345
+ backend=backend,
346
+ session_type=session_type,
347
+ task_profile=task_profile,
348
+ model=model,
349
+ reasoning_effort=reasoning_effort,
350
+ resonance_tier=resonance_tier,
351
+ cwd=cwd,
352
+ output_format=output_format,
353
+ prompt=prompt,
354
+ )
355
+ if err and row_id is None:
356
+ pass # fall through, end will insert a single row
357
+ return _record_automation_end(
358
+ row_id=row_id,
359
+ returncode=returncode,
360
+ duration_ms=duration_ms,
361
+ telemetry=telemetry,
362
+ )
363
+
364
+
245
365
  def _resolve_claude_cli() -> str:
246
366
  saved = NEXO_HOME / "config" / "claude-cli-path"
247
367
  if saved.exists():
@@ -394,11 +514,94 @@ def launch_interactive_client(
394
514
  env: dict | None = None,
395
515
  preferences: dict | None = None,
396
516
  ) -> subprocess.CompletedProcess:
397
- _, cmd = build_interactive_client_command(target=target, client=client, preferences=preferences)
517
+ return run_automation_interactive(
518
+ caller="nexo_chat",
519
+ target=target,
520
+ client=client,
521
+ env=env,
522
+ preferences=preferences,
523
+ )
524
+
525
+
526
+ def run_automation_interactive(
527
+ *,
528
+ caller: str,
529
+ target: str | os.PathLike[str],
530
+ client: str | None = None,
531
+ env: dict | None = None,
532
+ preferences: dict | None = None,
533
+ session_type: str = "interactive_chat",
534
+ ) -> subprocess.CompletedProcess:
535
+ """Launch an interactive Claude/Codex session with automation_runs logging.
536
+
537
+ Unlike ``run_automation_prompt`` the child inherits stdin/stdout/stderr
538
+ from the current terminal so the user can carry a normal conversation.
539
+ We still record the run in the ``automation_runs`` table: a row is
540
+ INSERTed at spawn time with ``ended_at IS NULL`` and UPDATEd when the
541
+ session exits. Rows that die before the UPDATE (crash, kill -9) can be
542
+ reconciled later by the watchdog via the ``pid`` column.
543
+
544
+ ``caller`` must be registered in ``src/resonance_map.py`` (typically
545
+ ``nexo_chat``, ``desktop_new_session``, or ``nexo_update_interactive``).
546
+ The resonance tier resolves through the user's default preference so
547
+ the interactive surface honours whatever the user selected.
548
+ """
549
+ prefs = preferences or load_client_preferences()
550
+ resolved_client, cmd = build_interactive_client_command(
551
+ target=target, client=client, preferences=prefs
552
+ )
398
553
  launch_env = os.environ.copy()
399
554
  if env:
400
555
  launch_env.update(env)
401
- return subprocess.run(cmd, env=launch_env, cwd=_interactive_target_cwd(target))
556
+ cwd_path = Path(_interactive_target_cwd(target))
557
+
558
+ # Best-effort resonance lookup — interactive sessions do not swap the
559
+ # command (the user chose Claude or Codex explicitly), but we still
560
+ # record which tier they are running at so telemetry is honest.
561
+ resonance_tier = ""
562
+ try:
563
+ from resonance_map import resolve_tier_for_caller
564
+ user_default = ""
565
+ if isinstance(prefs, dict):
566
+ user_default = str(prefs.get("default_resonance") or "").strip()
567
+ resonance_tier = resolve_tier_for_caller(
568
+ caller, user_default=user_default or None
569
+ )
570
+ except Exception:
571
+ resonance_tier = ""
572
+
573
+ # model / effort come from the pre-built command; we don't replay them
574
+ # here (build_interactive_client_command already embedded them).
575
+ row_id, _record_err = _record_automation_start(
576
+ caller=caller,
577
+ backend=resolved_client,
578
+ session_type=session_type,
579
+ task_profile="",
580
+ model="",
581
+ reasoning_effort="",
582
+ resonance_tier=resonance_tier,
583
+ cwd=cwd_path,
584
+ output_format="interactive",
585
+ prompt="",
586
+ )
587
+
588
+ started_wall = time.perf_counter()
589
+ try:
590
+ result = subprocess.run(cmd, env=launch_env, cwd=str(cwd_path))
591
+ finally:
592
+ duration_ms = int((time.perf_counter() - started_wall) * 1000)
593
+ try:
594
+ _record_automation_end(
595
+ row_id=row_id,
596
+ returncode=getattr(result, "returncode", -1)
597
+ if "result" in locals()
598
+ else -1,
599
+ duration_ms=duration_ms,
600
+ telemetry={},
601
+ )
602
+ except Exception:
603
+ pass
604
+ return result
402
605
 
403
606
 
404
607
  def build_followup_terminal_shell_command(
@@ -592,6 +795,7 @@ def _build_enforcement_system_prompt() -> str:
592
795
  def run_automation_prompt(
593
796
  prompt: str,
594
797
  *,
798
+ caller: str = "",
595
799
  backend: str | None = None,
596
800
  task_profile: str = "",
597
801
  cwd: str | os.PathLike[str] | None = None,
@@ -618,6 +822,36 @@ def run_automation_prompt(
618
822
  reasoning_effort = profile["reasoning_effort"]
619
823
  selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
620
824
 
825
+ # Resonance map takes over model+effort decisions when the caller is
826
+ # registered. Explicit model/effort arguments still win (required for
827
+ # edge cases like the fallback JSON-conversion call inside extract.py
828
+ # that asks a shorter/cheaper follow-up).
829
+ resonance_tier = ""
830
+ if caller and not model and not reasoning_effort:
831
+ try:
832
+ from resonance_map import (
833
+ resolve_model_and_effort,
834
+ resolve_tier_for_caller,
835
+ UnregisteredCallerError,
836
+ )
837
+ user_default = ""
838
+ if isinstance(prefs, dict):
839
+ user_default = str(prefs.get("default_resonance") or "").strip()
840
+ resonance_tier = resolve_tier_for_caller(
841
+ caller, user_default=user_default or None
842
+ )
843
+ mapped_model, mapped_effort = resolve_model_and_effort(
844
+ caller, selected_backend, user_default=user_default or None
845
+ )
846
+ if mapped_model:
847
+ model = mapped_model
848
+ if mapped_effort:
849
+ reasoning_effort = mapped_effort
850
+ except (ImportError, UnregisteredCallerError):
851
+ # Unknown caller during a transitional release: fall back to
852
+ # the legacy task_profile / model_defaults resolution below.
853
+ pass
854
+
621
855
  enforcement_fragment = _build_enforcement_system_prompt()
622
856
  if enforcement_fragment:
623
857
  if append_system_prompt:
@@ -698,6 +932,9 @@ def run_automation_prompt(
698
932
  returncode=result.returncode,
699
933
  duration_ms=int((time.perf_counter() - started_at) * 1000),
700
934
  telemetry=telemetry,
935
+ caller=caller,
936
+ session_type="headless",
937
+ resonance_tier=resonance_tier,
701
938
  )
702
939
  stderr = result.stderr or ""
703
940
  if not recorded:
package/src/cli.py CHANGED
@@ -1099,6 +1099,63 @@ def _clients_sync(args):
1099
1099
  return 0 if result.get("ok") else 1
1100
1100
 
1101
1101
 
1102
+ def _preferences(args):
1103
+ """Read or change user preferences stored in schedule.json.
1104
+
1105
+ Today this manages ``default_resonance``. Other knobs (default_client,
1106
+ autonomy level, etc.) can be added here instead of spreading across
1107
+ one-off flags.
1108
+ """
1109
+ from client_preferences import (
1110
+ load_client_preferences,
1111
+ save_client_preferences,
1112
+ )
1113
+ from resonance_map import DEFAULT_RESONANCE, TIERS
1114
+
1115
+ prefs = load_client_preferences()
1116
+ if not isinstance(prefs, dict):
1117
+ prefs = {}
1118
+
1119
+ if args.resonance:
1120
+ tier = args.resonance.lower()
1121
+ if tier not in TIERS:
1122
+ print(
1123
+ f"[NEXO] Unknown resonance tier '{args.resonance}'. "
1124
+ f"Valid values: {', '.join(TIERS)}.",
1125
+ file=sys.stderr,
1126
+ )
1127
+ return 2
1128
+ save_client_preferences(default_resonance=tier)
1129
+ prefs = load_client_preferences()
1130
+
1131
+ current_resonance = str(
1132
+ (prefs.get("default_resonance") if isinstance(prefs, dict) else "")
1133
+ or DEFAULT_RESONANCE
1134
+ )
1135
+
1136
+ if args.show or args.resonance:
1137
+ payload = {
1138
+ "default_resonance": current_resonance,
1139
+ "default_resonance_is_explicit": isinstance(prefs, dict)
1140
+ and bool(prefs.get("default_resonance")),
1141
+ "available_tiers": list(TIERS),
1142
+ }
1143
+ if args.json:
1144
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1145
+ else:
1146
+ print(f"default_resonance = {current_resonance}")
1147
+ if not payload["default_resonance_is_explicit"]:
1148
+ print(f" (inherited from DEFAULT_RESONANCE; run "
1149
+ f"`nexo preferences --resonance alto` to set explicitly)")
1150
+ return 0
1151
+
1152
+ # No flag: print usage
1153
+ print("Usage: nexo preferences [--resonance TIER] [--show] [--json]")
1154
+ print(f" resonance tiers: {', '.join(TIERS)}")
1155
+ print(f" current default: {current_resonance}")
1156
+ return 0
1157
+
1158
+
1102
1159
  def _contributor_status(args):
1103
1160
  public_contribution = _load_public_contribution_support()
1104
1161
  config = public_contribution["refresh_public_contribution_state"](
@@ -2051,6 +2108,26 @@ def main():
2051
2108
  clients_sync_p = clients_sub.add_parser("sync", help="Sync Claude Code, Claude Desktop, and Codex to the same NEXO brain")
2052
2109
  clients_sync_p.add_argument("--json", action="store_true", help="JSON output")
2053
2110
 
2111
+ # -- preferences --
2112
+ preferences_parser = sub.add_parser(
2113
+ "preferences",
2114
+ help="Read or change NEXO user preferences (resonance, default client, ...)",
2115
+ )
2116
+ preferences_parser.add_argument(
2117
+ "--resonance",
2118
+ choices=["maximo", "alto", "medio", "bajo"],
2119
+ help="Set the default resonance tier for interactive sessions "
2120
+ "(nexo chat, Desktop new conversation, interactive nexo update). "
2121
+ "System-owned callers (deep-sleep, catchup, etc.) ignore this value "
2122
+ "and use the tier hard-coded in resonance_map.py. Default: alto.",
2123
+ )
2124
+ preferences_parser.add_argument(
2125
+ "--show",
2126
+ action="store_true",
2127
+ help="Print the current preferences as JSON and exit.",
2128
+ )
2129
+ preferences_parser.add_argument("--json", action="store_true", help="JSON output")
2130
+
2054
2131
  # -- doctor --
2055
2132
  doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
2056
2133
  doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
@@ -2245,6 +2322,8 @@ def main():
2245
2322
  return _clients_sync(args)
2246
2323
  clients_parser.print_help()
2247
2324
  return 0
2325
+ elif args.command == "preferences":
2326
+ return _preferences(args)
2248
2327
  elif args.command == "doctor":
2249
2328
  return _doctor(args)
2250
2329
  elif args.command == "contributor":
@@ -494,6 +494,7 @@ def save_client_preferences(
494
494
  automation_task_profiles: dict | None = None,
495
495
  client_install_preferences: dict | None = None,
496
496
  acknowledged_model_recommendations: dict | None = None,
497
+ default_resonance: str | None = None,
497
498
  ) -> Path:
498
499
  schedule = apply_client_preferences(
499
500
  load_schedule_config(),
@@ -507,6 +508,10 @@ def save_client_preferences(
507
508
  client_install_preferences=client_install_preferences,
508
509
  acknowledged_model_recommendations=acknowledged_model_recommendations,
509
510
  )
511
+ if default_resonance is not None:
512
+ tier = str(default_resonance).strip().lower()
513
+ if tier:
514
+ schedule["default_resonance"] = tier
510
515
  return save_schedule_config(schedule)
511
516
 
512
517