apirrone commited on
Commit
c2d2d9e
·
1 Parent(s): 0c3901b

starting to add personality edition capabilities

Browse files
.gitignore CHANGED
@@ -56,3 +56,6 @@ cache/
56
  .directory
57
  .Trash-*
58
  .nfs*
 
 
 
 
56
  .directory
57
  .Trash-*
58
  .nfs*
59
+
60
+ # User-created personalities (managed by UI)
61
+ src/reachy_mini_conversation_app/profiles/user_personalities/
README.md CHANGED
@@ -196,6 +196,14 @@ Tools are resolved first from Python files in the profile folder (custom tools),
196
  On top of built-in tools found in the shared library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
197
  Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
198
 
 
 
 
 
 
 
 
 
199
 
200
 
201
 
 
196
  On top of built-in tools found in the shared library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
197
  Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
198
 
199
+ ### Edit personalities from the UI
200
+ When running with `--gradio`, open the “Personality” accordion:
201
+ - Select among available profiles (folders under `src/reachy_mini_conversation_app/profiles/`) or the built‑in default.
202
+ - Click “Apply” to update the current session instructions live.
203
+ - Create a new personality by entering a name and instructions text; it stores files under `profiles/<name>/` and copies `tools.txt` from the `default` profile.
204
+
205
+ Note: The “Personality” panel updates the conversation instructions. Tool sets are loaded at startup from `tools.txt` and are not hot‑reloaded.
206
+
207
 
208
 
209
 
src/reachy_mini_conversation_app/main.py CHANGED
@@ -19,6 +19,8 @@ from reachy_mini_conversation_app.utils import (
19
  setup_logger,
20
  handle_vision_stuff,
21
  )
 
 
22
 
23
 
24
  def update_chatbot(chatbot: List[Dict[str, Any]], response: Dict[str, Any]) -> List[Dict[str, Any]]:
@@ -102,11 +104,142 @@ def run(
102
  type="password",
103
  value=os.getenv("OPENAI_API_KEY") if not get_space() else "",
104
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  stream = Stream(
106
  handler=handler,
107
  mode="send-receive",
108
  modality="audio",
109
- additional_inputs=[chatbot, api_key_textbox],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  additional_outputs=[chatbot],
111
  additional_outputs_handler=update_chatbot,
112
  ui_args={"title": "Talk with Reachy Mini"},
@@ -117,6 +250,265 @@ def run(
117
  else:
118
  app = settings_app
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  app = gr.mount_gradio_app(app, stream.ui, path="/")
121
  else:
122
  stream_manager = LocalStream(handler, robot)
 
19
  setup_logger,
20
  handle_vision_stuff,
21
  )
22
+ from reachy_mini_conversation_app.config import config
23
+ from pathlib import Path
24
 
25
 
26
  def update_chatbot(chatbot: List[Dict[str, Any]], response: Dict[str, Any]) -> List[Dict[str, Any]]:
 
104
  type="password",
105
  value=os.getenv("OPENAI_API_KEY") if not get_space() else "",
106
  )
107
+ # Helpers for personalities management (profiles folder)
108
+ profiles_root = Path(__file__).parent / "profiles"
109
+
110
+ def _list_personalities() -> list[str]:
111
+ names: list[str] = []
112
+ try:
113
+ if profiles_root.exists():
114
+ # Built-in profiles (exclude holder for user profiles)
115
+ for p in sorted(profiles_root.iterdir()):
116
+ if p.name == "user_personalities":
117
+ continue
118
+ if p.is_dir() and (p / "instructions.txt").exists():
119
+ names.append(p.name)
120
+ # User-created profiles live under profiles/user_personalities/<name>
121
+ user_dir = profiles_root / "user_personalities"
122
+ if user_dir.exists():
123
+ for p in sorted(user_dir.iterdir()):
124
+ if p.is_dir() and (p / "instructions.txt").exists():
125
+ # Encode value as path segment so tools can resolve folder
126
+ names.append(f"user_personalities/{p.name}")
127
+ except Exception:
128
+ pass
129
+ return names
130
+
131
+ DEFAULT_OPTION = "(built-in default)"
132
+
133
+ def _current_selection_value() -> str:
134
+ return config.REACHY_MINI_CUSTOM_PROFILE or DEFAULT_OPTION
135
+
136
+ def _resolve_profile_dir(selection: str) -> Path:
137
+ return profiles_root / selection
138
+
139
+ def _read_instructions_for(name: str) -> str:
140
+ try:
141
+ if name == DEFAULT_OPTION:
142
+ # Show baked-in default prompt
143
+ default_file = Path(__file__).parent / "prompts" / "default_prompt.txt"
144
+ if default_file.exists():
145
+ return default_file.read_text(encoding="utf-8").strip()
146
+ return ""
147
+ target = _resolve_profile_dir(name) / "instructions.txt"
148
+ if target.exists():
149
+ return target.read_text(encoding="utf-8").strip()
150
+ return ""
151
+ except Exception as e:
152
+ return f"Could not load instructions: {e}"
153
+
154
+ async def _apply_personality(selected: str) -> tuple[str, str]:
155
+ profile = None if selected == DEFAULT_OPTION else selected
156
+ status = await handler.apply_personality(profile)
157
+ preview = _read_instructions_for(selected)
158
+ return status, preview
159
+
160
+ def _sanitize_name(name: str) -> str:
161
+ import re
162
+
163
+ s = name.strip()
164
+ s = re.sub(r"\s+", "_", s)
165
+ s = re.sub(r"[^a-zA-Z0-9_-]", "", s)
166
+ return s
167
+
168
+ def _create_personality(name: str, instructions: str, tools_text: str): # type: ignore[no-untyped-def]
169
+ name_s = _sanitize_name(name)
170
+ if not name_s:
171
+ return gr.update(), gr.update(), "Please enter a valid name."
172
+ try:
173
+ target_dir = profiles_root / "user_personalities" / name_s
174
+ target_dir.mkdir(parents=True, exist_ok=False)
175
+ # Write instructions
176
+ (target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
177
+ # Write tools.txt
178
+ (target_dir / "tools.txt").write_text(tools_text.strip() + "\n", encoding="utf-8")
179
+
180
+ choices = _list_personalities()
181
+ value = f"user_personalities/{name_s}"
182
+ if value not in choices:
183
+ choices.append(value)
184
+ return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Created personality '{name_s}'."
185
+ except FileExistsError:
186
+ choices = _list_personalities()
187
+ value = f"user_personalities/{name_s}"
188
+ if value not in choices:
189
+ choices.append(value)
190
+ return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Personality '{name_s}' already exists."
191
+ except Exception as e:
192
+ return gr.update(), gr.update(), f"Failed to create personality: {e}"
193
+
194
+ # Build personality UI components to place in the side panel
195
+ personalities_dropdown = gr.Dropdown(
196
+ label="Select personality",
197
+ choices=[DEFAULT_OPTION, *(_list_personalities())],
198
+ value=_current_selection_value(),
199
+ )
200
+ apply_btn = gr.Button("Apply personality")
201
+ status_md = gr.Markdown(visible=True)
202
+ preview_md = gr.Markdown(value=_read_instructions_for(_current_selection_value()))
203
+ person_name_tb = gr.Textbox(label="Personality name")
204
+ person_instr_ta = gr.TextArea(label="Personality instructions", lines=10)
205
+ tools_txt_ta = gr.TextArea(label="tools.txt", lines=10)
206
+ new_personality_btn = gr.Button("New personality")
207
+ # Convenience: discovered tools (shared + profile-local)
208
+ available_tools_cg = gr.CheckboxGroup(label="Available tools (helper)", choices=[], value=[])
209
+ load_file_dropdown = gr.Dropdown(label="Custom tool files (*.py)", choices=[], value=None)
210
+ file_content_ta = gr.TextArea(label="Selected file content", lines=12)
211
+ new_file_name_tb = gr.Textbox(label="New custom tool filename (e.g., my_tool.py)")
212
+ create_file_btn = gr.Button("Create file")
213
+ save_file_btn = gr.Button("Save file")
214
+ delete_file_btn = gr.Button("Delete file")
215
+ save_btn = gr.Button("Save personality (instructions + tools)")
216
+
217
+ # Build the streaming UI first
218
  stream = Stream(
219
  handler=handler,
220
  mode="send-receive",
221
  modality="audio",
222
+ # Keep original order for first two to preserve expected arg indices
223
+ additional_inputs=[
224
+ chatbot,
225
+ api_key_textbox,
226
+ personalities_dropdown,
227
+ apply_btn,
228
+ status_md,
229
+ preview_md,
230
+ person_name_tb,
231
+ person_instr_ta,
232
+ tools_txt_ta,
233
+ new_personality_btn,
234
+ available_tools_cg,
235
+ load_file_dropdown,
236
+ file_content_ta,
237
+ new_file_name_tb,
238
+ create_file_btn,
239
+ save_file_btn,
240
+ delete_file_btn,
241
+ save_btn,
242
+ ],
243
  additional_outputs=[chatbot],
244
  additional_outputs_handler=update_chatbot,
245
  ui_args={"title": "Talk with Reachy Mini"},
 
250
  else:
251
  app = settings_app
252
 
253
+ # Wire events for side panel controls inside the Blocks context
254
+ with stream_manager:
255
+ apply_btn.click(
256
+ fn=_apply_personality,
257
+ inputs=[personalities_dropdown],
258
+ outputs=[status_md, preview_md],
259
+ )
260
+
261
+ def _available_tools_for(selected: str): # type: ignore[no-untyped-def]
262
+ # Shared tools list
263
+ tools_dir = Path(__file__).parent / "tools"
264
+ shared = []
265
+ try:
266
+ for py in tools_dir.glob("*.py"):
267
+ if py.stem in {"__init__", "core_tools"}:
268
+ continue
269
+ shared.append(py.stem)
270
+ except Exception:
271
+ pass
272
+ # Profile-local tools
273
+ local = []
274
+ try:
275
+ if selected != DEFAULT_OPTION:
276
+ for py in (profiles_root / selected).glob("*.py"):
277
+ local.append(py.stem)
278
+ except Exception:
279
+ pass
280
+ all_tools = sorted(set(shared + local))
281
+ return gr.update(choices=all_tools)
282
+
283
+ def _parse_enabled_tools(text: str) -> list[str]:
284
+ enabled: list[str] = []
285
+ for line in text.splitlines():
286
+ s = line.strip()
287
+ if not s or s.startswith("#"):
288
+ continue
289
+ enabled.append(s)
290
+ return enabled
291
+
292
+ def _load_profile_for_edit(selected: str): # type: ignore[no-untyped-def]
293
+ instr = _read_instructions_for(selected)
294
+ # tools.txt
295
+ tools_txt = ""
296
+ if selected != DEFAULT_OPTION:
297
+ tp = _resolve_profile_dir(selected) / "tools.txt"
298
+ if tp.exists():
299
+ tools_txt = tp.read_text(encoding="utf-8")
300
+ # available tools and enabled tools
301
+ tools_dir = Path(__file__).parent / "tools"
302
+ shared = [py.stem for py in tools_dir.glob("*.py") if py.stem not in {"__init__", "core_tools"}]
303
+ local = []
304
+ if selected != DEFAULT_OPTION:
305
+ local = [py.stem for py in (profiles_root / selected).glob("*.py")]
306
+ all_tools = sorted(set(shared + local))
307
+ enabled = _parse_enabled_tools(tools_txt)
308
+
309
+ # files under profile (py only)
310
+ files = []
311
+ if selected != DEFAULT_OPTION:
312
+ files = [p.name for p in (profiles_root / selected).glob("*.py")]
313
+ file_value = files[0] if files else None
314
+ file_content = ""
315
+ if file_value:
316
+ file_content = (profiles_root / selected / file_value).read_text(encoding="utf-8")
317
+
318
+ # Name textbox
319
+ from pathlib import Path as _P
320
+ name_for_edit = "" if selected == DEFAULT_OPTION else _P(selected).name
321
+
322
+ return (
323
+ instr,
324
+ tools_txt,
325
+ gr.update(choices=all_tools, value=[t for t in enabled if t in all_tools]),
326
+ gr.update(choices=files, value=file_value),
327
+ file_content,
328
+ name_for_edit,
329
+ )
330
+
331
+ personalities_dropdown.change(
332
+ fn=_load_profile_for_edit,
333
+ inputs=[personalities_dropdown],
334
+ outputs=[person_instr_ta, tools_txt_ta, available_tools_cg, load_file_dropdown, file_content_ta, person_name_tb],
335
+ )
336
+
337
+ # Keep the name field in sync with selection (basename for user profiles)
338
+ def _selected_name_for_edit(selected: str) -> str:
339
+ if selected == DEFAULT_OPTION:
340
+ return ""
341
+ p = Path(selected)
342
+ return p.name
343
+
344
+ # Initial tools choices for current selection
345
+ personalities_dropdown.change(
346
+ fn=_available_tools_for,
347
+ inputs=[personalities_dropdown],
348
+ outputs=[available_tools_cg],
349
+ )
350
+
351
+ # Start a new personality from scratch
352
+ def _new_personality(): # type: ignore[no-untyped-def]
353
+ # Empty name and instructions
354
+ name_val = ""
355
+ instr_val = ""
356
+ # Blank tools.txt with a helpful header
357
+ tools_txt_val = "# tools enabled for this profile\n"
358
+ # Shared tools list (no local tools for a new profile yet)
359
+ tools_dir = Path(__file__).parent / "tools"
360
+ try:
361
+ shared = [py.stem for py in tools_dir.glob("*.py") if py.stem not in {"__init__", "core_tools"}]
362
+ except Exception:
363
+ shared = []
364
+ # No files yet for a new personality
365
+ files_dd = gr.update(choices=[], value=None)
366
+ file_content_val = ""
367
+ # Update status to guide the user
368
+ status_text = "Creating a new personality. Fill the fields and click 'Save'."
369
+ return (
370
+ gr.update(value=name_val),
371
+ gr.update(value=instr_val),
372
+ gr.update(value=tools_txt_val),
373
+ gr.update(choices=sorted(shared), value=[]),
374
+ files_dd,
375
+ gr.update(value=file_content_val),
376
+ status_text,
377
+ )
378
+
379
+ new_personality_btn.click(
380
+ fn=_new_personality,
381
+ inputs=[],
382
+ outputs=[person_name_tb, person_instr_ta, tools_txt_ta, available_tools_cg, load_file_dropdown, file_content_ta, status_md],
383
+ )
384
+
385
+ def _save_personality(name: str, instructions: str, tools_text: str): # type: ignore[no-untyped-def]
386
+ name_s = _sanitize_name(name)
387
+ if not name_s:
388
+ return gr.update(), gr.update(), "Please enter a valid name."
389
+ try:
390
+ target_dir = profiles_root / "user_personalities" / name_s
391
+ target_dir.mkdir(parents=True, exist_ok=True)
392
+ # Write instructions
393
+ (target_dir / "instructions.txt").write_text(instructions.strip() + "\n", encoding="utf-8")
394
+ # Write tools.txt
395
+ (target_dir / "tools.txt").write_text(tools_text.strip() + "\n", encoding="utf-8")
396
+ # Ensure tools.txt exists (copy from default once)
397
+ tools_target = target_dir / "tools.txt"
398
+ if not tools_target.exists():
399
+ tools_target.write_text("# tools enabled for this profile\n", encoding="utf-8")
400
+
401
+ # Refresh choices and select the saved profile
402
+ choices = _list_personalities()
403
+ value = f"user_personalities/{name_s}"
404
+ if value not in choices:
405
+ choices.append(value)
406
+ return gr.update(choices=[DEFAULT_OPTION, *sorted(choices)], value=value), gr.update(value=instructions), f"Saved personality '{name_s}'."
407
+ except Exception as e:
408
+ return gr.update(), gr.update(), f"Failed to save personality: {e}"
409
+
410
+ save_btn.click(
411
+ fn=_save_personality,
412
+ inputs=[person_name_tb, person_instr_ta, tools_txt_ta],
413
+ outputs=[personalities_dropdown, person_instr_ta, status_md],
414
+ ).then(
415
+ fn=_apply_personality,
416
+ inputs=[personalities_dropdown],
417
+ outputs=[status_md, preview_md],
418
+ )
419
+
420
+ def _sync_tools_from_checks(selected: list[str], current_text: str): # type: ignore[no-untyped-def]
421
+ # Keep comments from current_text at the top, then list selected tools
422
+ comments = [ln for ln in current_text.splitlines() if ln.strip().startswith("#")]
423
+ body = "\n".join(selected)
424
+ out = ("\n".join(comments) + ("\n" if comments else "") + body).strip() + "\n"
425
+ return gr.update(value=out)
426
+
427
+ available_tools_cg.change(
428
+ fn=_sync_tools_from_checks,
429
+ inputs=[available_tools_cg, tools_txt_ta],
430
+ outputs=[tools_txt_ta],
431
+ )
432
+
433
+ def _refresh_file_content(selected_profile: str, filename: str | None): # type: ignore[no-untyped-def]
434
+ if not filename:
435
+ return ""
436
+ path = profiles_root / selected_profile / filename
437
+ try:
438
+ if path.exists():
439
+ return path.read_text(encoding="utf-8")
440
+ except Exception as e:
441
+ return f"Error loading file: {e}"
442
+ return ""
443
+
444
+ load_file_dropdown.change(
445
+ fn=_refresh_file_content,
446
+ inputs=[personalities_dropdown, load_file_dropdown],
447
+ outputs=[file_content_ta],
448
+ )
449
+
450
+ def _create_file(selected_profile: str, filename: str): # type: ignore[no-untyped-def]
451
+ name = filename.strip()
452
+ if not name.endswith(".py"):
453
+ return gr.update(), "Filename must end with .py"
454
+ # Always create in user_personalities
455
+ base = Path(selected_profile).name if selected_profile != DEFAULT_OPTION else (person_name_tb.value or "new_profile") # type: ignore[attr-defined]
456
+ target_dir = profiles_root / "user_personalities" / base
457
+ target_dir.mkdir(parents=True, exist_ok=True)
458
+ path = target_dir / name
459
+ if path.exists():
460
+ # do nothing
461
+ pass
462
+ else:
463
+ path.write_text("# custom tool\n", encoding="utf-8")
464
+ # refresh list to show new file
465
+ files = [p.name for p in target_dir.glob("*.py")]
466
+ return gr.update(choices=files, value=name), f"Created {name}"
467
+
468
+ create_file_btn.click(
469
+ fn=_create_file,
470
+ inputs=[personalities_dropdown, new_file_name_tb],
471
+ outputs=[load_file_dropdown, status_md],
472
+ )
473
+
474
+ def _save_file(selected_profile: str, filename: str | None, content: str): # type: ignore[no-untyped-def]
475
+ if not filename:
476
+ return "No file selected."
477
+ base = Path(selected_profile).name if selected_profile != DEFAULT_OPTION else (person_name_tb.value or "new_profile") # type: ignore[attr-defined]
478
+ target_dir = profiles_root / "user_personalities" / base
479
+ target_dir.mkdir(parents=True, exist_ok=True)
480
+ path = target_dir / filename
481
+ path.write_text(content, encoding="utf-8")
482
+ return f"Saved {filename}"
483
+
484
+ save_file_btn.click(
485
+ fn=_save_file,
486
+ inputs=[personalities_dropdown, load_file_dropdown, file_content_ta],
487
+ outputs=[status_md],
488
+ )
489
+
490
+ def _delete_file(selected_profile: str, filename: str | None): # type: ignore[no-untyped-def]
491
+ if not filename:
492
+ return gr.update(), "No file selected."
493
+ # Only delete from user profiles
494
+ if not selected_profile.startswith("user_personalities/"):
495
+ return gr.update(), "Cannot delete from official profile. Save to user profile first."
496
+ path = profiles_root / selected_profile / filename
497
+ try:
498
+ if path.exists():
499
+ path.unlink()
500
+ files = [p.name for p in (profiles_root / selected_profile).glob("*.py")]
501
+ new_value = files[0] if files else None
502
+ return gr.update(choices=files, value=new_value), f"Deleted {filename}"
503
+ except Exception as e:
504
+ return gr.update(), f"Failed to delete: {e}"
505
+
506
+ delete_file_btn.click(
507
+ fn=_delete_file,
508
+ inputs=[personalities_dropdown, load_file_dropdown],
509
+ outputs=[load_file_dropdown, status_md],
510
+ )
511
+
512
  app = gr.mount_gradio_app(app, stream.ui, path="/")
513
  else:
514
  stream_manager = LocalStream(handler, robot)
src/reachy_mini_conversation_app/openai_realtime.py CHANGED
@@ -72,6 +72,41 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
72
  """Create a copy of the handler."""
73
  return OpenaiRealtimeHandler(self.deps, self.gradio_mode)
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  async def _emit_debounced_partial(self, transcript: str, sequence: int) -> None:
76
  """Emit partial transcript after debounce delay."""
77
  try:
 
72
  """Create a copy of the handler."""
73
  return OpenaiRealtimeHandler(self.deps, self.gradio_mode)
74
 
75
+ async def apply_personality(self, profile: str | None) -> str:
76
+ """Apply a new personality (profile) at runtime if possible.
77
+
78
+ - Updates the global config's selected profile for subsequent calls.
79
+ - If a realtime connection is active, sends a session.update with the
80
+ freshly resolved instructions so the change takes effect immediately.
81
+
82
+ Returns a short status message for UI feedback.
83
+ """
84
+ try:
85
+ # Update the in-process config value (env is not re-read automatically)
86
+ from reachy_mini_conversation_app.config import config as _config
87
+
88
+ _config.REACHY_MINI_CUSTOM_PROFILE = profile
89
+
90
+ instructions = get_session_instructions()
91
+
92
+ if self.connection is not None:
93
+ try:
94
+ await self.connection.session.update(
95
+ session={
96
+ "instructions": instructions,
97
+ },
98
+ )
99
+ logger.info("Applied personality: %s (live session updated)", profile or "built-in default")
100
+ return "Applied personality. New instructions active."
101
+ except Exception as e:
102
+ logger.warning("Failed to live-update session instructions: %s", e)
103
+ # Fall through: instructions will take effect on next reconnect
104
+ logger.info("Applied personality recorded: %s (will apply on next session)", profile or "built-in default")
105
+ return "Applied personality. Will take effect on next connection."
106
+ except Exception as e:
107
+ logger.error("Error applying personality '%s': %s", profile, e)
108
+ return f"Failed to apply personality: {e}"
109
+
110
  async def _emit_debounced_partial(self, transcript: str, sequence: int) -> None:
111
  """Emit partial transcript after debounce delay."""
112
  try: