voicesmith-mcp 1.0.13 → 1.0.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicesmith-mcp",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Local AI voice for coding assistants — TTS & STT via MCP. Kokoro ONNX + faster-whisper, fully offline.",
5
5
  "bin": {
6
6
  "voicesmith-mcp": "bin/cli.js"
package/server.py CHANGED
@@ -41,7 +41,7 @@ from shared import (
41
41
  get_logger,
42
42
  )
43
43
  from config import load_config, save_config, get_config_path, AppConfig
44
- from session_registry import register_session, unregister_session
44
+ from session_registry import register_session, rename_session, unregister_session
45
45
 
46
46
  logger = get_logger("server")
47
47
 
@@ -565,6 +565,10 @@ async def get_voice_registry() -> dict:
565
565
  async def set_voice(name: str, voice: str) -> dict:
566
566
  """Assign or reassign a voice to an agent name.
567
567
 
568
+ Also renames the session so name and voice always match.
569
+ The name is derived from the voice ID (e.g., "am_fenrir" -> "Fenrir").
570
+ If the derived name is taken by another session, returns name_occupied error.
571
+
568
572
  Args:
569
573
  name: Agent name to assign.
570
574
  voice: Kokoro voice ID (e.g., "am_eric"). Must be valid.
@@ -579,17 +583,41 @@ async def set_voice(name: str, voice: str) -> dict:
579
583
  "message": f"Voice '{voice}' not found. Use list_voices to see available options.",
580
584
  }
581
585
 
582
- _registry.set_voice(name, voice)
586
+ # Derive canonical name from voice ID (e.g., "am_fenrir" -> "Fenrir")
587
+ # The voice ID format is {prefix}_{name}, so split on underscore and capitalize
588
+ parts = voice.split("_", 1)
589
+ new_name = parts[1].capitalize() if len(parts) == 2 else name
590
+
591
+ old_name = _session_info["name"] if _session_info else name
592
+
593
+ # Update sessions.json with conflict check
594
+ if _session_info:
595
+ try:
596
+ updated = rename_session(os.getpid(), new_name, voice)
597
+ if updated:
598
+ _session_info.update(updated)
599
+ except ValueError:
600
+ return {
601
+ "success": False,
602
+ "error": "name_occupied",
603
+ "message": f"'{new_name}' is occupied by another session.",
604
+ }
605
+
606
+ # Update voice registry (remove old entry, add new)
607
+ _registry.rename_voice(old_name, new_name, voice)
583
608
 
584
609
  # Persist last voice name so it survives session restart / resume
585
610
  if _config is not None:
586
- _config.last_voice_name = name
611
+ _config.last_voice_name = new_name
587
612
  try:
588
613
  save_config(_config)
589
614
  except Exception as e:
590
615
  logger.warning(f"Failed to persist last_voice_name: {e}")
591
616
 
592
- return {"success": True, "name": name, "voice": voice}
617
+ result = {"success": True, "name": new_name, "voice": voice}
618
+ if old_name != new_name:
619
+ result["previous_name"] = old_name
620
+ return result
593
621
 
594
622
 
595
623
  @mcp.tool()
@@ -258,6 +258,52 @@ def register_session(
258
258
  return session
259
259
 
260
260
 
261
+ def rename_session(pid: int, new_name: str, new_voice: str) -> Optional[dict]:
262
+ """Rename this server's session in the registry.
263
+
264
+ Updates the name and voice fields for the entry matching pid.
265
+ Returns the updated session dict, or None if PID not found.
266
+ Raises ValueError if new_name is taken by another active session.
267
+ """
268
+ path = _sessions_path()
269
+ if not path.exists():
270
+ return None
271
+
272
+ try:
273
+ with open(path, "r+") as f:
274
+ fcntl.flock(f, fcntl.LOCK_EX)
275
+ sessions = _read_sessions(path)
276
+ sessions = _clean_stale(sessions)
277
+
278
+ # Find our entry
279
+ our_entry = None
280
+ for s in sessions:
281
+ if s.get("pid") == pid:
282
+ our_entry = s
283
+ break
284
+
285
+ if our_entry is None:
286
+ return None
287
+
288
+ # Check if new_name is taken by another session
289
+ if new_name != our_entry["name"]:
290
+ for s in sessions:
291
+ if s.get("name") == new_name and s.get("pid") != pid:
292
+ raise ValueError(
293
+ f"'{new_name}' is occupied by another session (pid {s.get('pid')})"
294
+ )
295
+
296
+ our_entry["name"] = new_name
297
+ our_entry["voice"] = new_voice
298
+ _write_sessions(path, sessions)
299
+ return dict(our_entry)
300
+ except ValueError:
301
+ raise
302
+ except OSError as e:
303
+ logger.warning(f"Failed to rename session: {e}")
304
+ return None
305
+
306
+
261
307
  def unregister_session() -> None:
262
308
  """Remove this server's session from the registry."""
263
309
  path = _sessions_path()
package/voice_registry.py CHANGED
@@ -87,6 +87,22 @@ class VoiceRegistry:
87
87
  logger.info(f"Set voice '{voice_id}' for '{name}'")
88
88
  return True
89
89
 
90
+ def rename_voice(self, old_name: str, new_name: str, voice_id: str) -> bool:
91
+ """Rename an agent's registry entry and set a new voice.
92
+
93
+ Removes the old name entry and creates a new one.
94
+ If old_name == new_name, just updates the voice in place.
95
+ Returns True if the voice_id is valid, False otherwise.
96
+ """
97
+ if voice_id not in ALL_VOICE_IDS:
98
+ logger.warning(f"Invalid voice ID '{voice_id}' for rename '{old_name}' -> '{new_name}'")
99
+ return False
100
+ if old_name != new_name and old_name in self._registry:
101
+ del self._registry[old_name]
102
+ self._registry[new_name] = voice_id
103
+ logger.info(f"Renamed '{old_name}' -> '{new_name}' with voice '{voice_id}'")
104
+ return True
105
+
90
106
  def get_registry(self) -> dict[str, str]:
91
107
  """Return a copy of the current registry."""
92
108
  return dict(self._registry)