agentvibes 5.13.0 → 5.14.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.
Files changed (82) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/kokoro-server.py +219 -219
  16. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  17. package/.claude/hooks-windows/play-tts.ps1 +17 -0
  18. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  19. package/.claude/verbosity.txt +1 -1
  20. package/.clawdbot/README.md +105 -105
  21. package/CLAUDE.md +84 -176
  22. package/README.md +27 -2
  23. package/RELEASE_NOTES.md +86 -0
  24. package/WINDOWS-SETUP.md +208 -208
  25. package/bin/agent-vibes +39 -39
  26. package/bin/mcp-server.js +121 -121
  27. package/bin/test-bmad-pr +78 -78
  28. package/mcp-server/QUICK_START.md +203 -203
  29. package/mcp-server/README.md +345 -345
  30. package/mcp-server/examples/claude_desktop_config.json +11 -11
  31. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  32. package/mcp-server/examples/custom_instructions.md +169 -169
  33. package/mcp-server/install-deps.js +12 -2
  34. package/mcp-server/server.py +2111 -2085
  35. package/mcp-server/test_mcp_correctness.py +504 -504
  36. package/mcp-server/test_windows_script_parity.py +339 -339
  37. package/package.json +7 -4
  38. package/setup-ssh-receiver.ps1 +259 -0
  39. package/setup-windows.ps1 +39 -29
  40. package/src/cli/list-personalities.js +110 -110
  41. package/src/commands/bmad-voices.js +394 -394
  42. package/src/console/brand-colors.js +13 -13
  43. package/src/console/constants/personalities.js +44 -44
  44. package/src/console/tabs/agents-tab.js +14 -4
  45. package/src/console/tabs/help-tab.js +314 -314
  46. package/src/console/tabs/readme-tab.js +272 -272
  47. package/src/console/tabs/settings-tab.js +31 -6
  48. package/src/console/tabs/setup-tab.js +154 -32
  49. package/src/console/tabs/voices-tab.js +48 -6
  50. package/src/console/widgets/destroy-list.js +25 -25
  51. package/src/console/widgets/notice.js +55 -55
  52. package/src/installer/language-screen.js +31 -31
  53. package/src/installer/music-file-input.js +304 -304
  54. package/src/installer.js +103 -43
  55. package/src/services/language-service.js +47 -47
  56. package/src/services/llm-provider-service.js +4 -1
  57. package/src/services/provider-voice-catalog.js +9 -5
  58. package/src/utils/audio-format-validator.js +277 -277
  59. package/src/utils/dependency-checker.js +469 -469
  60. package/src/utils/file-ownership-verifier.js +358 -358
  61. package/src/utils/music-file-validator.js +285 -285
  62. package/src/utils/preview-list-prompt.js +144 -144
  63. package/src/utils/secure-music-storage.js +412 -412
  64. package/templates/agentvibes-receiver.ps1 +311 -0
  65. package/templates/agentvibes-receiver.sh +297 -297
  66. package/voice-assignments.json +8244 -8244
  67. package/.agentvibes/config.json +0 -17
  68. package/.agentvibes/install-manifest.json +0 -342
  69. package/.claude/config/audio-effects.cfg +0 -7
  70. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  71. package/.claude/config/background-music-enabled.txt +0 -1
  72. package/.claude/config/background-music-position.txt +0 -27
  73. package/.claude/config/background-music-volume.txt +0 -1
  74. package/.claude/config/background-music.cfg +0 -1
  75. package/.claude/config/background-music.txt +0 -1
  76. package/.claude/config/language.txt +0 -1
  77. package/.claude/config/personality.txt +0 -1
  78. package/.claude/config/reverb-level.txt +0 -1
  79. package/.claude/config/tts-speech-rate.txt +0 -1
  80. package/.claude/config/tts-verbosity.txt +0 -1
  81. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  82. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
@@ -1,2085 +1,2111 @@
1
- #!/usr/bin/env python3
2
- """
3
- File: mcp-server/server.py
4
-
5
- AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
6
- Website: https://agentvibes.org
7
- Repository: https://github.com/paulpreibisch/AgentVibes
8
-
9
- Co-created by Paul Preibisch with Claude AI
10
- Copyright (c) 2025 Paul Preibisch
11
-
12
- Licensed under the Apache License, Version 2.0 (the "License");
13
- you may not use this file except in compliance with the License.
14
- You may obtain a copy of the License at
15
-
16
- http://www.apache.org/licenses/LICENSE-2.0
17
-
18
- Unless required by applicable law or agreed to in writing, software
19
- distributed under the License is distributed on an "AS IS" BASIS,
20
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
- See the License for the specific language governing permissions and
22
- limitations under the License.
23
-
24
- DISCLAIMER: This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
- express or implied, including but not limited to the warranties of
26
- merchantability, fitness for a particular purpose and noninfringement.
27
- In no event shall the authors or copyright holders be liable for any claim,
28
- damages or other liability, whether in an action of contract, tort or
29
- otherwise, arising from, out of or in connection with the software or the
30
- use or other dealings in the software.
31
-
32
- ---
33
-
34
- @fileoverview MCP Server exposing AgentVibes TTS capabilities via Model Context Protocol
35
- @context Provides natural language control of TTS features for Claude Desktop, Warp, and other MCP clients
36
- @architecture MCP Server implementation wrapping bash scripts, async subprocess execution for non-blocking I/O
37
- @dependencies .claude/hooks/*.sh scripts, MCP SDK, Python asyncio, subprocess
38
- @entrypoints Called by Claude Desktop/Warp via MCP protocol (stdio transport)
39
- @patterns Tool registry pattern, async subprocess wrapping, provider abstraction, state file management
40
- @related GitHub repo, mcp-server/test_server.py, .claude/hooks/play-tts.sh, docs/ai-optimized-documentation-standards.md
41
- """
42
-
43
- import asyncio
44
- import json
45
- import os
46
- import platform
47
- import re as _re
48
- import subprocess
49
- import sys
50
- from dataclasses import dataclass
51
- from pathlib import Path
52
- from typing import Optional
53
-
54
- from mcp.server import Server
55
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource, CallToolResult
56
- import mcp.server.stdio
57
-
58
-
59
- # Default per-script timeout for _run_script(). Keeps a hung/interactive
60
- # manager script (e.g. a `read -p` prompt reached by mistake) from wedging
61
- # the MCP server forever and eating the stdio JSON-RPC stream.
62
- DEFAULT_SCRIPT_TIMEOUT = 30.0
63
-
64
-
65
- # ── Provider Catalog (SSOT Layer 2) — platform allowlists + display names ────
66
- #
67
- # server.py DERIVES its per-platform provider allowlists and provider display
68
- # names from the shipped provider-catalog.json (generated from
69
- # src/services/provider-catalog.js). The literals below are an EMBEDDED FALLBACK
70
- # used ONLY when that JSON is missing or unreadable (installed-tree skew) —
71
- # degraded, never dead: the MCP server MUST start without the file.
72
- #
73
- # Bidirectional parity (embedded fallback ≡ catalog, per platform, BOTH
74
- # directions) is asserted by test/unit/provider-catalog-conformance.test.js so
75
- # the fallback can never silently drift from the catalog (design §8 — a
76
- # one-directional check is the exact gap that let elevenlabs-on-Windows through
77
- # the old positive-only test).
78
- #
79
- # The non-Windows fallback mirrors the catalog's DARWIN set (the superset that
80
- # also covers Linux: darwin adds macOS `say`, which a Linux box simply won't
81
- # have installed availability is enforced by the dispatchers downstream, not
82
- # here). Windows uses the catalog WINDOWS set. elevenlabs is deliberately ABSENT
83
- # from Windows: there is NO play-tts-elevenlabs.ps1 runtime (AVI-S9.1 /
84
- # provider-catalog.js: elevenlabs.runtime.windows === null).
85
- _FALLBACK_PROVIDERS_WINDOWS = ["windows-piper", "windows-sapi", "soprano", "kokoro"]
86
- _FALLBACK_PROVIDERS_NON_WINDOWS = ["piper", "macos", "soprano", "kokoro", "elevenlabs"]
87
-
88
- # termux-ssh is a TRANSPORT (relays TTS over SSH to a phone), NOT a synthesis
89
- # provider: it has no catalog record and no play-tts-termux-ssh runtime. It is
90
- # accepted on non-Windows as a documented non-catalog transport token, and is
91
- # EXCLUDED BY NAME from the catalog parity assertion (AC6) so it can neither be
92
- # silently dropped nor silently drift INTO the catalog.
93
- _TRANSPORT_TOKENS = ["termux-ssh"]
94
-
95
- # Embedded fallback display names — byte-equal to catalog.json `displayNames`
96
- # (group-8 parity). termux-ssh is NOT here (it has no catalog record); its
97
- # display name is added separately as a transport token.
98
- _FALLBACK_DISPLAY_NAMES = {
99
- "soprano": "Soprano TTS",
100
- "piper": "Piper TTS",
101
- "kokoro": "Kokoro TTS",
102
- "elevenlabs": "ElevenLabs",
103
- "macos": "macOS Say",
104
- "windows-sapi": "Windows SAPI",
105
- "windows-piper": "Piper TTS",
106
- }
107
- _TRANSPORT_DISPLAY_NAMES = {
108
- "termux-ssh": "Termux SSH",
109
- }
110
-
111
-
112
- @dataclass
113
- class ScriptResult:
114
- """Structured result of running a hook script.
115
-
116
- Callers MUST branch on `ok`/`returncode`, never on emoji/text sniffing —
117
- Windows manager scripts print plain text (no ✅/✓/🎭), so any
118
- `"<emoji>" in stdout` check silently reports failure on Windows even when
119
- the script succeeded.
120
- """
121
- returncode: int
122
- stdout: str
123
- stderr: str
124
-
125
- @property
126
- def ok(self) -> bool:
127
- return self.returncode == 0
128
-
129
- @property
130
- def error_detail(self) -> str:
131
- """Best-effort human-readable error text for failure messages."""
132
- return self.stderr or self.stdout or f"exit code {self.returncode}"
133
-
134
-
135
- class AgentVibesServer:
136
- """MCP Server for AgentVibes TTS functionality"""
137
-
138
- # Script name constants (addresses SonarCloud S1192)
139
- VOICE_MANAGER_SCRIPT = "voice-manager.sh"
140
- PERSONALITY_MANAGER_SCRIPT = "personality-manager.sh"
141
- LANGUAGE_MANAGER_SCRIPT = "language-manager.sh"
142
- BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.sh"
143
- EFFECTS_MANAGER_SCRIPT = "effects-manager.sh"
144
-
145
- # Path constants (addresses SonarCloud S1192)
146
- CLAUDE_DIR_NAME = ".claude"
147
- MUTE_FILE_NAME = ".agentvibes-muted"
148
- SEPARATOR = "" * 39
149
-
150
- def __init__(self):
151
- """Initialize the AgentVibes MCP server"""
152
- # Detect native Windows (not WSL)
153
- self.is_windows = platform.system() == "Windows" and not os.environ.get("WSL_DISTRO_NAME")
154
- self.is_darwin = platform.system() == "Darwin"
155
-
156
- # Script name constants — Windows uses .ps1, Unix uses .sh
157
- if self.is_windows:
158
- self.VOICE_MANAGER_SCRIPT = "voice-manager-windows.ps1"
159
- self.PERSONALITY_MANAGER_SCRIPT = "personality-manager.ps1"
160
- self.LANGUAGE_MANAGER_SCRIPT = "language-manager.ps1"
161
- self.BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.ps1"
162
- self.EFFECTS_MANAGER_SCRIPT = "effects-manager.ps1"
163
-
164
- # Find the .claude directory (project-local or global)
165
- self.claude_dir = self._find_claude_dir()
166
- self.hooks_dir = self.claude_dir / ("hooks-windows" if self.is_windows else "hooks")
167
- # Store AgentVibes root directory for environment variable
168
- self.agentvibes_root = self.claude_dir.parent
169
-
170
- # Serializes the "mutate global personality/language -> speak -> restore"
171
- # critical section in text_to_speech() so concurrent MCP tool calls
172
- # cannot interleave and corrupt persistent state (residual risk: this
173
- # only protects against concurrent calls *within this process* — a
174
- # second MCP server process or a slash-command CLI invocation writing
175
- # the same file at the same time is not covered; see story 8.2 notes).
176
- self._override_lock = asyncio.Lock()
177
-
178
- # provider-catalog.json is read lazily once and cached (design: no
179
- # blocking I/O per call; load at first use, not per set_provider).
180
- self._provider_catalog = None
181
- self._provider_catalog_loaded = False
182
-
183
- def _load_provider_catalog(self) -> Optional[dict]:
184
- """Load the shipped provider-catalog.json ONCE (cached).
185
-
186
- Generated from src/services/provider-catalog.js into
187
- .claude/hooks/provider-catalog.json and shipped beside the hooks, it is
188
- the SSOT for per-platform provider allowlists and display names. Returns
189
- the parsed dict, or None on ANY failure (missing / unreadable /
190
- malformed) so the MCP server always starts callers then fall back to
191
- the embedded literals that conformance asserts are equivalent.
192
- """
193
- if self._provider_catalog_loaded:
194
- return self._provider_catalog
195
- self._provider_catalog_loaded = True
196
- catalog = None
197
- try:
198
- # catalog.json lives under hooks/ on EVERY platform (program data,
199
- # not a per-platform hook script the generator only writes it there).
200
- path = self.claude_dir / "hooks" / "provider-catalog.json"
201
- if path.exists() and not path.is_symlink():
202
- parsed = json.loads(path.read_text(encoding="utf-8"))
203
- if isinstance(parsed, dict):
204
- catalog = parsed
205
- except (OSError, ValueError):
206
- catalog = None
207
- if catalog is None:
208
- # Degraded, never dead. Warn on stderr ONLY (stdout is the MCP
209
- # JSON-RPC stream); one terse line, since a missing file is the
210
- # common installed-tree-skew case.
211
- print(
212
- "agentvibes: provider-catalog.json unavailable; using embedded provider fallback",
213
- file=sys.stderr,
214
- )
215
- self._provider_catalog = catalog
216
- return catalog
217
-
218
- def _valid_providers(self) -> list:
219
- """Per-platform provider allowlist, DERIVED from provider-catalog.json.
220
-
221
- Windows → catalog `platforms.windows`; non-Windows → catalog
222
- `platforms.darwin` (the superset covering both Linux and macOS). Falls
223
- back to the embedded literals when the catalog is unavailable. Transport
224
- tokens (termux-ssh) are appended on non-Windows only.
225
- """
226
- catalog = self._load_provider_catalog()
227
- base = None
228
- if catalog:
229
- platforms = catalog.get("platforms")
230
- if isinstance(platforms, dict):
231
- key = "windows" if self.is_windows else "darwin"
232
- derived = platforms.get(key)
233
- if isinstance(derived, list) and derived:
234
- base = list(derived)
235
- if base is None:
236
- base = list(
237
- _FALLBACK_PROVIDERS_WINDOWS if self.is_windows else _FALLBACK_PROVIDERS_NON_WINDOWS
238
- )
239
- if not self.is_windows:
240
- for token in _TRANSPORT_TOKENS:
241
- if token not in base:
242
- base.append(token)
243
- return base
244
-
245
- def _provider_display_names(self) -> dict:
246
- """Provider display names, DERIVED from provider-catalog.json.
247
-
248
- Falls back to the embedded dict when the catalog is unavailable. Non-
249
- catalog transport tokens (termux-ssh) are always merged in.
250
- """
251
- catalog = self._load_provider_catalog()
252
- names = None
253
- if catalog:
254
- derived = catalog.get("displayNames")
255
- if isinstance(derived, dict) and derived:
256
- names = dict(derived)
257
- if names is None:
258
- names = dict(_FALLBACK_DISPLAY_NAMES)
259
- for token, label in _TRANSPORT_DISPLAY_NAMES.items():
260
- names.setdefault(token, label)
261
- return names
262
-
263
- def _find_claude_dir(self) -> Path:
264
- """Find the .claude directory relative to this script"""
265
- # Get the AgentVibes root directory (parent of mcp-server)
266
- script_dir = Path(__file__).resolve().parent # mcp-server/
267
- agentvibes_root = script_dir.parent # AgentVibes/
268
- claude_dir = agentvibes_root / self.CLAUDE_DIR_NAME
269
-
270
- # ALWAYS use package .claude for hooks (even in NPX cache)
271
- # The package ALWAYS has .claude/ with all the hooks
272
- if claude_dir.exists() and claude_dir.is_dir():
273
- return claude_dir
274
-
275
- # Fallback to global ~/.claude (should never happen in properly installed package)
276
- return Path.home() / self.CLAUDE_DIR_NAME
277
-
278
- def _resolve_friendly_name(self, voice_name: str) -> str:
279
- """
280
- Resolve friendly name to Piper voice ID using voice-metadata.json.
281
-
282
- Args:
283
- voice_name: Friendly name (e.g., "ryan") or Piper ID
284
-
285
- Returns:
286
- Resolved Piper voice ID, or original voice_name if not found
287
- """
288
- import re
289
-
290
- metadata_path = self.agentvibes_root / ".agentvibes" / "config" / "voice-metadata.json"
291
-
292
- # SECURITY: Verify file exists and is not a symlink
293
- if not metadata_path.exists() or metadata_path.is_symlink():
294
- return voice_name
295
-
296
- # SECURITY: Verify file ownership matches current user (Unix only)
297
- try:
298
- if hasattr(os, 'getuid'):
299
- stat_info = metadata_path.stat()
300
- if stat_info.st_uid != os.getuid():
301
- return voice_name
302
- except (OSError, AttributeError):
303
- pass
304
-
305
- try:
306
- with open(metadata_path, 'r') as f:
307
- metadata = json.load(f)
308
-
309
- voices = metadata.get('voices', {})
310
- voice_lower = voice_name.lower()
311
-
312
- resolved_id = None
313
-
314
- # Check if it's a friendly name key
315
- if voice_lower in voices:
316
- resolved_id = voices[voice_lower].get('id')
317
-
318
- # Check if it matches a displayName
319
- if not resolved_id:
320
- for friendly_name, voice_data in voices.items():
321
- if voice_data.get('displayName', '').lower() == voice_lower:
322
- resolved_id = voice_data.get('id')
323
- break
324
-
325
- # SECURITY: Validate resolved ID matches safe pattern
326
- if resolved_id and re.match(r'^[a-zA-Z0-9_-]+$', resolved_id):
327
- return resolved_id
328
-
329
- except (json.JSONDecodeError, KeyError, IOError, TypeError):
330
- pass
331
-
332
- return voice_name
333
-
334
- # ── LibriTTS display-name resolution ──────────────────────────────────────
335
-
336
- _SURNAME_POOL = [
337
- 'Bell', 'Carter', 'Davis', 'Ellis', 'Foster', 'Gray', 'Hayes', 'Irving',
338
- 'Jones', 'Knox', 'Lane', 'Mason', 'Nash', 'Owens', 'Pierce', 'Quinn',
339
- ]
340
-
341
- @classmethod
342
- def _uniquify_voice_name(cls, raw_name: str) -> str:
343
- """Python port of uniquifyVoiceName from src/utils/voice-names.js"""
344
- import re as _re
345
- if not raw_name:
346
- return raw_name
347
- m = _re.match(r'^(.+)-(\d+)$', raw_name)
348
- if m:
349
- base, n = m.group(1), int(m.group(2))
350
- if n >= 2:
351
- return f"{base} {cls._SURNAME_POOL[(n - 1) % len(cls._SURNAME_POOL)]}"
352
- if ' ' in raw_name:
353
- return raw_name
354
- return f"{raw_name} {cls._SURNAME_POOL[0]}"
355
-
356
- def _build_libritts_catalog(self) -> dict:
357
- """
358
- Build a case-insensitive display-name → entry map from voice-assignments.json.
359
- Returns dict keyed by lowercased display name / raw name / speaker name.
360
- """
361
- catalog: dict = {}
362
- va_path = self.agentvibes_root / "voice-assignments.json"
363
- if not va_path.exists():
364
- return catalog
365
- try:
366
- data = json.loads(va_path.read_text())
367
- for id_str, entry in data.get("libritts_speakers", {}).items():
368
- speaker_id = int(id_str)
369
- raw_name = entry.get("voice_name", "")
370
- display_name = self._uniquify_voice_name(raw_name)
371
- voice_id = f"en_US-libritts-high::{raw_name}"
372
- info = {
373
- "voice_id": voice_id,
374
- "model": "en_US-libritts-high",
375
- "speaker_name": raw_name,
376
- "speaker_id": speaker_id,
377
- "display_name": display_name,
378
- "gender": entry.get("gender", ""),
379
- }
380
- for key in (display_name.lower(), raw_name.lower(),
381
- raw_name.replace(" ", "_").lower()):
382
- catalog.setdefault(key, info)
383
- except (json.JSONDecodeError, KeyError, ValueError, OSError):
384
- pass
385
- return catalog
386
-
387
- def _resolve_voice_input(self, voice_input: str) -> Optional[dict]:
388
- """
389
- Resolve a voice display name or ID to a dict with model/speakerId/voiceId.
390
- Returns None if unresolvable.
391
- Accepts: "Bella Bell", "Bella-2", "en_US-libritts-high::Bella",
392
- "Kristin_Hughes", "en_US-amy-medium"
393
- """
394
- import re as _re
395
- if not voice_input:
396
- return None
397
- MS_SEP = "::"
398
-
399
- # Already a full voiceId with MS_SEP
400
- if MS_SEP in voice_input:
401
- parts = voice_input.split(MS_SEP, 1)
402
- model, speaker_name = parts[0], parts[1]
403
- if not _re.match(r'^[a-zA-Z0-9_-]+$', model):
404
- return None
405
- catalog = self._build_libritts_catalog()
406
- entry = catalog.get(speaker_name.lower())
407
- return {
408
- "voice_id": voice_input,
409
- "model": model,
410
- "speaker_name": speaker_name,
411
- "speaker_id": entry["speaker_id"] if entry else None,
412
- "display_name": entry["display_name"] if entry else speaker_name,
413
- }
414
-
415
- # Plain piper model ID (e.g. en_US-amy-medium)
416
- if _re.match(r'^en_[A-Z]{2}-[a-zA-Z0-9_]+-[a-z]+$', voice_input):
417
- return {
418
- "voice_id": voice_input, "model": voice_input,
419
- "speaker_name": None, "speaker_id": None, "display_name": voice_input,
420
- }
421
-
422
- # LibriTTS display name / raw name lookup
423
- catalog = self._build_libritts_catalog()
424
- normalised = voice_input.replace("_", " ")
425
- entry = catalog.get(normalised.lower()) or catalog.get(voice_input.lower())
426
- return entry or None
427
-
428
- def _get_config_dir(self) -> Path:
429
- """Return the .claude dir to write voice config files into (project or global)."""
430
- cwd = Path.cwd()
431
- if (cwd / ".claude").is_dir() and cwd != self.agentvibes_root:
432
- return cwd / ".claude"
433
- return self.claude_dir
434
-
435
- async def text_to_speech(
436
- self,
437
- text: str,
438
- voice: Optional[str] = None,
439
- personality: Optional[str] = None,
440
- language: Optional[str] = None,
441
- ) -> str:
442
- """
443
- Convert text to speech using AgentVibes.
444
-
445
- Args:
446
- text: The text to speak
447
- voice: Optional voice name (e.g., "Aria", "Northern Terry")
448
- personality: Optional personality style (e.g., "flirty", "sarcastic")
449
- language: Optional language (e.g., "spanish", "french")
450
-
451
- Returns:
452
- Success message with audio file path
453
- """
454
- # Store original settings to restore later. Mutating the personality/
455
- # language files is inherently racy across processes; the lock below
456
- # only protects against concurrent tool calls within *this* server
457
- # instance (see the residual-risk note on self._override_lock).
458
- original_personality = None
459
- personality_file_existed = True
460
- original_language = None
461
- needs_override = bool(personality or language)
462
-
463
- if needs_override:
464
- await self._override_lock.acquire()
465
-
466
- try:
467
- # Temporarily set personality if specified
468
- personality_path = self._get_config_dir() / "tts-personality.txt"
469
- if personality:
470
- # Read the ORIGINAL from the SAME file the manager writes to
471
- # (the config dir). _get_personality() reads a different set of
472
- # dirs (package dir, then global ~/.claude) and, from inside a
473
- # host project, returns the wrong value restoring that would
474
- # overwrite the project's real personality. Non-Destructive Rule.
475
- personality_file_existed = personality_path.exists()
476
- if personality_file_existed:
477
- try:
478
- original_personality = personality_path.read_text(encoding="utf-8").strip()
479
- except OSError:
480
- original_personality = None # can't read → don't clobber on restore
481
- await self._run_script(
482
- self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
483
- )
484
-
485
- # Temporarily set language if specified
486
- if language:
487
- original_language = await self._get_language()
488
- await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
489
-
490
- # Resolve LLM key: AGENTVIBES_LLM > CLAUDECODE=1 > AGENTVIBES_MCP_FALLBACK > "default"
491
- llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
492
- if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
493
- llm_key = ""
494
- if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
495
- llm_key = "claude-code"
496
- if not llm_key:
497
- fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
498
- if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
499
- llm_key = fallback
500
-
501
- # Call the TTS script via appropriate shell
502
- tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
503
- play_tts = self.hooks_dir / tts_script
504
- if self.is_windows:
505
- args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(play_tts), text]
506
- if voice:
507
- args.extend(["-VoiceOverride", voice])
508
- if llm_key:
509
- args.extend(["-llm", llm_key])
510
- else:
511
- args = ["bash", str(play_tts)]
512
- if llm_key:
513
- args.extend(["--llm", llm_key])
514
- args.append(text)
515
- if voice:
516
- args.append(voice)
517
-
518
- env = self._build_script_env()
519
-
520
- # Declare voice provenance so the resolver treats an MCP-requested
521
- # voice as a genuine explicit pick (user-explicit), never demoting it
522
- # to a per-LLM/default row the way it would an LLM echo (F-1). Only
523
- # set when the caller actually asked for a specific voice.
524
- if voice:
525
- env["AGENTVIBES_VOICE_SOURCE"] = "user-explicit"
526
-
527
- result = await asyncio.create_subprocess_exec(
528
- *args,
529
- stdin=asyncio.subprocess.DEVNULL,
530
- stdout=asyncio.subprocess.PIPE,
531
- stderr=asyncio.subprocess.PIPE,
532
- env=env,
533
- )
534
- try:
535
- try:
536
- stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=60.0)
537
- except asyncio.TimeoutError:
538
- result.kill()
539
- await result.wait()
540
- return "❌ TTS timed out after 60 seconds"
541
-
542
- if result.returncode == 0:
543
- output = stdout.decode().strip()
544
- # Strip ANSI escape codes for clean extraction
545
- _ansi_strip = _re.compile(r'\x1b\[[0-9;]*m')
546
- audio_file_path = None
547
- voice_info = None
548
- for line in output.split("\n"):
549
- clean = _ansi_strip.sub('', line).strip()
550
- if "Saved to:" in clean and audio_file_path is None:
551
- raw_path = clean.split("Saved to:")[1].strip()
552
- # Path ends at .wav (strip trailing size/count info)
553
- wav_end = raw_path.find(".wav")
554
- audio_file_path = raw_path[:wav_end + 4] if wav_end != -1 else raw_path.split()[0]
555
- if ("Voice used:" in clean or ("Voice:" in clean and "Background" not in clean)) and voice_info is None:
556
- voice_info = clean
557
-
558
- if audio_file_path:
559
- truncated = (
560
- f"{text[:50]}..." if len(text) > 50 else text
561
- )
562
- result_msg = f"✅ Spoke: {truncated}\n📁 Audio saved: {audio_file_path}"
563
- if voice_info:
564
- result_msg += f"\n{voice_info}"
565
- return result_msg
566
-
567
- return f"✅ Spoke: {text[:50]}..." if len(text) > 50 else f"✅ Spoke: {text}"
568
- else:
569
- error = stderr.decode().strip()
570
- stdout_output = stdout.decode().strip()
571
- full_error = f"{error}\nStdout: {stdout_output}" if stdout_output else error
572
- return f"❌ TTS failed: {full_error}"
573
- finally:
574
- # Ensure process cleanup
575
- if result.returncode is None:
576
- result.kill()
577
- await result.wait()
578
-
579
- finally:
580
- # Restore original personality. personality-manager.sh has no
581
- # delete-on-default behavior (unlike language-manager.sh), so if
582
- # no personality file existed before this call, restore by
583
- # deleting the file rather than writing "normal" into it — the
584
- # non-destructive-config rule means a temporary per-call override
585
- # must not leave a permanent file behind that wasn't there before.
586
- if personality:
587
- if personality_file_existed:
588
- if original_personality is not None:
589
- restore = await self._run_script(
590
- self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
591
- )
592
- if not restore.ok:
593
- import sys
594
- print(
595
- f"Warning: failed to restore personality "
596
- f"'{original_personality}': {restore.error_detail}",
597
- file=sys.stderr,
598
- )
599
- else:
600
- # No personality file existed before this call — restore by
601
- # deleting (same config dir we captured existence from).
602
- try:
603
- personality_path.unlink()
604
- except OSError:
605
- pass
606
- if original_language is not None:
607
- # "english"/"reset" makes language-manager.sh *delete* the
608
- # language file rather than write it, so this naturally
609
- # restores "no override was ever set" correctly too.
610
- restore_lang = await self._run_script(
611
- self.LANGUAGE_MANAGER_SCRIPT, ["set", original_language]
612
- )
613
- if not restore_lang.ok:
614
- import sys
615
- print(
616
- f"Warning: failed to restore language "
617
- f"'{original_language}': {restore_lang.error_detail}",
618
- file=sys.stderr,
619
- )
620
- if needs_override:
621
- self._override_lock.release()
622
-
623
- async def list_voices(self) -> str:
624
- """
625
- List all available TTS voices for the active provider.
626
-
627
- Returns:
628
- Formatted list of available voices
629
- """
630
- # Get active provider for display purposes
631
- provider = await self._get_provider()
632
- current_voice = await self._get_current_voice()
633
-
634
- # voice-manager.sh list-simple is now provider-aware
635
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["list-simple"])
636
- if result.ok and result.stdout:
637
- voices = result.stdout.strip().split("\n")
638
- voices = [v for v in voices if v] # Filter empty strings
639
-
640
- if not voices:
641
- return (
642
- f"📦 No voices available\n"
643
- f"{self.SEPARATOR}\n"
644
- f"For Piper: Download voices using /agent-vibes:provider download <voice-name>\n"
645
- f"Example: en_US-lessac-medium, en_GB-alba-medium"
646
- )
647
-
648
- # Determine provider label and alternative provider
649
- if "Piper" in provider:
650
- provider_label = "Piper TTS"
651
- alternative_provider = "macOS"
652
- elif "macOS" in provider:
653
- provider_label = "macOS TTS"
654
- alternative_provider = "Piper"
655
- elif "Termux" in provider or "Android" in provider:
656
- provider_label = "Termux SSH (Android)"
657
- alternative_provider = "Piper"
658
- else:
659
- provider_label = "TTS"
660
- alternative_provider = None
661
-
662
- output = f"🎤 Available {provider_label} Voices:\n"
663
- output += f"{self.SEPARATOR}\n"
664
- for voice in voices:
665
- marker = " ✓ (current)" if voice == current_voice else ""
666
- output += f" {voice}{marker}\n"
667
-
668
- # Expand LibriTTS named speakers when en_US-libritts-high is installed
669
- piper_voices_dir = Path.home() / ".local" / "share" / "piper-voices"
670
- libritts_onnx = piper_voices_dir / "en_US-libritts-high.onnx"
671
- if libritts_onnx.exists():
672
- catalog = self._build_libritts_catalog()
673
- if catalog:
674
- output += f"\n 📖 LibriTTS named speakers (en_US-libritts-high):\n"
675
- # De-duplicate: only one entry per display name
676
- seen: set = set()
677
- for entry in catalog.values():
678
- dn = entry["display_name"]
679
- if dn in seen:
680
- continue
681
- seen.add(dn)
682
- spk = entry["speaker_name"]
683
- sid = entry["speaker_id"]
684
- gender = entry.get("gender", "")
685
- g_icon = "♀" if gender.lower() == "female" else ("♂" if gender.lower() == "male" else "")
686
- marker = " (current)" if entry["voice_id"] == current_voice else ""
687
- output += f" {dn} ({g_icon} speaker {sid}){marker}\n"
688
-
689
- output += f"{self.SEPARATOR}\n"
690
-
691
- # Add provider switch hint
692
- if alternative_provider:
693
- output += f"\n💡 Switch to {alternative_provider}? Use: set_provider(provider=\"{alternative_provider.lower()}\")\n"
694
-
695
- return output
696
- return f"❌ Failed to list voices: {result.error_detail}"
697
-
698
- async def set_voice(self, voice_name: str) -> str:
699
- """
700
- Switch to a different voice (supports friendly names like "ryan" or "katherine").
701
-
702
- Args:
703
- voice_name: Friendly name (e.g., "ryan") or Piper voice ID
704
-
705
- Returns:
706
- Success or error message
707
- """
708
- # Try new display-name resolver first (handles "Bella Bell", "::" ids, etc.)
709
- resolved = self._resolve_voice_input(voice_name)
710
-
711
- if resolved:
712
- voice_id = resolved["voice_id"]
713
- display_name = resolved["display_name"]
714
- model = resolved["model"]
715
- speaker_id = resolved["speaker_id"]
716
- speaker_name = resolved["speaker_name"]
717
-
718
- # Write the three config files directly (no voice-manager.sh needed)
719
- config_dir = self._get_config_dir()
720
- try:
721
- config_dir.mkdir(parents=True, exist_ok=True)
722
- (config_dir / "tts-voice.txt").write_text(display_name + "\n")
723
- if speaker_name:
724
- (config_dir / "tts-piper-model.txt").write_text(model + "\n")
725
- if speaker_id is not None:
726
- (config_dir / "tts-piper-speaker-id.txt").write_text(str(speaker_id) + "\n")
727
- else:
728
- # Clear speaker-id so piper uses default
729
- try: (config_dir / "tts-piper-speaker-id.txt").unlink()
730
- except FileNotFoundError: pass
731
- else:
732
- # Single-speaker model — clear multi-speaker files
733
- for f in ("tts-piper-model.txt", "tts-piper-speaker-id.txt"):
734
- try: (config_dir / f).unlink()
735
- except FileNotFoundError: pass
736
- except OSError as e:
737
- return f"❌ Failed to write voice config: {e}"
738
-
739
- detail = f" (speaker {speaker_id}, model {model})" if speaker_id is not None else ""
740
- return f" Voice set to: {display_name}{detail}"
741
-
742
- # Fall back to legacy friendly-name resolver (voice-metadata.json)
743
- original_name = voice_name
744
- resolved_name = self._resolve_friendly_name(voice_name)
745
- result = await self._run_script(
746
- self.VOICE_MANAGER_SCRIPT, ["switch", resolved_name, "--silent"]
747
- )
748
- if result.ok:
749
- if original_name.lower() != resolved_name.lower():
750
- return f"✅ Voice switched to: {original_name} ({resolved_name})"
751
- return f"✅ Voice switched to: {voice_name}"
752
- return (
753
- f"❌ Failed to switch voice — could not resolve '{voice_name}'. "
754
- f"Try 'list_voices' to see available names. ({result.error_detail})"
755
- )
756
-
757
- async def list_personalities(self) -> str:
758
- """
759
- List all available personalities.
760
-
761
- Returns:
762
- Formatted list of personalities with descriptions
763
- """
764
- result = await self._run_script(self.PERSONALITY_MANAGER_SCRIPT, ["list"])
765
- if result.ok:
766
- return result.stdout
767
- return f"❌ Failed to list personalities: {result.error_detail}"
768
-
769
- async def set_personality(self, personality: str) -> str:
770
- """
771
- Set the personality style for TTS messages.
772
-
773
- Args:
774
- personality: Personality name (e.g., "flirty", "sarcastic", "pirate")
775
-
776
- Returns:
777
- Success or error message
778
- """
779
- # Serialize against text_to_speech's temporary override/restore so a
780
- # deliberate set here can't be silently reverted by an in-flight call's
781
- # restore step (they mutate the same tts-personality.txt).
782
- async with self._override_lock:
783
- result = await self._run_script(
784
- self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
785
- )
786
- if result.ok:
787
- # Windows (.ps1) scripts print plain text with no 🎭 marker; add
788
- # our own so the tool's output stays consistent across platforms.
789
- return result.stdout if "🎭" in result.stdout else f"🎭 {result.stdout}"
790
- return f" Failed to set personality: {result.error_detail}"
791
-
792
- async def get_config(self) -> str:
793
- """
794
- Get current AgentVibes configuration.
795
-
796
- Returns:
797
- Current voice, personality, language, provider, and LLM settings
798
- """
799
- voice = await self._get_current_voice()
800
- personality = await self._get_personality()
801
- language = await self._get_language()
802
- provider = await self._get_provider()
803
-
804
- # Resolve the LLM key using the same priority as text_to_speech:
805
- # 1. AGENTVIBES_LLM 2. CLAUDECODE=1 3. AGENTVIBES_MCP_FALLBACK 4. "default"
806
- llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
807
- if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
808
- llm_key = ""
809
- if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
810
- llm_key = "claude-code"
811
- if not llm_key:
812
- fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
813
- if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
814
- llm_key = fallback
815
- if not llm_key:
816
- llm_key = "default"
817
-
818
- output = "🎤 Current AgentVibes Configuration\n"
819
- output += f"{self.SEPARATOR}\n"
820
- output += f"LLM: {llm_key}\n"
821
- output += f"Provider: {provider}\n"
822
- output += f"Voice: {voice}\n"
823
- output += f"Personality: {personality}\n"
824
- output += f"Language: {language}\n"
825
- output += f"{self.SEPARATOR}\n"
826
- return output
827
-
828
- async def set_language(self, language: str) -> str:
829
- """
830
- Set the language for TTS speech.
831
-
832
- Args:
833
- language: Language name (e.g., "spanish", "french", "german")
834
-
835
- Returns:
836
- Success or error message
837
- """
838
- # Serialize against text_to_speech's temporary override/restore (both
839
- # mutate the same language config), so a deliberate set here isn't
840
- # silently reverted by an in-flight call's restore step.
841
- async with self._override_lock:
842
- result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
843
- if result.ok:
844
- return result.stdout if "✓" in result.stdout else f"✓ {result.stdout}"
845
- return f" Failed to set language: {result.error_detail}"
846
-
847
- async def replay_audio(self, n: int = 1) -> str:
848
- """
849
- Replay recently generated TTS audio.
850
-
851
- Args:
852
- n: Which audio to replay (1 = most recent, 2 = second most recent, etc.)
853
-
854
- Returns:
855
- Success or error message
856
- """
857
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["replay", str(n)])
858
- if result.ok:
859
- return result.stdout if "🔊" in result.stdout else f"🔊 {result.stdout}"
860
- return f" Failed to replay audio: {result.error_detail}"
861
-
862
- async def set_provider(self, provider: str) -> str:
863
- """
864
- Switch TTS provider between the supported synthesis engines.
865
-
866
- Args:
867
- provider: Provider name. Non-Windows: "piper", "macos", "termux-ssh",
868
- "soprano", "kokoro", "elevenlabs". Windows: "windows-piper",
869
- "windows-sapi", "soprano", "kokoro".
870
-
871
- Returns:
872
- Success or error message
873
- """
874
- provider = provider.lower()
875
- # Platform allowlist + display names DERIVE from provider-catalog.json
876
- # (SSOT), with embedded fallbacks (module constants above). kokoro is
877
- # cross-platform; elevenlabs is Unix-only (NO play-tts-elevenlabs.ps1, so
878
- # it is absent from the Windows set — switching to it on Windows would be
879
- # silently unplayable). See AVI-S9.1 / AVI-S9.5 and provider-catalog.js
880
- # (elevenlabs.runtime.windows === null).
881
- valid_providers = self._valid_providers()
882
- if provider not in valid_providers:
883
- return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
884
-
885
- result = await self._run_script("provider-manager.sh", ["switch", provider])
886
- if result.ok:
887
- # Automatically speak confirmation in the new provider's voice.
888
- provider_names = self._provider_display_names()
889
- provider_name = provider_names.get(provider, provider.title())
890
- confirmation_text = f"Successfully switched to {provider_name} provider"
891
-
892
- try:
893
- # Speak the confirmation with 5 second timeout to prevent hanging
894
- await asyncio.wait_for(
895
- self.text_to_speech(confirmation_text),
896
- timeout=5.0
897
- )
898
- # Return the provider switch result plus TTS confirmation
899
- return f"{result.stdout}\n🔊 Spoken confirmation: {confirmation_text}"
900
- except asyncio.TimeoutError:
901
- # Timeout - provider may need setup (e.g., Piper not installed)
902
- return f"{result.stdout}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
903
- except Exception as e:
904
- # If TTS fails, still return success for the provider switch
905
- return f"{result.stdout}\n⚠️ Provider switched but TTS confirmation failed: {e}"
906
-
907
- return f"❌ Failed to switch provider: {result.error_detail}"
908
-
909
- async def set_speed(self, speed: str, target: bool = False) -> str:
910
- """
911
- Set speech speed for main or target voice.
912
-
913
- Works with both Piper and macOS providers.
914
-
915
- Args:
916
- speed: Speed value (e.g., "0.5x", "1x", "2x", "normal", "fast", "slow")
917
- target: If True, sets target language speed; if False, sets main voice speed
918
-
919
- Returns:
920
- Success or error message
921
- """
922
- # Security: Using secrets.choice for cryptographically secure random selection
923
- # Even though this is just for UI variety, we use secrets to satisfy security scanners
924
- import secrets
925
-
926
- args = ["target", speed] if target else [speed]
927
- result = await self._run_script("speed-manager.sh", args)
928
- if result.ok:
929
- # Simple test messages to demonstrate the new speed
930
- test_messages = [
931
- "Testing speed change",
932
- "Speed test in progress",
933
- "Checking audio speed",
934
- "Speed configuration test",
935
- "Audio speed test",
936
- ]
937
-
938
- # Pick a random test message and speak it
939
- test_message = secrets.choice(test_messages)
940
-
941
- try:
942
- # Speak the test message to demonstrate the new speed
943
- await self.text_to_speech(test_message)
944
- return f"{result.stdout}\n🔊 Testing new speed: \"{test_message}\""
945
- except Exception as e:
946
- # If TTS fails, still return success for the speed change
947
- return f"{result.stdout}\n⚠️ Speed changed but demo failed: {e}"
948
-
949
- return f"❌ Failed to set speed: {result.error_detail}"
950
-
951
- async def get_speed(self) -> str:
952
- """
953
- Get current speech speed settings.
954
-
955
- Returns:
956
- Current speed settings for main and target voices
957
- """
958
- result = await self._run_script("speed-manager.sh", ["get"])
959
- return result.stdout if result.ok else f"❌ Failed to get speed settings: {result.error_detail}"
960
-
961
- async def download_extra_voices(self, auto_yes: bool = False) -> str:
962
- """
963
- Download extra high-quality Piper voices from HuggingFace.
964
-
965
- Downloads custom voices: Kristin, Jenny, and Tracy/16Speakers.
966
-
967
- Args:
968
- auto_yes: If True, skips confirmation prompt and downloads automatically
969
-
970
- Returns:
971
- Success message with download summary
972
- """
973
- if not auto_yes:
974
- # download-extra-voices.sh hits `read -p "...? [Y/n]: "` when no
975
- # --yes flag is given. Since stdin is always DEVNULL (see
976
- # _run_script), that read would return EOF/empty rather than
977
- # hang but reaching it at all is still the wrong behavior for
978
- # an MCP tool: an LLM caller can't answer an interactive prompt.
979
- # Fail fast with a clear, actionable error instead of ever
980
- # spawning the script.
981
- return (
982
- "⚠️ Confirmation required: call download_extra_voices(auto_yes=True) "
983
- "to download the extra voices. This tool cannot answer an interactive "
984
- "Y/n prompt, so it refuses to start the download without explicit consent."
985
- )
986
- result = await self._run_script("download-extra-voices.sh", ["--yes"], timeout=180.0)
987
- if result.ok:
988
- return result.stdout
989
- return f"❌ Failed to download extra voices: {result.error_detail}"
990
-
991
- async def get_verbosity(self) -> str:
992
- """
993
- Get current verbosity level.
994
-
995
- Returns:
996
- Current verbosity level with description
997
- """
998
- result = await self._run_script("verbosity-manager.sh", ["get"])
999
- if result.ok:
1000
- level = result.stdout.strip()
1001
- descriptions = {
1002
- "low": "LOW - Acknowledgments + Completions only (minimal)",
1003
- "medium": "MEDIUM - + Major decisions and findings (balanced)",
1004
- "high": "HIGH - All reasoning (maximum transparency)"
1005
- }
1006
- desc = descriptions.get(level, level)
1007
- return f"🎙️ Current Verbosity: {desc}\n\n💡 Change with: set_verbosity(level=\"low|medium|high\")"
1008
- return f" Failed to get verbosity level: {result.error_detail}"
1009
-
1010
- async def set_verbosity(self, level: str) -> str:
1011
- """
1012
- Set verbosity level to control how much Claude speaks.
1013
-
1014
- Args:
1015
- level: Verbosity level (low, medium, or high)
1016
-
1017
- Returns:
1018
- Success or error message
1019
- """
1020
- result = await self._run_script("verbosity-manager.sh", ["set", level])
1021
- if result.ok:
1022
- body = result.stdout if "✅" in result.stdout else f"✅ {result.stdout}"
1023
- return f"{body}\n\n⚠️ Restart Claude Code for changes to take effect"
1024
- return f" Failed to set verbosity: {result.error_detail}"
1025
-
1026
- def _get_mute_files(self) -> list:
1027
- """Get all mute file paths for current platform"""
1028
- files = [
1029
- Path.home() / self.MUTE_FILE_NAME,
1030
- Path.cwd() / self.CLAUDE_DIR_NAME / "agentvibes-muted",
1031
- ]
1032
- # Windows PowerShell scripts check tts-muted.txt in .claude dir
1033
- if self.is_windows:
1034
- files.append(Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt")
1035
- return files
1036
-
1037
- async def mute(self) -> str:
1038
- """
1039
- Mute all TTS output. Creates a persistent mute flag.
1040
-
1041
- Returns:
1042
- Success message confirming mute is active
1043
- """
1044
- try:
1045
- mute_file = Path.home() / self.MUTE_FILE_NAME
1046
- mute_file.touch()
1047
- # On Windows, also write tts-muted.txt for PowerShell script compatibility
1048
- if self.is_windows:
1049
- win_mute = Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt"
1050
- win_mute.parent.mkdir(parents=True, exist_ok=True)
1051
- win_mute.write_text("true")
1052
- return "🔇 AgentVibes TTS muted. All voice output is now silenced.\n\n💡 To unmute, use: unmute()"
1053
- except Exception as e:
1054
- return f"❌ Failed to mute: {e}"
1055
-
1056
- async def unmute(self) -> str:
1057
- """
1058
- Unmute TTS output. Removes the mute flag.
1059
-
1060
- Returns:
1061
- Success message confirming TTS is restored
1062
- """
1063
- removed = []
1064
- try:
1065
- for mute_file in self._get_mute_files():
1066
- if mute_file.exists():
1067
- # tts-muted.txt uses content "true"/"false", others use file existence
1068
- if mute_file.name == "tts-muted.txt":
1069
- content = mute_file.read_text().strip()
1070
- if content == "true":
1071
- mute_file.write_text("false")
1072
- removed.append(str(mute_file.name))
1073
- else:
1074
- mute_file.unlink()
1075
- removed.append(str(mute_file.name))
1076
-
1077
- if removed:
1078
- return f"🔊 AgentVibes TTS unmuted. Voice output is now restored.\n (Removed: {', '.join(removed)} mute flag)"
1079
- else:
1080
- return "🔊 AgentVibes TTS was not muted. Voice output is active."
1081
- except Exception as e:
1082
- return f"❌ Failed to unmute: {e}"
1083
-
1084
- async def is_muted(self) -> str:
1085
- """
1086
- Check if TTS is currently muted.
1087
-
1088
- Returns:
1089
- Current mute status
1090
- """
1091
- for mute_file in self._get_mute_files():
1092
- if mute_file.exists():
1093
- # tts-muted.txt uses content "true"/"false"
1094
- if mute_file.name == "tts-muted.txt":
1095
- content = mute_file.read_text().strip()
1096
- if content == "true":
1097
- return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
1098
- else:
1099
- return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
1100
- return "🔊 TTS is currently ACTIVE\n\n💡 To mute, use: mute()"
1101
-
1102
- async def list_background_music(self) -> str:
1103
- """
1104
- List all available background music tracks.
1105
-
1106
- Returns:
1107
- Formatted list of all pre-packaged background music files
1108
- """
1109
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
1110
- return result.stdout if result.ok else f"❌ Failed to list background music: {result.error_detail}"
1111
-
1112
- async def set_background_music(self, track_name: str, agent_name: Optional[str] = None) -> str:
1113
- """
1114
- Set background music track for a specific agent, all agents, or as default.
1115
-
1116
- Args:
1117
- track_name: Track filename or partial name for fuzzy matching
1118
- agent_name: Agent name ('all' for all agents, None for default)
1119
-
1120
- Returns:
1121
- Success or error message
1122
- """
1123
- import re
1124
-
1125
- # Get list of available tracks for fuzzy matching
1126
- list_result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
1127
- if not list_result.ok:
1128
- return f"❌ Failed to list background music tracks: {list_result.error_detail}"
1129
-
1130
- # Parse track names
1131
- tracks = []
1132
- for line in list_result.stdout.split("\n"):
1133
- match = re.match(r'\s*\d+\.\s+(.+)', line.strip())
1134
- if match:
1135
- tracks.append(match.group(1).strip())
1136
-
1137
- # Try to find a matching track (case-insensitive partial match)
1138
- track_lower = track_name.lower()
1139
- matched_track = None
1140
-
1141
- # First try exact match
1142
- for track in tracks:
1143
- if track.lower() == track_lower:
1144
- matched_track = track
1145
- break
1146
-
1147
- # If no exact match, try partial match
1148
- if not matched_track:
1149
- for track in tracks:
1150
- if track_lower in track.lower():
1151
- matched_track = track
1152
- break
1153
-
1154
- if not matched_track:
1155
- # Show available tracks to help user
1156
- available = "\n".join([f" • {t}" for t in tracks])
1157
- return f" No track matching '{track_name}' found.\n\nAvailable tracks:\n{available}\n\n💡 Try a partial match like 'celtic' or 'chillwave'"
1158
-
1159
- # Determine which command to use based on agent_name
1160
- if agent_name and agent_name.lower() == "all":
1161
- # Set for all agents
1162
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-all", matched_track])
1163
- elif agent_name:
1164
- # Set for specific agent
1165
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-agent", agent_name, matched_track])
1166
- else:
1167
- # Set as default
1168
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-default", matched_track])
1169
-
1170
- if result.ok:
1171
- if matched_track.lower() != track_name.lower():
1172
- return f"{result.stdout}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
1173
- return result.stdout
1174
- return f"❌ Failed to set background music: {result.error_detail}"
1175
-
1176
- async def enable_background_music(self, enabled: bool) -> str:
1177
- """
1178
- Enable or disable background music globally.
1179
-
1180
- Args:
1181
- enabled: True to enable, False to disable
1182
-
1183
- Returns:
1184
- Success or error message
1185
- """
1186
- command = "on" if enabled else "off"
1187
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, [command])
1188
- # Sync to .agentvibes/config.json (TUI source of truth)
1189
- try:
1190
- import json
1191
- cfg_path = self.agentvibes_root / ".agentvibes" / "config.json"
1192
- cfg = {}
1193
- if cfg_path.exists():
1194
- cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
1195
- if "backgroundMusic" not in cfg:
1196
- cfg["backgroundMusic"] = {}
1197
- cfg["backgroundMusic"]["enabled"] = enabled
1198
- cfg_path.parent.mkdir(parents=True, exist_ok=True)
1199
- cfg_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
1200
- except Exception:
1201
- pass # best-effort sync
1202
- if result.ok:
1203
- return result.stdout
1204
- return f"❌ Failed to {'enable' if enabled else 'disable'} background music: {result.error_detail}"
1205
-
1206
- async def set_background_music_volume(self, volume: float) -> str:
1207
- """
1208
- Set background music volume.
1209
-
1210
- Args:
1211
- volume: Volume level (0.0-1.0)
1212
-
1213
- Returns:
1214
- Success or error message
1215
- """
1216
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["volume", str(volume)])
1217
- return result.stdout if result.ok else f" Failed to set background music volume: {result.error_detail}"
1218
-
1219
- async def get_background_music_status(self) -> str:
1220
- """
1221
- Get current background music configuration.
1222
-
1223
- Returns:
1224
- Status information
1225
- """
1226
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["status"])
1227
- return result.stdout if result.ok else f"❌ Failed to get background music status: {result.error_detail}"
1228
-
1229
- async def set_reverb(self, level: str, agent: str = "default", apply_all: bool = False) -> str:
1230
- """
1231
- Set reverb level for an agent or globally.
1232
-
1233
- Args:
1234
- level: Reverb level (off, light, medium, heavy, cathedral)
1235
- agent: Agent name (default: "default")
1236
- apply_all: Apply to all agents (default: False)
1237
-
1238
- Returns:
1239
- Success message
1240
- """
1241
- args = ["set-reverb", level, agent]
1242
- if apply_all:
1243
- args.append("--all")
1244
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, args)
1245
- if result.ok:
1246
- return result.stdout if result.stdout else f"✅ Set reverb to {level}"
1247
- return f" Failed to set reverb: {result.error_detail}"
1248
-
1249
- async def get_reverb(self, agent: str = "default") -> str:
1250
- """
1251
- Get current reverb level for an agent.
1252
-
1253
- Args:
1254
- agent: Agent name (default: "default")
1255
-
1256
- Returns:
1257
- Current reverb level
1258
- """
1259
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["get-reverb", agent])
1260
- if result.ok:
1261
- return f"Current reverb level for {agent}: {result.stdout.strip()}"
1262
- return f" Failed to get reverb for {agent}: {result.error_detail}"
1263
-
1264
- async def list_audio_effects(self) -> str:
1265
- """
1266
- List all audio effects for all agents.
1267
-
1268
- Returns:
1269
- Effects configuration
1270
- """
1271
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["list"])
1272
- return result.stdout if result.ok else f"❌ Failed to list audio effects: {result.error_detail}"
1273
-
1274
- async def clean_audio_cache(self) -> str:
1275
- """
1276
- Clean all TTS audio cache files and report space freed.
1277
-
1278
- Non-interactive cleanup suitable for MCP tool usage. Deletes all
1279
- TTS-generated audio files (wav, mp3, aiff) while preserving
1280
- background music tracks.
1281
-
1282
- Returns:
1283
- Cleanup results with file count and space freed
1284
- """
1285
- result = await self._run_script("clean-audio-cache.sh", [])
1286
- return result.stdout if result.ok else f"❌ Failed to clean audio cache: {result.error_detail}"
1287
-
1288
- # ── Hermes config helpers ────────────────────────────────────────────────
1289
-
1290
- def _hermes_cfg_path(self) -> Path:
1291
- hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
1292
- return hermes_home / "hooks" / "agentvibes-tts" / "agentvibes-ssh-config.json"
1293
-
1294
- async def get_hermes_config(self) -> str:
1295
- """
1296
- Get current Hermes AgentVibes SSH configuration.
1297
-
1298
- Returns:
1299
- Current SSH key, host, port, and voice settings
1300
- """
1301
- cfg_path = self._hermes_cfg_path()
1302
- defaults = {
1303
- "mode": "local",
1304
- "sshKey": "/absolute/path/to/id_ed25519_agentvibes",
1305
- "host": "your-receiver-tailscale-ip",
1306
- "port": "2222",
1307
- "voice": "en_US-libritts-high::Leo-8",
1308
- }
1309
- try:
1310
- cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
1311
- except Exception:
1312
- cfg = {}
1313
- merged = {**defaults, **cfg}
1314
- installed = cfg_path.exists()
1315
- is_local = merged.get("mode", "local") == "local"
1316
- out = "🔌 Hermes AgentVibes Configuration\n"
1317
- out += "─" * 40 + "\n"
1318
- out += f"Status: {'✅ Configured' if installed else '⚠️ Not yet installed (run: agentvibes install)'}\n"
1319
- out += f"Mode: {'🏠 Local (Hermes & speakers on same machine)' if is_local else '🌐 Remote (SSH to receiver)'}\n"
1320
- out += f"Voice: {merged['voice']}\n"
1321
- if not is_local:
1322
- out += f"SSH Key: {merged['sshKey']}\n"
1323
- out += f"Host: {merged['host']}\n"
1324
- out += f"Port: {merged['port']}\n"
1325
- if installed:
1326
- out += f"\nConfig file: {cfg_path}\n"
1327
- out += "After changes, run: hermes gateway restart\n"
1328
- return out
1329
-
1330
- async def set_hermes_config(
1331
- self,
1332
- mode: Optional[str] = None,
1333
- ssh_key: Optional[str] = None,
1334
- host: Optional[str] = None,
1335
- port: Optional[str] = None,
1336
- voice: Optional[str] = None,
1337
- ) -> str:
1338
- """
1339
- Save Hermes AgentVibes SSH configuration.
1340
-
1341
- Returns:
1342
- Success message with saved values
1343
- """
1344
- import re as _re
1345
- cfg_path = self._hermes_cfg_path()
1346
- defaults = {
1347
- "mode": "local",
1348
- "sshKey": "/absolute/path/to/id_ed25519_agentvibes",
1349
- "host": "your-receiver-tailscale-ip",
1350
- "port": "2222",
1351
- "voice": "en_US-libritts-high::Leo-8",
1352
- }
1353
- try:
1354
- existing = json.loads(cfg_path.read_text(encoding="utf-8"))
1355
- except Exception:
1356
- existing = {}
1357
- merged = {**defaults, **existing}
1358
-
1359
- if mode is not None:
1360
- m = str(mode).lower().strip()
1361
- if m not in ("local", "remote"):
1362
- return "❌ Invalid mode: must be 'local' or 'remote'"
1363
- merged["mode"] = m
1364
- if ssh_key is not None:
1365
- sk = str(ssh_key).strip()
1366
- if not _re.match(r'^[/~][a-zA-Z0-9_./ -]{0,511}$', sk):
1367
- return "❌ Invalid ssh_key: must be an absolute path (no special chars)"
1368
- merged["sshKey"] = sk
1369
- if host is not None:
1370
- h = str(host).strip()
1371
- if not _re.match(r'^[a-zA-Z0-9._\[\]:-]{1,253}$', h):
1372
- return "❌ Invalid host: must be a hostname or IP address"
1373
- merged["host"] = h
1374
- if port is not None:
1375
- p = str(port).strip()
1376
- if not _re.match(r"^\d{1,5}$", p):
1377
- return "❌ Invalid port: must be a number (e.g. '2222')"
1378
- merged["port"] = p
1379
- if voice is not None:
1380
- merged["voice"] = str(voice)[:200]
1381
-
1382
- try:
1383
- cfg_path.parent.mkdir(parents=True, exist_ok=True)
1384
- cfg_path.parent.chmod(0o700)
1385
- cfg_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
1386
- cfg_path.chmod(0o600)
1387
- except Exception as e:
1388
- return f"❌ Failed to save config: {e}"
1389
-
1390
- is_local = merged.get("mode", "local") == "local"
1391
- out = " Hermes config saved!\n"
1392
- out += "─" * 40 + "\n"
1393
- out += f"Mode: {'🏠 Local' if is_local else '🌐 Remote (SSH)'}\n"
1394
- out += f"Voice: {merged['voice']}\n"
1395
- if not is_local:
1396
- out += f"SSH Key: {merged['sshKey']}\n"
1397
- out += f"Host: {merged['host']}\n"
1398
- out += f"Port: {merged['port']}\n"
1399
- out += f"\nConfig file: {cfg_path}\n"
1400
- out += "Run: hermes gateway restart\n"
1401
- return out
1402
-
1403
- # Helper methods
1404
- def _build_script_env(self) -> dict:
1405
- """Build environment dict for script execution (shared by all script runners)"""
1406
- env = os.environ.copy()
1407
-
1408
- # Determine where to save settings based on context:
1409
- # 1. If cwd has .claude/ Use cwd (real Claude Code project)
1410
- # 2. Otherwise → Use global ~/.claude/ (Claude Desktop, Warp, etc.)
1411
- # Note: Hooks are ALWAYS from package .claude/ (self.claude_dir)
1412
- cwd = Path.cwd()
1413
- if (cwd / ".claude").is_dir() and cwd != self.agentvibes_root:
1414
- env["CLAUDE_PROJECT_DIR"] = str(cwd)
1415
-
1416
- # Augment PATH with platform-specific binary locations (Unix only).
1417
- # MCP servers launched by Claude Desktop inherit a sanitized launchd/dbus PATH
1418
- # that omits Homebrew (Mac) and pipx (all POSIX) locations.
1419
- if not self.is_windows:
1420
- home_dir = Path.home()
1421
- extra_paths = [
1422
- str(home_dir / ".local" / "bin"),
1423
- str(home_dir / ".local" / "share" / "pipx" / "venvs" / "piper-tts" / "bin"),
1424
- ]
1425
- # Mac: add Homebrew prefix for both Apple Silicon (/opt/homebrew) and Intel (/usr/local)
1426
- if self.is_darwin:
1427
- extra_paths = ["/opt/homebrew/bin", "/usr/local/bin"] + extra_paths
1428
-
1429
- current_path = env.get("PATH", "")
1430
- path_parts = current_path.split(os.pathsep) if current_path else []
1431
- new_dirs = [p for p in extra_paths if p not in path_parts]
1432
- if new_dirs:
1433
- env["PATH"] = os.pathsep.join(new_dirs) + os.pathsep + current_path
1434
-
1435
- return env
1436
-
1437
- async def _run_script(
1438
- self,
1439
- script_name: str,
1440
- args: list[str],
1441
- timeout: float = DEFAULT_SCRIPT_TIMEOUT,
1442
- ) -> ScriptResult:
1443
- """Run a script and return its (returncode, stdout, stderr) as a ScriptResult.
1444
-
1445
- Callers MUST branch on `.ok`/`.returncode` — never on text/emoji
1446
- content because Windows manager scripts (.ps1) print plain text
1447
- where the Unix (.sh) scripts print an emoji marker.
1448
-
1449
- `stdin` is always DEVNULL and a timeout is always enforced so a
1450
- script that reaches an interactive prompt (e.g. `read -p`) cannot
1451
- inherit the MCP stdio JSON-RPC stream or hang the server forever.
1452
- """
1453
- # Auto-resolve .sh → .ps1 on Windows (class constants handle special cases)
1454
- if self.is_windows and script_name.endswith('.sh'):
1455
- script_name = script_name[:-3] + '.ps1'
1456
- script_path = self.hooks_dir / script_name
1457
- if not script_path.exists():
1458
- return ScriptResult(127, "", f"Script not found: {script_path}")
1459
-
1460
- # Build command — PowerShell on Windows, bash on Unix
1461
- if self.is_windows:
1462
- cmd = [
1463
- "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
1464
- "-File", str(script_path)
1465
- ] + args
1466
- else:
1467
- cmd = ["bash", str(script_path)] + args
1468
-
1469
- env = self._build_script_env()
1470
-
1471
- proc = None
1472
- try:
1473
- proc = await asyncio.create_subprocess_exec(
1474
- *cmd,
1475
- stdin=asyncio.subprocess.DEVNULL,
1476
- stdout=asyncio.subprocess.PIPE,
1477
- stderr=asyncio.subprocess.PIPE,
1478
- env=env,
1479
- )
1480
- try:
1481
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
1482
- except asyncio.TimeoutError:
1483
- proc.kill()
1484
- await proc.wait()
1485
- return ScriptResult(
1486
- -1, "",
1487
- f"Script '{script_name}' timed out after {timeout:.0f}s "
1488
- "(it may have reached an interactive prompt)"
1489
- )
1490
- return ScriptResult(
1491
- proc.returncode if proc.returncode is not None else -1,
1492
- stdout.decode(errors="replace").strip(),
1493
- stderr.decode(errors="replace").strip(),
1494
- )
1495
- except Exception as e:
1496
- return ScriptResult(-2, "", f"Error running script: {e}")
1497
- finally:
1498
- # Ensure process cleanup
1499
- if proc is not None and proc.returncode is None:
1500
- proc.kill()
1501
- await proc.wait()
1502
-
1503
- async def _get_current_voice(self) -> str:
1504
- """Get the currently active voice"""
1505
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["get"])
1506
- return result.stdout.strip() if result.ok and result.stdout else "Unknown"
1507
-
1508
- async def _get_personality(self) -> str:
1509
- """Get the current personality setting"""
1510
- personality_file = self.claude_dir / "tts-personality.txt"
1511
- if not personality_file.exists():
1512
- # Try global
1513
- personality_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-personality.txt"
1514
-
1515
- try:
1516
- if personality_file.exists():
1517
- return personality_file.read_text().strip()
1518
- except (PermissionError, UnicodeDecodeError, OSError) as e:
1519
- # Log error but don't crash - return default
1520
- import sys
1521
- print(f"Warning: Could not read personality file: {e}", file=sys.stderr)
1522
- return "normal"
1523
-
1524
- async def _get_language(self) -> str:
1525
- """Get the current language setting"""
1526
- result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["code"])
1527
- return result.stdout.strip() if result.ok and result.stdout else "english"
1528
-
1529
- async def _get_provider(self) -> str:
1530
- """Get the active TTS provider"""
1531
- provider_file = self.claude_dir / "tts-provider.txt"
1532
- if not provider_file.exists():
1533
- provider_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-provider.txt"
1534
-
1535
- provider_labels = {
1536
- "macos": "macOS TTS",
1537
- "piper": "Piper TTS (Free, Offline)",
1538
- "termux-ssh": "Termux SSH (Android)",
1539
- "windows-piper": "Windows Piper TTS (Free, Offline)",
1540
- "windows-sapi": "Windows SAPI (Built-in)",
1541
- "soprano": "Soprano TTS (Ultra-fast Neural)",
1542
- }
1543
- try:
1544
- if provider_file.exists():
1545
- provider = provider_file.read_text().strip()
1546
- # Strip BOM from PowerShell-written files
1547
- provider = provider.lstrip('\ufeff')
1548
- return provider_labels.get(provider, provider)
1549
- except (PermissionError, UnicodeDecodeError, OSError) as e:
1550
- # Log error but don't crash - return default
1551
- import sys
1552
- print(f"Warning: Could not read provider file: {e}", file=sys.stderr)
1553
- # Default based on platform
1554
- if self.is_windows:
1555
- return "Windows SAPI (Built-in)"
1556
- return "Piper TTS (Free, Offline)"
1557
-
1558
-
1559
- # Create the MCP server
1560
- app = Server("agentvibes")
1561
- agent_vibes = AgentVibesServer()
1562
-
1563
-
1564
- @app.list_tools()
1565
- async def list_tools() -> list[Tool]:
1566
- """List all available AgentVibes tools"""
1567
- return [
1568
- Tool(
1569
- name="text_to_speech",
1570
- description="""Convert text to speech using AgentVibes TTS.
1571
-
1572
- Supports both macOS TTS and Piper (free, offline) providers.
1573
- Can use different voices, personalities, and languages.
1574
-
1575
- Perfect for:
1576
- - Speaking acknowledgments and confirmations
1577
- - Adding voice to Claude responses
1578
- - Multi-language communication
1579
- - Personality-driven interactions
1580
-
1581
- Examples:
1582
- - text_to_speech(text="Hello, I'm ready to help!")
1583
- - text_to_speech(text="Task completed!", personality="flirty")
1584
- - text_to_speech(text="Hola, ¿cómo estás?", language="spanish")
1585
- """,
1586
- inputSchema={
1587
- "type": "object",
1588
- "properties": {
1589
- "text": {
1590
- "type": "string",
1591
- "description": "Text to convert to speech (max 500 characters)",
1592
- },
1593
- "voice": {
1594
- "type": "string",
1595
- "description": "Voice name (optional). Use list_voices to see options.",
1596
- },
1597
- "personality": {
1598
- "type": "string",
1599
- "description": "Personality style (optional). Examples: flirty, sarcastic, pirate, robot, zen",
1600
- },
1601
- "language": {
1602
- "type": "string",
1603
- "description": "Language to speak in (optional). Examples: spanish, french, german, italian",
1604
- },
1605
- },
1606
- "required": ["text"],
1607
- },
1608
- ),
1609
- Tool(
1610
- name="list_voices",
1611
- description="List all available TTS voices with current selection",
1612
- inputSchema={"type": "object", "properties": {}},
1613
- ),
1614
- Tool(
1615
- name="set_voice",
1616
- description="Switch to a different TTS voice",
1617
- inputSchema={
1618
- "type": "object",
1619
- "properties": {
1620
- "voice_name": {
1621
- "type": "string",
1622
- "description": "Name of the voice to switch to",
1623
- }
1624
- },
1625
- "required": ["voice_name"],
1626
- },
1627
- ),
1628
- Tool(
1629
- name="list_personalities",
1630
- description="List all available personality styles with descriptions",
1631
- inputSchema={"type": "object", "properties": {}},
1632
- ),
1633
- Tool(
1634
- name="set_personality",
1635
- description="Set the personality style for TTS messages",
1636
- inputSchema={
1637
- "type": "object",
1638
- "properties": {
1639
- "personality": {
1640
- "type": "string",
1641
- "description": "Personality name (e.g., flirty, sarcastic, pirate)",
1642
- }
1643
- },
1644
- "required": ["personality"],
1645
- },
1646
- ),
1647
- Tool(
1648
- name="set_language",
1649
- description="Set the language for TTS speech (supports 25+ languages)",
1650
- inputSchema={
1651
- "type": "object",
1652
- "properties": {
1653
- "language": {
1654
- "type": "string",
1655
- "description": "Language name (e.g., spanish, french, german)",
1656
- }
1657
- },
1658
- "required": ["language"],
1659
- },
1660
- ),
1661
- Tool(
1662
- name="get_config",
1663
- description="Get current voice, personality, language, and provider configuration",
1664
- inputSchema={"type": "object", "properties": {}},
1665
- ),
1666
- Tool(
1667
- name="replay_audio",
1668
- description="Replay recently generated TTS audio",
1669
- inputSchema={
1670
- "type": "object",
1671
- "properties": {
1672
- "n": {
1673
- "type": "integer",
1674
- "description": "Which audio to replay (1 = most recent, default: 1)",
1675
- "minimum": 1,
1676
- "maximum": 10,
1677
- }
1678
- },
1679
- },
1680
- ),
1681
- Tool(
1682
- name="set_provider",
1683
- description="Switch between TTS providers" + (
1684
- ": Windows Piper, Windows SAPI, or Soprano" if agent_vibes.is_windows
1685
- else ": macOS TTS, Piper (free, offline), Soprano, or Termux SSH (Android)"
1686
- ),
1687
- inputSchema={
1688
- "type": "object",
1689
- "properties": {
1690
- "provider": {
1691
- "type": "string",
1692
- "description": (
1693
- "Provider name: 'windows-piper', 'windows-sapi', or 'soprano'"
1694
- if agent_vibes.is_windows
1695
- else "Provider name: 'piper', 'macos', 'soprano', or 'termux-ssh'"
1696
- ),
1697
- "enum": (
1698
- ["windows-piper", "windows-sapi", "soprano"]
1699
- if agent_vibes.is_windows
1700
- else ["piper", "macos", "soprano", "termux-ssh"]
1701
- ),
1702
- }
1703
- },
1704
- "required": ["provider"],
1705
- },
1706
- ),
1707
- Tool(
1708
- name="set_speed",
1709
- description="Set speech speed for main or target voice. Works with both Piper and macOS providers. Use this to make voices faster or slower.",
1710
- inputSchema={
1711
- "type": "object",
1712
- "properties": {
1713
- "speed": {
1714
- "type": "string",
1715
- "description": "Speed value: '0.5x' or 'slow/slower' (half speed, slower), '1x' or 'normal' (normal speed), '2x' or 'fast' (double speed, faster), '3x' or 'faster' (triple speed, very fast)"
1716
- },
1717
- "target": {
1718
- "type": "boolean",
1719
- "description": "If true, sets target language speed (for learning mode); if false or omitted, sets main voice speed",
1720
- "default": False
1721
- }
1722
- },
1723
- "required": ["speed"],
1724
- },
1725
- ),
1726
- Tool(
1727
- name="get_speed",
1728
- description="Get current speech speed settings for main and target voices",
1729
- inputSchema={"type": "object", "properties": {}},
1730
- ),
1731
- Tool(
1732
- name="download_extra_voices",
1733
- description=(
1734
- "Download extra high-quality custom Piper voices from HuggingFace. "
1735
- "Includes: Kristin (US female), Jenny (UK female with Irish accent), "
1736
- "and Tracy/16Speakers (multi-speaker). Perfect for adding variety to "
1737
- "your TTS voices. This tool never proceeds without explicit consent: "
1738
- "call it with auto_yes=True to actually start the download, or it "
1739
- "returns a 'confirmation required' message and does nothing."
1740
- ),
1741
- inputSchema={
1742
- "type": "object",
1743
- "properties": {
1744
- "auto_yes": {
1745
- "type": "boolean",
1746
- "description": (
1747
- "Must be True to download. False (the default) returns a "
1748
- "confirmation-required message without downloading anything — "
1749
- "this tool cannot answer an interactive Y/n prompt."
1750
- ),
1751
- "default": False
1752
- }
1753
- },
1754
- },
1755
- ),
1756
- Tool(
1757
- name="get_verbosity",
1758
- description="Get current AgentVibes verbosity level (low/medium/high/caveman). Verbosity controls how much Claude speaks while working - from minimal (acknowledgments only) to maximum transparency (all reasoning spoken) to caveman (ultra-terse fragments, max token savings).",
1759
- inputSchema={"type": "object", "properties": {}},
1760
- ),
1761
- Tool(
1762
- name="set_verbosity",
1763
- description="""Set AgentVibes verbosity level to control how much Claude speaks while working.
1764
-
1765
- Verbosity Levels:
1766
- - LOW: Only acknowledgments (start) and completions (end). Minimal interruption.
1767
- - MEDIUM: + Major decisions and key findings. Balanced transparency.
1768
- - HIGH: All reasoning, decisions, and findings. Maximum transparency.
1769
- - CAVEMAN: Ultra-terse fragments. Drops articles, filler, hedging. Abbreviates heavily. 65-75% fewer output tokens.
1770
-
1771
- Perfect for:
1772
- - LOW: Quiet work sessions, minimal distraction
1773
- - MEDIUM: Understanding major decisions without full narration
1774
- - HIGH: Full transparency, learning mode, debugging complex tasks
1775
- - CAVEMAN: Maximum token savings, minimal prose
1776
-
1777
- Note: Changes take effect on next Claude Code session restart.""",
1778
- inputSchema={
1779
- "type": "object",
1780
- "properties": {
1781
- "level": {
1782
- "type": "string",
1783
- "description": "Verbosity level to set",
1784
- "enum": ["low", "medium", "high", "caveman"]
1785
- }
1786
- },
1787
- "required": ["level"],
1788
- },
1789
- ),
1790
- Tool(
1791
- name="mute",
1792
- description="Mute all AgentVibes TTS output. Creates a persistent mute flag that silences all voice output until unmuted. Persists across sessions.",
1793
- inputSchema={"type": "object", "properties": {}},
1794
- ),
1795
- Tool(
1796
- name="unmute",
1797
- description="Unmute AgentVibes TTS output. Removes the mute flag and restores voice output.",
1798
- inputSchema={"type": "object", "properties": {}},
1799
- ),
1800
- Tool(
1801
- name="is_muted",
1802
- description="Check if TTS is currently muted.",
1803
- inputSchema={"type": "object", "properties": {}},
1804
- ),
1805
- Tool(
1806
- name="list_background_music",
1807
- description="List all available pre-packaged background music tracks. Shows all audio files that can be used as background music for TTS.",
1808
- inputSchema={"type": "object", "properties": {}},
1809
- ),
1810
- Tool(
1811
- name="set_background_music",
1812
- description="""Set background music track for a specific agent, all agents, or as default. Supports smart fuzzy matching.
1813
-
1814
- Perfect for:
1815
- - "change background music to flamenco" - Sets for all agents
1816
- - "set John's background music to celtic harp" - Agent-specific
1817
- - "use chillwave as default background" - Default for new agents
1818
-
1819
- Fuzzy matching examples:
1820
- - "flamenco" matches "agentvibes_soft_flamenco_loop.mp3"
1821
- - "celtic" matches "agent_vibes_celtic_harp_v1_loop.mp3"
1822
- - "bossa" matches "agent_vibes_bossa_nova_v2_loop.mp3"
1823
- """,
1824
- inputSchema={
1825
- "type": "object",
1826
- "properties": {
1827
- "track_name": {
1828
- "type": "string",
1829
- "description": "Track filename or partial name for fuzzy matching (e.g., 'celtic', 'flamenco', 'bossa nova')",
1830
- },
1831
- "agent_name": {
1832
- "type": "string",
1833
- "description": "Agent name to configure (optional). Use 'all' for all agents, omit for default",
1834
- },
1835
- },
1836
- "required": ["track_name"],
1837
- },
1838
- ),
1839
- Tool(
1840
- name="enable_background_music",
1841
- description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 20%).",
1842
- inputSchema={
1843
- "type": "object",
1844
- "properties": {
1845
- "enabled": {
1846
- "type": "boolean",
1847
- "description": "True to enable background music, False to disable",
1848
- }
1849
- },
1850
- "required": ["enabled"],
1851
- },
1852
- ),
1853
- Tool(
1854
- name="set_background_music_volume",
1855
- description="Set the volume level for background music (0.0-1.0). Recommended: 0.20-0.40 for subtle background ambiance.",
1856
- inputSchema={
1857
- "type": "object",
1858
- "properties": {
1859
- "volume": {
1860
- "type": "number",
1861
- "description": "Volume level (0.0 = silent, 0.20 = default, 1.0 = full volume)",
1862
- "minimum": 0.0,
1863
- "maximum": 1.0,
1864
- }
1865
- },
1866
- "required": ["volume"],
1867
- },
1868
- ),
1869
- Tool(
1870
- name="get_background_music_status",
1871
- description="Get current background music configuration including enabled status, volume, default track, and number of available tracks.",
1872
- inputSchema={"type": "object", "properties": {}},
1873
- ),
1874
- Tool(
1875
- name="set_reverb",
1876
- description="""Set reverb level for TTS audio. Can apply globally (default agent), to a specific agent, or to all agents.
1877
-
1878
- Reverb adds room/space ambiance to the voice, making it sound like it's in a small room, conference room, or large hall.
1879
-
1880
- Examples:
1881
- - set_reverb(level="medium") - Set reverb for default agent
1882
- - set_reverb(level="cathedral", agent="Winston") - Set cathedral reverb for Winston
1883
- - set_reverb(level="light", apply_all=True) - Set light reverb for all agents
1884
- - set_reverb(level="off") - Turn off reverb for default agent
1885
- """,
1886
- inputSchema={
1887
- "type": "object",
1888
- "properties": {
1889
- "level": {
1890
- "type": "string",
1891
- "description": "Reverb level",
1892
- "enum": ["off", "light", "medium", "heavy", "cathedral"]
1893
- },
1894
- "agent": {
1895
- "type": "string",
1896
- "description": "Agent name (optional, defaults to 'default'). Examples: Winston, John, Mary, Amelia",
1897
- },
1898
- "apply_all": {
1899
- "type": "boolean",
1900
- "description": "Apply to all agents (optional, default: false)",
1901
- }
1902
- },
1903
- "required": ["level"],
1904
- },
1905
- ),
1906
- Tool(
1907
- name="get_reverb",
1908
- description="Get current reverb level for a specific agent or default",
1909
- inputSchema={
1910
- "type": "object",
1911
- "properties": {
1912
- "agent": {
1913
- "type": "string",
1914
- "description": "Agent name (optional, defaults to 'default')",
1915
- }
1916
- },
1917
- },
1918
- ),
1919
- Tool(
1920
- name="list_audio_effects",
1921
- description="List current audio effects configuration for all agents, including reverb levels and other effects",
1922
- inputSchema={"type": "object", "properties": {}},
1923
- ),
1924
- Tool(
1925
- name="clean_audio_cache",
1926
- description="Clean all TTS audio cache files and report space freed. Non-interactive cleanup that removes all wav/mp3/aiff files while preserving background music tracks.",
1927
- inputSchema={"type": "object", "properties": {}},
1928
- ),
1929
- Tool(
1930
- name="get_hermes_config",
1931
- description="Get current Hermes AgentVibes SSH configuration (SSH key path, host, port, voice). Use this to check what's currently set before changing it.",
1932
- inputSchema={"type": "object", "properties": {}},
1933
- ),
1934
- Tool(
1935
- name="set_hermes_config",
1936
- description="Configure Hermes AgentVibes TTS settings. Choose 'local' mode when Hermes runs on the same machine as your speakers (no SSH needed), or 'remote' mode to send audio over SSH to a receiver. Omit any field to keep its current value.",
1937
- inputSchema={
1938
- "type": "object",
1939
- "properties": {
1940
- "mode": {
1941
- "type": "string",
1942
- "enum": ["local", "remote"],
1943
- "description": "'local' = Hermes and speakers on same machine (no SSH). 'remote' = send audio over SSH to a receiver machine.",
1944
- },
1945
- "ssh_key": {
1946
- "type": "string",
1947
- "description": "Absolute path to SSH private key (e.g. /home/user/.ssh/id_ed25519_agentvibes) only used in remote mode",
1948
- },
1949
- "host": {
1950
- "type": "string",
1951
- "description": "Tailscale IP or hostname of the machine with speakers — only used in remote mode",
1952
- },
1953
- "port": {
1954
- "type": "string",
1955
- "description": "AgentVibes receiver SSH port (e.g. '2222') — only used in remote mode",
1956
- },
1957
- "voice": {
1958
- "type": "string",
1959
- "description": "Piper voice model (e.g. 'en_US-libritts-high::Leo-8')",
1960
- },
1961
- },
1962
- },
1963
- ),
1964
- ]
1965
-
1966
-
1967
- @app.call_tool()
1968
- async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolResult:
1969
- """Handle tool calls.
1970
-
1971
- Every AgentVibesServer method below returns a string that starts with
1972
- "" on failure (a marker produced by our own code from the script's
1973
- *exit code*, not sniffed from the child script's stdoutsee H1/#1 in
1974
- story 8.2). We use that marker here, once, to set MCP's `isError` so
1975
- clients can distinguish failure without parsing text.
1976
- """
1977
- try:
1978
- if name == "text_to_speech":
1979
- result = await agent_vibes.text_to_speech(
1980
- text=arguments["text"],
1981
- voice=arguments.get("voice"),
1982
- personality=arguments.get("personality"),
1983
- language=arguments.get("language"),
1984
- )
1985
- elif name == "list_voices":
1986
- result = await agent_vibes.list_voices()
1987
- elif name == "set_voice":
1988
- result = await agent_vibes.set_voice(arguments["voice_name"])
1989
- elif name == "list_personalities":
1990
- result = await agent_vibes.list_personalities()
1991
- elif name == "set_personality":
1992
- result = await agent_vibes.set_personality(arguments["personality"])
1993
- elif name == "set_language":
1994
- result = await agent_vibes.set_language(arguments["language"])
1995
- elif name == "get_config":
1996
- result = await agent_vibes.get_config()
1997
- elif name == "replay_audio":
1998
- n = arguments.get("n", 1)
1999
- result = await agent_vibes.replay_audio(n)
2000
- elif name == "set_provider":
2001
- result = await agent_vibes.set_provider(arguments["provider"])
2002
- elif name == "set_speed":
2003
- target = arguments.get("target", False)
2004
- result = await agent_vibes.set_speed(arguments["speed"], target)
2005
- elif name == "get_speed":
2006
- result = await agent_vibes.get_speed()
2007
- elif name == "download_extra_voices":
2008
- auto_yes = arguments.get("auto_yes", False)
2009
- result = await agent_vibes.download_extra_voices(auto_yes)
2010
- elif name == "get_verbosity":
2011
- result = await agent_vibes.get_verbosity()
2012
- elif name == "set_verbosity":
2013
- result = await agent_vibes.set_verbosity(arguments["level"])
2014
- elif name == "mute":
2015
- result = await agent_vibes.mute()
2016
- elif name == "unmute":
2017
- result = await agent_vibes.unmute()
2018
- elif name == "is_muted":
2019
- result = await agent_vibes.is_muted()
2020
- elif name == "list_background_music":
2021
- result = await agent_vibes.list_background_music()
2022
- elif name == "set_background_music":
2023
- track_name = arguments.get("track_name")
2024
- agent_name = arguments.get("agent_name")
2025
- result = await agent_vibes.set_background_music(track_name, agent_name)
2026
- elif name == "enable_background_music":
2027
- enabled = arguments.get("enabled")
2028
- result = await agent_vibes.enable_background_music(enabled)
2029
- elif name == "set_background_music_volume":
2030
- volume = arguments.get("volume")
2031
- result = await agent_vibes.set_background_music_volume(volume)
2032
- elif name == "get_background_music_status":
2033
- result = await agent_vibes.get_background_music_status()
2034
- elif name == "set_reverb":
2035
- level = arguments["level"]
2036
- agent = arguments.get("agent", "default")
2037
- apply_all = arguments.get("apply_all", False)
2038
- result = await agent_vibes.set_reverb(level, agent, apply_all)
2039
- elif name == "get_reverb":
2040
- agent = arguments.get("agent", "default")
2041
- result = await agent_vibes.get_reverb(agent)
2042
- elif name == "list_audio_effects":
2043
- result = await agent_vibes.list_audio_effects()
2044
- elif name == "clean_audio_cache":
2045
- result = await agent_vibes.clean_audio_cache()
2046
- elif name == "get_hermes_config":
2047
- result = await agent_vibes.get_hermes_config()
2048
- elif name == "set_hermes_config":
2049
- result = await agent_vibes.set_hermes_config(
2050
- mode=arguments.get("mode"),
2051
- ssh_key=arguments.get("ssh_key"),
2052
- host=arguments.get("host"),
2053
- port=arguments.get("port"),
2054
- voice=arguments.get("voice"),
2055
- )
2056
- else:
2057
- return CallToolResult(
2058
- content=[TextContent(type="text", text=f"Unknown tool: {name}")],
2059
- isError=True,
2060
- )
2061
-
2062
- content = [TextContent(type="text", text=result)]
2063
- if isinstance(result, str) and result.startswith(""):
2064
- return CallToolResult(content=content, isError=True)
2065
- return content
2066
-
2067
- except Exception as e:
2068
- return CallToolResult(
2069
- content=[TextContent(type="text", text=f"Error: {str(e)}")],
2070
- isError=True,
2071
- )
2072
-
2073
-
2074
- async def main():
2075
- """Run the MCP server"""
2076
- async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
2077
- await app.run(
2078
- read_stream,
2079
- write_stream,
2080
- app.create_initialization_options(),
2081
- )
2082
-
2083
-
2084
- if __name__ == "__main__":
2085
- asyncio.run(main())
1
+ #!/usr/bin/env python3
2
+ """
3
+ File: mcp-server/server.py
4
+
5
+ AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
6
+ Website: https://agentvibes.org
7
+ Repository: https://github.com/paulpreibisch/AgentVibes
8
+
9
+ Co-created by Paul Preibisch with Claude AI
10
+ Copyright (c) 2025 Paul Preibisch
11
+
12
+ Licensed under the Apache License, Version 2.0 (the "License");
13
+ you may not use this file except in compliance with the License.
14
+ You may obtain a copy of the License at
15
+
16
+ http://www.apache.org/licenses/LICENSE-2.0
17
+
18
+ Unless required by applicable law or agreed to in writing, software
19
+ distributed under the License is distributed on an "AS IS" BASIS,
20
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ See the License for the specific language governing permissions and
22
+ limitations under the License.
23
+
24
+ DISCLAIMER: This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
+ express or implied, including but not limited to the warranties of
26
+ merchantability, fitness for a particular purpose and noninfringement.
27
+ In no event shall the authors or copyright holders be liable for any claim,
28
+ damages or other liability, whether in an action of contract, tort or
29
+ otherwise, arising from, out of or in connection with the software or the
30
+ use or other dealings in the software.
31
+
32
+ ---
33
+
34
+ @fileoverview MCP Server exposing AgentVibes TTS capabilities via Model Context Protocol
35
+ @context Provides natural language control of TTS features for Claude Desktop, Warp, and other MCP clients
36
+ @architecture MCP Server implementation wrapping bash scripts, async subprocess execution for non-blocking I/O
37
+ @dependencies .claude/hooks/*.sh scripts, MCP SDK, Python asyncio, subprocess
38
+ @entrypoints Called by Claude Desktop/Warp via MCP protocol (stdio transport)
39
+ @patterns Tool registry pattern, async subprocess wrapping, provider abstraction, state file management
40
+ @related GitHub repo, mcp-server/test_server.py, .claude/hooks/play-tts.sh, docs/ai-optimized-documentation-standards.md
41
+ """
42
+
43
+ import asyncio
44
+ import json
45
+ import os
46
+ import platform
47
+ import re as _re
48
+ import signal
49
+ import subprocess
50
+ import sys
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+ from typing import Optional
54
+
55
+ from mcp.server import Server
56
+ from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource, CallToolResult
57
+ import mcp.server.stdio
58
+
59
+
60
+ # Default per-script timeout for _run_script(). Keeps a hung/interactive
61
+ # manager script (e.g. a `read -p` prompt reached by mistake) from wedging
62
+ # the MCP server forever and eating the stdio JSON-RPC stream.
63
+ DEFAULT_SCRIPT_TIMEOUT = 30.0
64
+
65
+
66
+ # ── Provider Catalog (SSOT Layer 2) — platform allowlists + display names ────
67
+ #
68
+ # server.py DERIVES its per-platform provider allowlists and provider display
69
+ # names from the shipped provider-catalog.json (generated from
70
+ # src/services/provider-catalog.js). The literals below are an EMBEDDED FALLBACK
71
+ # used ONLY when that JSON is missing or unreadable (installed-tree skew) —
72
+ # degraded, never dead: the MCP server MUST start without the file.
73
+ #
74
+ # Bidirectional parity (embedded fallback catalog, per platform, BOTH
75
+ # directions) is asserted by test/unit/provider-catalog-conformance.test.js so
76
+ # the fallback can never silently drift from the catalog (design §8 a
77
+ # one-directional check is the exact gap that let elevenlabs-on-Windows through
78
+ # the old positive-only test).
79
+ #
80
+ # The non-Windows fallback mirrors the catalog's DARWIN set (the superset that
81
+ # also covers Linux: darwin adds macOS `say`, which a Linux box simply won't
82
+ # have installed availability is enforced by the dispatchers downstream, not
83
+ # here). Windows uses the catalog WINDOWS set. elevenlabs is deliberately ABSENT
84
+ # from Windows: there is NO play-tts-elevenlabs.ps1 runtime (AVI-S9.1 /
85
+ # provider-catalog.js: elevenlabs.runtime.windows === null).
86
+ _FALLBACK_PROVIDERS_WINDOWS = ["windows-piper", "windows-sapi", "soprano", "kokoro"]
87
+ _FALLBACK_PROVIDERS_NON_WINDOWS = ["piper", "macos", "soprano", "kokoro", "elevenlabs"]
88
+
89
+ # termux-ssh is a TRANSPORT (relays TTS over SSH to a phone), NOT a synthesis
90
+ # provider: it has no catalog record and no play-tts-termux-ssh runtime. It is
91
+ # accepted on non-Windows as a documented non-catalog transport token, and is
92
+ # EXCLUDED BY NAME from the catalog parity assertion (AC6) so it can neither be
93
+ # silently dropped nor silently drift INTO the catalog.
94
+ _TRANSPORT_TOKENS = ["termux-ssh"]
95
+
96
+ # Embedded fallback display names byte-equal to catalog.json `displayNames`
97
+ # (group-8 parity). termux-ssh is NOT here (it has no catalog record); its
98
+ # display name is added separately as a transport token.
99
+ _FALLBACK_DISPLAY_NAMES = {
100
+ "soprano": "Soprano TTS",
101
+ "piper": "Piper TTS",
102
+ "kokoro": "Kokoro TTS",
103
+ "elevenlabs": "ElevenLabs",
104
+ "macos": "macOS Say",
105
+ "windows-sapi": "Windows SAPI",
106
+ "windows-piper": "Piper TTS",
107
+ }
108
+ _TRANSPORT_DISPLAY_NAMES = {
109
+ "termux-ssh": "Termux SSH",
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class ScriptResult:
115
+ """Structured result of running a hook script.
116
+
117
+ Callers MUST branch on `ok`/`returncode`, never on emoji/text sniffing
118
+ Windows manager scripts print plain text (no ✅/✓/🎭), so any
119
+ `"<emoji>" in stdout` check silently reports failure on Windows even when
120
+ the script succeeded.
121
+ """
122
+ returncode: int
123
+ stdout: str
124
+ stderr: str
125
+
126
+ @property
127
+ def ok(self) -> bool:
128
+ return self.returncode == 0
129
+
130
+ @property
131
+ def error_detail(self) -> str:
132
+ """Best-effort human-readable error text for failure messages."""
133
+ return self.stderr or self.stdout or f"exit code {self.returncode}"
134
+
135
+
136
+ class AgentVibesServer:
137
+ """MCP Server for AgentVibes TTS functionality"""
138
+
139
+ # Script name constants (addresses SonarCloud S1192)
140
+ VOICE_MANAGER_SCRIPT = "voice-manager.sh"
141
+ PERSONALITY_MANAGER_SCRIPT = "personality-manager.sh"
142
+ LANGUAGE_MANAGER_SCRIPT = "language-manager.sh"
143
+ BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.sh"
144
+ EFFECTS_MANAGER_SCRIPT = "effects-manager.sh"
145
+
146
+ # Path constants (addresses SonarCloud S1192)
147
+ CLAUDE_DIR_NAME = ".claude"
148
+ MUTE_FILE_NAME = ".agentvibes-muted"
149
+ SEPARATOR = "━" * 39
150
+
151
+ def __init__(self):
152
+ """Initialize the AgentVibes MCP server"""
153
+ # Detect native Windows (not WSL)
154
+ self.is_windows = platform.system() == "Windows" and not os.environ.get("WSL_DISTRO_NAME")
155
+ self.is_darwin = platform.system() == "Darwin"
156
+
157
+ # Script name constants — Windows uses .ps1, Unix uses .sh
158
+ if self.is_windows:
159
+ self.VOICE_MANAGER_SCRIPT = "voice-manager-windows.ps1"
160
+ self.PERSONALITY_MANAGER_SCRIPT = "personality-manager.ps1"
161
+ self.LANGUAGE_MANAGER_SCRIPT = "language-manager.ps1"
162
+ self.BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.ps1"
163
+ self.EFFECTS_MANAGER_SCRIPT = "effects-manager.ps1"
164
+
165
+ # Find the .claude directory (project-local or global)
166
+ self.claude_dir = self._find_claude_dir()
167
+ self.hooks_dir = self.claude_dir / ("hooks-windows" if self.is_windows else "hooks")
168
+ # Store AgentVibes root directory for environment variable
169
+ self.agentvibes_root = self.claude_dir.parent
170
+
171
+ # Serializes the "mutate global personality/language -> speak -> restore"
172
+ # critical section in text_to_speech() so concurrent MCP tool calls
173
+ # cannot interleave and corrupt persistent state (residual risk: this
174
+ # only protects against concurrent calls *within this process* a
175
+ # second MCP server process or a slash-command CLI invocation writing
176
+ # the same file at the same time is not covered; see story 8.2 notes).
177
+ self._override_lock = asyncio.Lock()
178
+
179
+ # provider-catalog.json is read lazily once and cached (design: no
180
+ # blocking I/O per call; load at first use, not per set_provider).
181
+ self._provider_catalog = None
182
+ self._provider_catalog_loaded = False
183
+
184
+ def _load_provider_catalog(self) -> Optional[dict]:
185
+ """Load the shipped provider-catalog.json ONCE (cached).
186
+
187
+ Generated from src/services/provider-catalog.js into
188
+ .claude/hooks/provider-catalog.json and shipped beside the hooks, it is
189
+ the SSOT for per-platform provider allowlists and display names. Returns
190
+ the parsed dict, or None on ANY failure (missing / unreadable /
191
+ malformed) so the MCP server always starts callers then fall back to
192
+ the embedded literals that conformance asserts are equivalent.
193
+ """
194
+ if self._provider_catalog_loaded:
195
+ return self._provider_catalog
196
+ self._provider_catalog_loaded = True
197
+ catalog = None
198
+ try:
199
+ # catalog.json lives under hooks/ on EVERY platform (program data,
200
+ # not a per-platform hook script — the generator only writes it there).
201
+ path = self.claude_dir / "hooks" / "provider-catalog.json"
202
+ if path.exists() and not path.is_symlink():
203
+ parsed = json.loads(path.read_text(encoding="utf-8"))
204
+ if isinstance(parsed, dict):
205
+ catalog = parsed
206
+ except (OSError, ValueError):
207
+ catalog = None
208
+ if catalog is None:
209
+ # Degraded, never dead. Warn on stderr ONLY (stdout is the MCP
210
+ # JSON-RPC stream); one terse line, since a missing file is the
211
+ # common installed-tree-skew case.
212
+ print(
213
+ "agentvibes: provider-catalog.json unavailable; using embedded provider fallback",
214
+ file=sys.stderr,
215
+ )
216
+ self._provider_catalog = catalog
217
+ return catalog
218
+
219
+ def _valid_providers(self) -> list:
220
+ """Per-platform provider allowlist, DERIVED from provider-catalog.json.
221
+
222
+ Windows → catalog `platforms.windows`; non-Windows catalog
223
+ `platforms.darwin` (the superset covering both Linux and macOS). Falls
224
+ back to the embedded literals when the catalog is unavailable. Transport
225
+ tokens (termux-ssh) are appended on non-Windows only.
226
+ """
227
+ catalog = self._load_provider_catalog()
228
+ base = None
229
+ if catalog:
230
+ platforms = catalog.get("platforms")
231
+ if isinstance(platforms, dict):
232
+ key = "windows" if self.is_windows else "darwin"
233
+ derived = platforms.get(key)
234
+ if isinstance(derived, list) and derived:
235
+ base = list(derived)
236
+ if base is None:
237
+ base = list(
238
+ _FALLBACK_PROVIDERS_WINDOWS if self.is_windows else _FALLBACK_PROVIDERS_NON_WINDOWS
239
+ )
240
+ if not self.is_windows:
241
+ for token in _TRANSPORT_TOKENS:
242
+ if token not in base:
243
+ base.append(token)
244
+ return base
245
+
246
+ def _provider_display_names(self) -> dict:
247
+ """Provider display names, DERIVED from provider-catalog.json.
248
+
249
+ Falls back to the embedded dict when the catalog is unavailable. Non-
250
+ catalog transport tokens (termux-ssh) are always merged in.
251
+ """
252
+ catalog = self._load_provider_catalog()
253
+ names = None
254
+ if catalog:
255
+ derived = catalog.get("displayNames")
256
+ if isinstance(derived, dict) and derived:
257
+ names = dict(derived)
258
+ if names is None:
259
+ names = dict(_FALLBACK_DISPLAY_NAMES)
260
+ for token, label in _TRANSPORT_DISPLAY_NAMES.items():
261
+ names.setdefault(token, label)
262
+ return names
263
+
264
+ def _find_claude_dir(self) -> Path:
265
+ """Find the .claude directory relative to this script"""
266
+ # Get the AgentVibes root directory (parent of mcp-server)
267
+ script_dir = Path(__file__).resolve().parent # mcp-server/
268
+ agentvibes_root = script_dir.parent # AgentVibes/
269
+ claude_dir = agentvibes_root / self.CLAUDE_DIR_NAME
270
+
271
+ # ALWAYS use package .claude for hooks (even in NPX cache)
272
+ # The package ALWAYS has .claude/ with all the hooks
273
+ if claude_dir.exists() and claude_dir.is_dir():
274
+ return claude_dir
275
+
276
+ # Fallback to global ~/.claude (should never happen in properly installed package)
277
+ return Path.home() / self.CLAUDE_DIR_NAME
278
+
279
+ def _resolve_friendly_name(self, voice_name: str) -> str:
280
+ """
281
+ Resolve friendly name to Piper voice ID using voice-metadata.json.
282
+
283
+ Args:
284
+ voice_name: Friendly name (e.g., "ryan") or Piper ID
285
+
286
+ Returns:
287
+ Resolved Piper voice ID, or original voice_name if not found
288
+ """
289
+ import re
290
+
291
+ metadata_path = self.agentvibes_root / ".agentvibes" / "config" / "voice-metadata.json"
292
+
293
+ # SECURITY: Verify file exists and is not a symlink
294
+ if not metadata_path.exists() or metadata_path.is_symlink():
295
+ return voice_name
296
+
297
+ # SECURITY: Verify file ownership matches current user (Unix only)
298
+ try:
299
+ if hasattr(os, 'getuid'):
300
+ stat_info = metadata_path.stat()
301
+ if stat_info.st_uid != os.getuid():
302
+ return voice_name
303
+ except (OSError, AttributeError):
304
+ pass
305
+
306
+ try:
307
+ with open(metadata_path, 'r') as f:
308
+ metadata = json.load(f)
309
+
310
+ voices = metadata.get('voices', {})
311
+ voice_lower = voice_name.lower()
312
+
313
+ resolved_id = None
314
+
315
+ # Check if it's a friendly name key
316
+ if voice_lower in voices:
317
+ resolved_id = voices[voice_lower].get('id')
318
+
319
+ # Check if it matches a displayName
320
+ if not resolved_id:
321
+ for friendly_name, voice_data in voices.items():
322
+ if voice_data.get('displayName', '').lower() == voice_lower:
323
+ resolved_id = voice_data.get('id')
324
+ break
325
+
326
+ # SECURITY: Validate resolved ID matches safe pattern
327
+ if resolved_id and re.match(r'^[a-zA-Z0-9_-]+$', resolved_id):
328
+ return resolved_id
329
+
330
+ except (json.JSONDecodeError, KeyError, IOError, TypeError):
331
+ pass
332
+
333
+ return voice_name
334
+
335
+ # ── LibriTTS display-name resolution ──────────────────────────────────────
336
+
337
+ _SURNAME_POOL = [
338
+ 'Bell', 'Carter', 'Davis', 'Ellis', 'Foster', 'Gray', 'Hayes', 'Irving',
339
+ 'Jones', 'Knox', 'Lane', 'Mason', 'Nash', 'Owens', 'Pierce', 'Quinn',
340
+ ]
341
+
342
+ @classmethod
343
+ def _uniquify_voice_name(cls, raw_name: str) -> str:
344
+ """Python port of uniquifyVoiceName from src/utils/voice-names.js"""
345
+ import re as _re
346
+ if not raw_name:
347
+ return raw_name
348
+ m = _re.match(r'^(.+)-(\d+)$', raw_name)
349
+ if m:
350
+ base, n = m.group(1), int(m.group(2))
351
+ if n >= 2:
352
+ return f"{base} {cls._SURNAME_POOL[(n - 1) % len(cls._SURNAME_POOL)]}"
353
+ if ' ' in raw_name:
354
+ return raw_name
355
+ return f"{raw_name} {cls._SURNAME_POOL[0]}"
356
+
357
+ def _build_libritts_catalog(self) -> dict:
358
+ """
359
+ Build a case-insensitive display-name entry map from voice-assignments.json.
360
+ Returns dict keyed by lowercased display name / raw name / speaker name.
361
+ """
362
+ catalog: dict = {}
363
+ va_path = self.agentvibes_root / "voice-assignments.json"
364
+ if not va_path.exists():
365
+ return catalog
366
+ try:
367
+ data = json.loads(va_path.read_text())
368
+ for id_str, entry in data.get("libritts_speakers", {}).items():
369
+ speaker_id = int(id_str)
370
+ raw_name = entry.get("voice_name", "")
371
+ display_name = self._uniquify_voice_name(raw_name)
372
+ voice_id = f"en_US-libritts-high::{raw_name}"
373
+ info = {
374
+ "voice_id": voice_id,
375
+ "model": "en_US-libritts-high",
376
+ "speaker_name": raw_name,
377
+ "speaker_id": speaker_id,
378
+ "display_name": display_name,
379
+ "gender": entry.get("gender", ""),
380
+ }
381
+ for key in (display_name.lower(), raw_name.lower(),
382
+ raw_name.replace(" ", "_").lower()):
383
+ catalog.setdefault(key, info)
384
+ except (json.JSONDecodeError, KeyError, ValueError, OSError):
385
+ pass
386
+ return catalog
387
+
388
+ def _resolve_voice_input(self, voice_input: str) -> Optional[dict]:
389
+ """
390
+ Resolve a voice display name or ID to a dict with model/speakerId/voiceId.
391
+ Returns None if unresolvable.
392
+ Accepts: "Bella Bell", "Bella-2", "en_US-libritts-high::Bella",
393
+ "Kristin_Hughes", "en_US-amy-medium"
394
+ """
395
+ import re as _re
396
+ if not voice_input:
397
+ return None
398
+ MS_SEP = "::"
399
+
400
+ # Already a full voiceId with MS_SEP
401
+ if MS_SEP in voice_input:
402
+ parts = voice_input.split(MS_SEP, 1)
403
+ model, speaker_name = parts[0], parts[1]
404
+ if not _re.match(r'^[a-zA-Z0-9_-]+$', model):
405
+ return None
406
+ catalog = self._build_libritts_catalog()
407
+ entry = catalog.get(speaker_name.lower())
408
+ return {
409
+ "voice_id": voice_input,
410
+ "model": model,
411
+ "speaker_name": speaker_name,
412
+ "speaker_id": entry["speaker_id"] if entry else None,
413
+ "display_name": entry["display_name"] if entry else speaker_name,
414
+ }
415
+
416
+ # Plain piper model ID (e.g. en_US-amy-medium)
417
+ if _re.match(r'^en_[A-Z]{2}-[a-zA-Z0-9_]+-[a-z]+$', voice_input):
418
+ return {
419
+ "voice_id": voice_input, "model": voice_input,
420
+ "speaker_name": None, "speaker_id": None, "display_name": voice_input,
421
+ }
422
+
423
+ # LibriTTS display name / raw name lookup
424
+ catalog = self._build_libritts_catalog()
425
+ normalised = voice_input.replace("_", " ")
426
+ entry = catalog.get(normalised.lower()) or catalog.get(voice_input.lower())
427
+ return entry or None
428
+
429
+ def _get_config_dir(self) -> Path:
430
+ """Return the .claude dir to write voice config files into (project or global)."""
431
+ cwd = Path.cwd()
432
+ if (cwd / ".claude").is_dir() and cwd != self.agentvibes_root:
433
+ return cwd / ".claude"
434
+ return self.claude_dir
435
+
436
+ async def text_to_speech(
437
+ self,
438
+ text: str,
439
+ voice: Optional[str] = None,
440
+ personality: Optional[str] = None,
441
+ language: Optional[str] = None,
442
+ ) -> str:
443
+ """
444
+ Convert text to speech using AgentVibes.
445
+
446
+ Args:
447
+ text: The text to speak
448
+ voice: Optional voice name (e.g., "Aria", "Northern Terry")
449
+ personality: Optional personality style (e.g., "flirty", "sarcastic")
450
+ language: Optional language (e.g., "spanish", "french")
451
+
452
+ Returns:
453
+ Success message with audio file path
454
+ """
455
+ # Store original settings to restore later. Mutating the personality/
456
+ # language files is inherently racy across processes; the lock below
457
+ # only protects against concurrent tool calls within *this* server
458
+ # instance (see the residual-risk note on self._override_lock).
459
+ original_personality = None
460
+ personality_file_existed = True
461
+ original_language = None
462
+ needs_override = bool(personality or language)
463
+
464
+ if needs_override:
465
+ await self._override_lock.acquire()
466
+
467
+ try:
468
+ # Temporarily set personality if specified
469
+ personality_path = self._get_config_dir() / "tts-personality.txt"
470
+ if personality:
471
+ # Read the ORIGINAL from the SAME file the manager writes to
472
+ # (the config dir). _get_personality() reads a different set of
473
+ # dirs (package dir, then global ~/.claude) and, from inside a
474
+ # host project, returns the wrong value restoring that would
475
+ # overwrite the project's real personality. Non-Destructive Rule.
476
+ personality_file_existed = personality_path.exists()
477
+ if personality_file_existed:
478
+ try:
479
+ original_personality = personality_path.read_text(encoding="utf-8").strip()
480
+ except OSError:
481
+ original_personality = None # can't read → don't clobber on restore
482
+ await self._run_script(
483
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
484
+ )
485
+
486
+ # Temporarily set language if specified
487
+ if language:
488
+ original_language = await self._get_language()
489
+ await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
490
+
491
+ # Resolve LLM key: AGENTVIBES_LLM > CLAUDECODE=1 > AGENTVIBES_MCP_FALLBACK > "default"
492
+ llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
493
+ if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
494
+ llm_key = ""
495
+ if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
496
+ llm_key = "claude-code"
497
+ if not llm_key:
498
+ fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
499
+ if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
500
+ llm_key = fallback
501
+
502
+ # Call the TTS script via appropriate shell
503
+ tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
504
+ play_tts = self.hooks_dir / tts_script
505
+ if self.is_windows:
506
+ args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(play_tts), text]
507
+ if voice:
508
+ args.extend(["-VoiceOverride", voice])
509
+ if llm_key:
510
+ args.extend(["-llm", llm_key])
511
+ else:
512
+ args = ["bash", str(play_tts)]
513
+ if llm_key:
514
+ args.extend(["--llm", llm_key])
515
+ args.append(text)
516
+ if voice:
517
+ args.append(voice)
518
+
519
+ env = self._build_script_env()
520
+
521
+ # Declare voice provenance so the resolver treats an MCP-requested
522
+ # voice as a genuine explicit pick (user-explicit), never demoting it
523
+ # to a per-LLM/default row the way it would an LLM echo (F-1). Only
524
+ # set when the caller actually asked for a specific voice.
525
+ if voice:
526
+ env["AGENTVIBES_VOICE_SOURCE"] = "user-explicit"
527
+
528
+ result = await asyncio.create_subprocess_exec(
529
+ *args,
530
+ stdin=asyncio.subprocess.DEVNULL,
531
+ stdout=asyncio.subprocess.PIPE,
532
+ stderr=asyncio.subprocess.PIPE,
533
+ env=env,
534
+ )
535
+ try:
536
+ try:
537
+ stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=60.0)
538
+ except asyncio.TimeoutError:
539
+ result.kill()
540
+ await result.wait()
541
+ return "❌ TTS timed out after 60 seconds"
542
+
543
+ if result.returncode == 0:
544
+ output = stdout.decode().strip()
545
+ # Strip ANSI escape codes for clean extraction
546
+ _ansi_strip = _re.compile(r'\x1b\[[0-9;]*m')
547
+ audio_file_path = None
548
+ voice_info = None
549
+ for line in output.split("\n"):
550
+ clean = _ansi_strip.sub('', line).strip()
551
+ if "Saved to:" in clean and audio_file_path is None:
552
+ raw_path = clean.split("Saved to:")[1].strip()
553
+ # Path ends at .wav (strip trailing size/count info)
554
+ wav_end = raw_path.find(".wav")
555
+ audio_file_path = raw_path[:wav_end + 4] if wav_end != -1 else raw_path.split()[0]
556
+ if ("Voice used:" in clean or ("Voice:" in clean and "Background" not in clean)) and voice_info is None:
557
+ voice_info = clean
558
+
559
+ if audio_file_path:
560
+ truncated = (
561
+ f"{text[:50]}..." if len(text) > 50 else text
562
+ )
563
+ result_msg = f"✅ Spoke: {truncated}\n📁 Audio saved: {audio_file_path}"
564
+ if voice_info:
565
+ result_msg += f"\n{voice_info}"
566
+ return result_msg
567
+
568
+ return f"✅ Spoke: {text[:50]}..." if len(text) > 50 else f"✅ Spoke: {text}"
569
+ else:
570
+ error = stderr.decode().strip()
571
+ stdout_output = stdout.decode().strip()
572
+ full_error = f"{error}\nStdout: {stdout_output}" if stdout_output else error
573
+ return f"❌ TTS failed: {full_error}"
574
+ finally:
575
+ # Ensure process cleanup
576
+ if result.returncode is None:
577
+ result.kill()
578
+ await result.wait()
579
+
580
+ finally:
581
+ # Restore original personality. personality-manager.sh has no
582
+ # delete-on-default behavior (unlike language-manager.sh), so if
583
+ # no personality file existed before this call, restore by
584
+ # deleting the file rather than writing "normal" into it — the
585
+ # non-destructive-config rule means a temporary per-call override
586
+ # must not leave a permanent file behind that wasn't there before.
587
+ if personality:
588
+ if personality_file_existed:
589
+ if original_personality is not None:
590
+ restore = await self._run_script(
591
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
592
+ )
593
+ if not restore.ok:
594
+ import sys
595
+ print(
596
+ f"Warning: failed to restore personality "
597
+ f"'{original_personality}': {restore.error_detail}",
598
+ file=sys.stderr,
599
+ )
600
+ else:
601
+ # No personality file existed before this call — restore by
602
+ # deleting (same config dir we captured existence from).
603
+ try:
604
+ personality_path.unlink()
605
+ except OSError:
606
+ pass
607
+ if original_language is not None:
608
+ # "english"/"reset" makes language-manager.sh *delete* the
609
+ # language file rather than write it, so this naturally
610
+ # restores "no override was ever set" correctly too.
611
+ restore_lang = await self._run_script(
612
+ self.LANGUAGE_MANAGER_SCRIPT, ["set", original_language]
613
+ )
614
+ if not restore_lang.ok:
615
+ import sys
616
+ print(
617
+ f"Warning: failed to restore language "
618
+ f"'{original_language}': {restore_lang.error_detail}",
619
+ file=sys.stderr,
620
+ )
621
+ if needs_override:
622
+ self._override_lock.release()
623
+
624
+ async def list_voices(self) -> str:
625
+ """
626
+ List all available TTS voices for the active provider.
627
+
628
+ Returns:
629
+ Formatted list of available voices
630
+ """
631
+ # Get active provider for display purposes
632
+ provider = await self._get_provider()
633
+ current_voice = await self._get_current_voice()
634
+
635
+ # voice-manager.sh list-simple is now provider-aware
636
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["list-simple"])
637
+ if result.ok and result.stdout:
638
+ voices = result.stdout.strip().split("\n")
639
+ voices = [v for v in voices if v] # Filter empty strings
640
+
641
+ if not voices:
642
+ return (
643
+ f"📦 No voices available\n"
644
+ f"{self.SEPARATOR}\n"
645
+ f"For Piper: Download voices using /agent-vibes:provider download <voice-name>\n"
646
+ f"Example: en_US-lessac-medium, en_GB-alba-medium"
647
+ )
648
+
649
+ # Determine provider label and alternative provider
650
+ if "Piper" in provider:
651
+ provider_label = "Piper TTS"
652
+ alternative_provider = "macOS"
653
+ elif "macOS" in provider:
654
+ provider_label = "macOS TTS"
655
+ alternative_provider = "Piper"
656
+ elif "Termux" in provider or "Android" in provider:
657
+ provider_label = "Termux SSH (Android)"
658
+ alternative_provider = "Piper"
659
+ else:
660
+ provider_label = "TTS"
661
+ alternative_provider = None
662
+
663
+ output = f"🎤 Available {provider_label} Voices:\n"
664
+ output += f"{self.SEPARATOR}\n"
665
+ for voice in voices:
666
+ marker = " ✓ (current)" if voice == current_voice else ""
667
+ output += f" • {voice}{marker}\n"
668
+
669
+ # Expand LibriTTS named speakers when en_US-libritts-high is installed
670
+ piper_voices_dir = Path.home() / ".local" / "share" / "piper-voices"
671
+ libritts_onnx = piper_voices_dir / "en_US-libritts-high.onnx"
672
+ if libritts_onnx.exists():
673
+ catalog = self._build_libritts_catalog()
674
+ if catalog:
675
+ output += f"\n 📖 LibriTTS named speakers (en_US-libritts-high):\n"
676
+ # De-duplicate: only one entry per display name
677
+ seen: set = set()
678
+ for entry in catalog.values():
679
+ dn = entry["display_name"]
680
+ if dn in seen:
681
+ continue
682
+ seen.add(dn)
683
+ spk = entry["speaker_name"]
684
+ sid = entry["speaker_id"]
685
+ gender = entry.get("gender", "")
686
+ g_icon = "♀" if gender.lower() == "female" else ("" if gender.lower() == "male" else "")
687
+ marker = " (current)" if entry["voice_id"] == current_voice else ""
688
+ output += f" • {dn} ({g_icon} speaker {sid}){marker}\n"
689
+
690
+ output += f"{self.SEPARATOR}\n"
691
+
692
+ # Add provider switch hint
693
+ if alternative_provider:
694
+ output += f"\n💡 Switch to {alternative_provider}? Use: set_provider(provider=\"{alternative_provider.lower()}\")\n"
695
+
696
+ return output
697
+ return f"❌ Failed to list voices: {result.error_detail}"
698
+
699
+ async def set_voice(self, voice_name: str) -> str:
700
+ """
701
+ Switch to a different voice (supports friendly names like "ryan" or "katherine").
702
+
703
+ Args:
704
+ voice_name: Friendly name (e.g., "ryan") or Piper voice ID
705
+
706
+ Returns:
707
+ Success or error message
708
+ """
709
+ # Try new display-name resolver first (handles "Bella Bell", "::" ids, etc.)
710
+ resolved = self._resolve_voice_input(voice_name)
711
+
712
+ if resolved:
713
+ voice_id = resolved["voice_id"]
714
+ display_name = resolved["display_name"]
715
+ model = resolved["model"]
716
+ speaker_id = resolved["speaker_id"]
717
+ speaker_name = resolved["speaker_name"]
718
+
719
+ # Write the three config files directly (no voice-manager.sh needed)
720
+ config_dir = self._get_config_dir()
721
+ try:
722
+ config_dir.mkdir(parents=True, exist_ok=True)
723
+ (config_dir / "tts-voice.txt").write_text(display_name + "\n")
724
+ if speaker_name:
725
+ (config_dir / "tts-piper-model.txt").write_text(model + "\n")
726
+ if speaker_id is not None:
727
+ (config_dir / "tts-piper-speaker-id.txt").write_text(str(speaker_id) + "\n")
728
+ else:
729
+ # Clear speaker-id so piper uses default
730
+ try: (config_dir / "tts-piper-speaker-id.txt").unlink()
731
+ except FileNotFoundError: pass
732
+ else:
733
+ # Single-speaker model — clear multi-speaker files
734
+ for f in ("tts-piper-model.txt", "tts-piper-speaker-id.txt"):
735
+ try: (config_dir / f).unlink()
736
+ except FileNotFoundError: pass
737
+ except OSError as e:
738
+ return f"❌ Failed to write voice config: {e}"
739
+
740
+ detail = f" (speaker {speaker_id}, model {model})" if speaker_id is not None else ""
741
+ return f"✅ Voice set to: {display_name}{detail}"
742
+
743
+ # Fall back to legacy friendly-name resolver (voice-metadata.json)
744
+ original_name = voice_name
745
+ resolved_name = self._resolve_friendly_name(voice_name)
746
+ result = await self._run_script(
747
+ self.VOICE_MANAGER_SCRIPT, ["switch", resolved_name, "--silent"]
748
+ )
749
+ if result.ok:
750
+ if original_name.lower() != resolved_name.lower():
751
+ return f"✅ Voice switched to: {original_name} ({resolved_name})"
752
+ return f"✅ Voice switched to: {voice_name}"
753
+ return (
754
+ f" Failed to switch voice could not resolve '{voice_name}'. "
755
+ f"Try 'list_voices' to see available names. ({result.error_detail})"
756
+ )
757
+
758
+ async def list_personalities(self) -> str:
759
+ """
760
+ List all available personalities.
761
+
762
+ Returns:
763
+ Formatted list of personalities with descriptions
764
+ """
765
+ result = await self._run_script(self.PERSONALITY_MANAGER_SCRIPT, ["list"])
766
+ if result.ok:
767
+ return result.stdout
768
+ return f"❌ Failed to list personalities: {result.error_detail}"
769
+
770
+ async def set_personality(self, personality: str) -> str:
771
+ """
772
+ Set the personality style for TTS messages.
773
+
774
+ Args:
775
+ personality: Personality name (e.g., "flirty", "sarcastic", "pirate")
776
+
777
+ Returns:
778
+ Success or error message
779
+ """
780
+ # Serialize against text_to_speech's temporary override/restore so a
781
+ # deliberate set here can't be silently reverted by an in-flight call's
782
+ # restore step (they mutate the same tts-personality.txt).
783
+ async with self._override_lock:
784
+ result = await self._run_script(
785
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
786
+ )
787
+ if result.ok:
788
+ # Windows (.ps1) scripts print plain text with no 🎭 marker; add
789
+ # our own so the tool's output stays consistent across platforms.
790
+ return result.stdout if "🎭" in result.stdout else f"🎭 {result.stdout}"
791
+ return f"❌ Failed to set personality: {result.error_detail}"
792
+
793
+ async def get_config(self) -> str:
794
+ """
795
+ Get current AgentVibes configuration.
796
+
797
+ Returns:
798
+ Current voice, personality, language, provider, and LLM settings
799
+ """
800
+ voice = await self._get_current_voice()
801
+ personality = await self._get_personality()
802
+ language = await self._get_language()
803
+ provider = await self._get_provider()
804
+
805
+ # Resolve the LLM key using the same priority as text_to_speech:
806
+ # 1. AGENTVIBES_LLM 2. CLAUDECODE=1 3. AGENTVIBES_MCP_FALLBACK 4. "default"
807
+ llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
808
+ if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
809
+ llm_key = ""
810
+ if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
811
+ llm_key = "claude-code"
812
+ if not llm_key:
813
+ fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
814
+ if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
815
+ llm_key = fallback
816
+ if not llm_key:
817
+ llm_key = "default"
818
+
819
+ output = "🎤 Current AgentVibes Configuration\n"
820
+ output += f"{self.SEPARATOR}\n"
821
+ output += f"LLM: {llm_key}\n"
822
+ output += f"Provider: {provider}\n"
823
+ output += f"Voice: {voice}\n"
824
+ output += f"Personality: {personality}\n"
825
+ output += f"Language: {language}\n"
826
+ output += f"{self.SEPARATOR}\n"
827
+ return output
828
+
829
+ async def set_language(self, language: str) -> str:
830
+ """
831
+ Set the language for TTS speech.
832
+
833
+ Args:
834
+ language: Language name (e.g., "spanish", "french", "german")
835
+
836
+ Returns:
837
+ Success or error message
838
+ """
839
+ # Serialize against text_to_speech's temporary override/restore (both
840
+ # mutate the same language config), so a deliberate set here isn't
841
+ # silently reverted by an in-flight call's restore step.
842
+ async with self._override_lock:
843
+ result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
844
+ if result.ok:
845
+ return result.stdout if "✓" in result.stdout else f"✓ {result.stdout}"
846
+ return f"❌ Failed to set language: {result.error_detail}"
847
+
848
+ async def replay_audio(self, n: int = 1) -> str:
849
+ """
850
+ Replay recently generated TTS audio.
851
+
852
+ Args:
853
+ n: Which audio to replay (1 = most recent, 2 = second most recent, etc.)
854
+
855
+ Returns:
856
+ Success or error message
857
+ """
858
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["replay", str(n)])
859
+ if result.ok:
860
+ return result.stdout if "🔊" in result.stdout else f"🔊 {result.stdout}"
861
+ return f"❌ Failed to replay audio: {result.error_detail}"
862
+
863
+ async def set_provider(self, provider: str) -> str:
864
+ """
865
+ Switch TTS provider between the supported synthesis engines.
866
+
867
+ Args:
868
+ provider: Provider name. Non-Windows: "piper", "macos", "termux-ssh",
869
+ "soprano", "kokoro", "elevenlabs". Windows: "windows-piper",
870
+ "windows-sapi", "soprano", "kokoro".
871
+
872
+ Returns:
873
+ Success or error message
874
+ """
875
+ provider = provider.lower()
876
+ # Platform allowlist + display names DERIVE from provider-catalog.json
877
+ # (SSOT), with embedded fallbacks (module constants above). kokoro is
878
+ # cross-platform; elevenlabs is Unix-only (NO play-tts-elevenlabs.ps1, so
879
+ # it is absent from the Windows set — switching to it on Windows would be
880
+ # silently unplayable). See AVI-S9.1 / AVI-S9.5 and provider-catalog.js
881
+ # (elevenlabs.runtime.windows === null).
882
+ valid_providers = self._valid_providers()
883
+ if provider not in valid_providers:
884
+ return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
885
+
886
+ result = await self._run_script("provider-manager.sh", ["switch", provider])
887
+ if result.ok:
888
+ # Automatically speak confirmation in the new provider's voice.
889
+ provider_names = self._provider_display_names()
890
+ provider_name = provider_names.get(provider, provider.title())
891
+ confirmation_text = f"Successfully switched to {provider_name} provider"
892
+
893
+ try:
894
+ # Speak the confirmation with 5 second timeout to prevent hanging
895
+ await asyncio.wait_for(
896
+ self.text_to_speech(confirmation_text),
897
+ timeout=5.0
898
+ )
899
+ # Return the provider switch result plus TTS confirmation
900
+ return f"{result.stdout}\n🔊 Spoken confirmation: {confirmation_text}"
901
+ except asyncio.TimeoutError:
902
+ # Timeout - provider may need setup (e.g., Piper not installed)
903
+ return f"{result.stdout}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
904
+ except Exception as e:
905
+ # If TTS fails, still return success for the provider switch
906
+ return f"{result.stdout}\n⚠️ Provider switched but TTS confirmation failed: {e}"
907
+
908
+ return f"❌ Failed to switch provider: {result.error_detail}"
909
+
910
+ async def set_speed(self, speed: str, target: bool = False) -> str:
911
+ """
912
+ Set speech speed for main or target voice.
913
+
914
+ Works with both Piper and macOS providers.
915
+
916
+ Args:
917
+ speed: Speed value (e.g., "0.5x", "1x", "2x", "normal", "fast", "slow")
918
+ target: If True, sets target language speed; if False, sets main voice speed
919
+
920
+ Returns:
921
+ Success or error message
922
+ """
923
+ # Security: Using secrets.choice for cryptographically secure random selection
924
+ # Even though this is just for UI variety, we use secrets to satisfy security scanners
925
+ import secrets
926
+
927
+ args = ["target", speed] if target else [speed]
928
+ result = await self._run_script("speed-manager.sh", args)
929
+ if result.ok:
930
+ # Simple test messages to demonstrate the new speed
931
+ test_messages = [
932
+ "Testing speed change",
933
+ "Speed test in progress",
934
+ "Checking audio speed",
935
+ "Speed configuration test",
936
+ "Audio speed test",
937
+ ]
938
+
939
+ # Pick a random test message and speak it
940
+ test_message = secrets.choice(test_messages)
941
+
942
+ try:
943
+ # Speak the test message to demonstrate the new speed
944
+ await self.text_to_speech(test_message)
945
+ return f"{result.stdout}\n🔊 Testing new speed: \"{test_message}\""
946
+ except Exception as e:
947
+ # If TTS fails, still return success for the speed change
948
+ return f"{result.stdout}\n⚠️ Speed changed but demo failed: {e}"
949
+
950
+ return f"❌ Failed to set speed: {result.error_detail}"
951
+
952
+ async def get_speed(self) -> str:
953
+ """
954
+ Get current speech speed settings.
955
+
956
+ Returns:
957
+ Current speed settings for main and target voices
958
+ """
959
+ result = await self._run_script("speed-manager.sh", ["get"])
960
+ return result.stdout if result.ok else f"❌ Failed to get speed settings: {result.error_detail}"
961
+
962
+ async def download_extra_voices(self, auto_yes: bool = False) -> str:
963
+ """
964
+ Download extra high-quality Piper voices from HuggingFace.
965
+
966
+ Downloads custom voices: Kristin, Jenny, and Tracy/16Speakers.
967
+
968
+ Args:
969
+ auto_yes: If True, skips confirmation prompt and downloads automatically
970
+
971
+ Returns:
972
+ Success message with download summary
973
+ """
974
+ if not auto_yes:
975
+ # download-extra-voices.sh hits `read -p "...? [Y/n]: "` when no
976
+ # --yes flag is given. Since stdin is always DEVNULL (see
977
+ # _run_script), that read would return EOF/empty rather than
978
+ # hang but reaching it at all is still the wrong behavior for
979
+ # an MCP tool: an LLM caller can't answer an interactive prompt.
980
+ # Fail fast with a clear, actionable error instead of ever
981
+ # spawning the script.
982
+ return (
983
+ "⚠️ Confirmation required: call download_extra_voices(auto_yes=True) "
984
+ "to download the extra voices. This tool cannot answer an interactive "
985
+ "Y/n prompt, so it refuses to start the download without explicit consent."
986
+ )
987
+ result = await self._run_script("download-extra-voices.sh", ["--yes"], timeout=180.0)
988
+ if result.ok:
989
+ return result.stdout
990
+ return f"❌ Failed to download extra voices: {result.error_detail}"
991
+
992
+ async def get_verbosity(self) -> str:
993
+ """
994
+ Get current verbosity level.
995
+
996
+ Returns:
997
+ Current verbosity level with description
998
+ """
999
+ result = await self._run_script("verbosity-manager.sh", ["get"])
1000
+ if result.ok:
1001
+ level = result.stdout.strip()
1002
+ descriptions = {
1003
+ "low": "LOW - Acknowledgments + Completions only (minimal)",
1004
+ "medium": "MEDIUM - + Major decisions and findings (balanced)",
1005
+ "high": "HIGH - All reasoning (maximum transparency)"
1006
+ }
1007
+ desc = descriptions.get(level, level)
1008
+ return f"🎙️ Current Verbosity: {desc}\n\n💡 Change with: set_verbosity(level=\"low|medium|high\")"
1009
+ return f"❌ Failed to get verbosity level: {result.error_detail}"
1010
+
1011
+ async def set_verbosity(self, level: str) -> str:
1012
+ """
1013
+ Set verbosity level to control how much Claude speaks.
1014
+
1015
+ Args:
1016
+ level: Verbosity level (low, medium, or high)
1017
+
1018
+ Returns:
1019
+ Success or error message
1020
+ """
1021
+ result = await self._run_script("verbosity-manager.sh", ["set", level])
1022
+ if result.ok:
1023
+ body = result.stdout if "✅" in result.stdout else f"✅ {result.stdout}"
1024
+ return f"{body}\n\n⚠️ Restart Claude Code for changes to take effect"
1025
+ return f"❌ Failed to set verbosity: {result.error_detail}"
1026
+
1027
+ def _get_mute_files(self) -> list:
1028
+ """Get all mute file paths for current platform"""
1029
+ files = [
1030
+ Path.home() / self.MUTE_FILE_NAME,
1031
+ Path.cwd() / self.CLAUDE_DIR_NAME / "agentvibes-muted",
1032
+ ]
1033
+ # Windows PowerShell scripts check tts-muted.txt in .claude dir
1034
+ if self.is_windows:
1035
+ files.append(Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt")
1036
+ return files
1037
+
1038
+ async def mute(self) -> str:
1039
+ """
1040
+ Mute all TTS output. Creates a persistent mute flag.
1041
+
1042
+ Returns:
1043
+ Success message confirming mute is active
1044
+ """
1045
+ try:
1046
+ mute_file = Path.home() / self.MUTE_FILE_NAME
1047
+ mute_file.touch()
1048
+ # On Windows, also write tts-muted.txt for PowerShell script compatibility
1049
+ if self.is_windows:
1050
+ win_mute = Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt"
1051
+ win_mute.parent.mkdir(parents=True, exist_ok=True)
1052
+ win_mute.write_text("true")
1053
+ return "🔇 AgentVibes TTS muted. All voice output is now silenced.\n\n💡 To unmute, use: unmute()"
1054
+ except Exception as e:
1055
+ return f"❌ Failed to mute: {e}"
1056
+
1057
+ async def unmute(self) -> str:
1058
+ """
1059
+ Unmute TTS output. Removes the mute flag.
1060
+
1061
+ Returns:
1062
+ Success message confirming TTS is restored
1063
+ """
1064
+ removed = []
1065
+ try:
1066
+ for mute_file in self._get_mute_files():
1067
+ if mute_file.exists():
1068
+ # tts-muted.txt uses content "true"/"false", others use file existence
1069
+ if mute_file.name == "tts-muted.txt":
1070
+ content = mute_file.read_text().strip()
1071
+ if content == "true":
1072
+ mute_file.write_text("false")
1073
+ removed.append(str(mute_file.name))
1074
+ else:
1075
+ mute_file.unlink()
1076
+ removed.append(str(mute_file.name))
1077
+
1078
+ if removed:
1079
+ return f"🔊 AgentVibes TTS unmuted. Voice output is now restored.\n (Removed: {', '.join(removed)} mute flag)"
1080
+ else:
1081
+ return "🔊 AgentVibes TTS was not muted. Voice output is active."
1082
+ except Exception as e:
1083
+ return f"❌ Failed to unmute: {e}"
1084
+
1085
+ async def is_muted(self) -> str:
1086
+ """
1087
+ Check if TTS is currently muted.
1088
+
1089
+ Returns:
1090
+ Current mute status
1091
+ """
1092
+ for mute_file in self._get_mute_files():
1093
+ if mute_file.exists():
1094
+ # tts-muted.txt uses content "true"/"false"
1095
+ if mute_file.name == "tts-muted.txt":
1096
+ content = mute_file.read_text().strip()
1097
+ if content == "true":
1098
+ return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
1099
+ else:
1100
+ return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
1101
+ return "🔊 TTS is currently ACTIVE\n\n💡 To mute, use: mute()"
1102
+
1103
+ async def list_background_music(self) -> str:
1104
+ """
1105
+ List all available background music tracks.
1106
+
1107
+ Returns:
1108
+ Formatted list of all pre-packaged background music files
1109
+ """
1110
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
1111
+ return result.stdout if result.ok else f"❌ Failed to list background music: {result.error_detail}"
1112
+
1113
+ async def set_background_music(self, track_name: str, agent_name: Optional[str] = None) -> str:
1114
+ """
1115
+ Set background music track for a specific agent, all agents, or as default.
1116
+
1117
+ Args:
1118
+ track_name: Track filename or partial name for fuzzy matching
1119
+ agent_name: Agent name ('all' for all agents, None for default)
1120
+
1121
+ Returns:
1122
+ Success or error message
1123
+ """
1124
+ import re
1125
+
1126
+ # Get list of available tracks for fuzzy matching
1127
+ list_result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
1128
+ if not list_result.ok:
1129
+ return f"❌ Failed to list background music tracks: {list_result.error_detail}"
1130
+
1131
+ # Parse track names
1132
+ tracks = []
1133
+ for line in list_result.stdout.split("\n"):
1134
+ match = re.match(r'\s*\d+\.\s+(.+)', line.strip())
1135
+ if match:
1136
+ tracks.append(match.group(1).strip())
1137
+
1138
+ # Try to find a matching track (case-insensitive partial match)
1139
+ track_lower = track_name.lower()
1140
+ matched_track = None
1141
+
1142
+ # First try exact match
1143
+ for track in tracks:
1144
+ if track.lower() == track_lower:
1145
+ matched_track = track
1146
+ break
1147
+
1148
+ # If no exact match, try partial match
1149
+ if not matched_track:
1150
+ for track in tracks:
1151
+ if track_lower in track.lower():
1152
+ matched_track = track
1153
+ break
1154
+
1155
+ if not matched_track:
1156
+ # Show available tracks to help user
1157
+ available = "\n".join([f" {t}" for t in tracks])
1158
+ return f"❌ No track matching '{track_name}' found.\n\nAvailable tracks:\n{available}\n\n💡 Try a partial match like 'celtic' or 'chillwave'"
1159
+
1160
+ # Determine which command to use based on agent_name
1161
+ if agent_name and agent_name.lower() == "all":
1162
+ # Set for all agents
1163
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-all", matched_track])
1164
+ elif agent_name:
1165
+ # Set for specific agent
1166
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-agent", agent_name, matched_track])
1167
+ else:
1168
+ # Set as default
1169
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-default", matched_track])
1170
+
1171
+ if result.ok:
1172
+ if matched_track.lower() != track_name.lower():
1173
+ return f"{result.stdout}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
1174
+ return result.stdout
1175
+ return f"❌ Failed to set background music: {result.error_detail}"
1176
+
1177
+ async def enable_background_music(self, enabled: bool) -> str:
1178
+ """
1179
+ Enable or disable background music globally.
1180
+
1181
+ Args:
1182
+ enabled: True to enable, False to disable
1183
+
1184
+ Returns:
1185
+ Success or error message
1186
+ """
1187
+ command = "on" if enabled else "off"
1188
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, [command])
1189
+ # Sync to .agentvibes/config.json (TUI source of truth)
1190
+ try:
1191
+ import json
1192
+ cfg_path = self.agentvibes_root / ".agentvibes" / "config.json"
1193
+ cfg = {}
1194
+ if cfg_path.exists():
1195
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
1196
+ if "backgroundMusic" not in cfg:
1197
+ cfg["backgroundMusic"] = {}
1198
+ cfg["backgroundMusic"]["enabled"] = enabled
1199
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
1200
+ cfg_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
1201
+ except Exception:
1202
+ pass # best-effort sync
1203
+ if result.ok:
1204
+ return result.stdout
1205
+ return f"❌ Failed to {'enable' if enabled else 'disable'} background music: {result.error_detail}"
1206
+
1207
+ async def set_background_music_volume(self, volume: float) -> str:
1208
+ """
1209
+ Set background music volume.
1210
+
1211
+ Args:
1212
+ volume: Volume level (0.0-1.0)
1213
+
1214
+ Returns:
1215
+ Success or error message
1216
+ """
1217
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["volume", str(volume)])
1218
+ return result.stdout if result.ok else f"❌ Failed to set background music volume: {result.error_detail}"
1219
+
1220
+ async def get_background_music_status(self) -> str:
1221
+ """
1222
+ Get current background music configuration.
1223
+
1224
+ Returns:
1225
+ Status information
1226
+ """
1227
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["status"])
1228
+ return result.stdout if result.ok else f"❌ Failed to get background music status: {result.error_detail}"
1229
+
1230
+ async def set_reverb(self, level: str, agent: str = "default", apply_all: bool = False) -> str:
1231
+ """
1232
+ Set reverb level for an agent or globally.
1233
+
1234
+ Args:
1235
+ level: Reverb level (off, light, medium, heavy, cathedral)
1236
+ agent: Agent name (default: "default")
1237
+ apply_all: Apply to all agents (default: False)
1238
+
1239
+ Returns:
1240
+ Success message
1241
+ """
1242
+ args = ["set-reverb", level, agent]
1243
+ if apply_all:
1244
+ args.append("--all")
1245
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, args)
1246
+ if result.ok:
1247
+ return result.stdout if result.stdout else f" Set reverb to {level}"
1248
+ return f"❌ Failed to set reverb: {result.error_detail}"
1249
+
1250
+ async def get_reverb(self, agent: str = "default") -> str:
1251
+ """
1252
+ Get current reverb level for an agent.
1253
+
1254
+ Args:
1255
+ agent: Agent name (default: "default")
1256
+
1257
+ Returns:
1258
+ Current reverb level
1259
+ """
1260
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["get-reverb", agent])
1261
+ if result.ok:
1262
+ return f"Current reverb level for {agent}: {result.stdout.strip()}"
1263
+ return f"❌ Failed to get reverb for {agent}: {result.error_detail}"
1264
+
1265
+ async def list_audio_effects(self) -> str:
1266
+ """
1267
+ List all audio effects for all agents.
1268
+
1269
+ Returns:
1270
+ Effects configuration
1271
+ """
1272
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["list"])
1273
+ return result.stdout if result.ok else f"❌ Failed to list audio effects: {result.error_detail}"
1274
+
1275
+ async def clean_audio_cache(self) -> str:
1276
+ """
1277
+ Clean all TTS audio cache files and report space freed.
1278
+
1279
+ Non-interactive cleanup suitable for MCP tool usage. Deletes all
1280
+ TTS-generated audio files (wav, mp3, aiff) while preserving
1281
+ background music tracks.
1282
+
1283
+ Returns:
1284
+ Cleanup results with file count and space freed
1285
+ """
1286
+ result = await self._run_script("clean-audio-cache.sh", [])
1287
+ return result.stdout if result.ok else f"❌ Failed to clean audio cache: {result.error_detail}"
1288
+
1289
+ # ── Hermes config helpers ────────────────────────────────────────────────
1290
+
1291
+ def _hermes_cfg_path(self) -> Path:
1292
+ hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
1293
+ return hermes_home / "hooks" / "agentvibes-tts" / "agentvibes-ssh-config.json"
1294
+
1295
+ async def get_hermes_config(self) -> str:
1296
+ """
1297
+ Get current Hermes AgentVibes SSH configuration.
1298
+
1299
+ Returns:
1300
+ Current SSH key, host, port, and voice settings
1301
+ """
1302
+ cfg_path = self._hermes_cfg_path()
1303
+ defaults = {
1304
+ "mode": "local",
1305
+ "sshKey": "/absolute/path/to/id_ed25519_agentvibes",
1306
+ "host": "your-receiver-tailscale-ip",
1307
+ "port": "2222",
1308
+ "voice": "en_US-libritts-high::Leo-8",
1309
+ }
1310
+ try:
1311
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
1312
+ except Exception:
1313
+ cfg = {}
1314
+ merged = {**defaults, **cfg}
1315
+ installed = cfg_path.exists()
1316
+ is_local = merged.get("mode", "local") == "local"
1317
+ out = "🔌 Hermes AgentVibes Configuration\n"
1318
+ out += "─" * 40 + "\n"
1319
+ out += f"Status: {' Configured' if installed else '⚠️ Not yet installed (run: agentvibes install)'}\n"
1320
+ out += f"Mode: {'🏠 Local (Hermes & speakers on same machine)' if is_local else '🌐 Remote (SSH to receiver)'}\n"
1321
+ out += f"Voice: {merged['voice']}\n"
1322
+ if not is_local:
1323
+ out += f"SSH Key: {merged['sshKey']}\n"
1324
+ out += f"Host: {merged['host']}\n"
1325
+ out += f"Port: {merged['port']}\n"
1326
+ if installed:
1327
+ out += f"\nConfig file: {cfg_path}\n"
1328
+ out += "After changes, run: hermes gateway restart\n"
1329
+ return out
1330
+
1331
+ async def set_hermes_config(
1332
+ self,
1333
+ mode: Optional[str] = None,
1334
+ ssh_key: Optional[str] = None,
1335
+ host: Optional[str] = None,
1336
+ port: Optional[str] = None,
1337
+ voice: Optional[str] = None,
1338
+ ) -> str:
1339
+ """
1340
+ Save Hermes AgentVibes SSH configuration.
1341
+
1342
+ Returns:
1343
+ Success message with saved values
1344
+ """
1345
+ import re as _re
1346
+ cfg_path = self._hermes_cfg_path()
1347
+ defaults = {
1348
+ "mode": "local",
1349
+ "sshKey": "/absolute/path/to/id_ed25519_agentvibes",
1350
+ "host": "your-receiver-tailscale-ip",
1351
+ "port": "2222",
1352
+ "voice": "en_US-libritts-high::Leo-8",
1353
+ }
1354
+ try:
1355
+ existing = json.loads(cfg_path.read_text(encoding="utf-8"))
1356
+ except Exception:
1357
+ existing = {}
1358
+ merged = {**defaults, **existing}
1359
+
1360
+ if mode is not None:
1361
+ m = str(mode).lower().strip()
1362
+ if m not in ("local", "remote"):
1363
+ return "❌ Invalid mode: must be 'local' or 'remote'"
1364
+ merged["mode"] = m
1365
+ if ssh_key is not None:
1366
+ sk = str(ssh_key).strip()
1367
+ if not _re.match(r'^[/~][a-zA-Z0-9_./ -]{0,511}$', sk):
1368
+ return " Invalid ssh_key: must be an absolute path (no special chars)"
1369
+ merged["sshKey"] = sk
1370
+ if host is not None:
1371
+ h = str(host).strip()
1372
+ if not _re.match(r'^[a-zA-Z0-9._\[\]:-]{1,253}$', h):
1373
+ return "❌ Invalid host: must be a hostname or IP address"
1374
+ merged["host"] = h
1375
+ if port is not None:
1376
+ p = str(port).strip()
1377
+ if not _re.match(r"^\d{1,5}$", p):
1378
+ return "❌ Invalid port: must be a number (e.g. '2222')"
1379
+ merged["port"] = p
1380
+ if voice is not None:
1381
+ merged["voice"] = str(voice)[:200]
1382
+
1383
+ try:
1384
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
1385
+ cfg_path.parent.chmod(0o700)
1386
+ cfg_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
1387
+ cfg_path.chmod(0o600)
1388
+ except Exception as e:
1389
+ return f"❌ Failed to save config: {e}"
1390
+
1391
+ is_local = merged.get("mode", "local") == "local"
1392
+ out = " Hermes config saved!\n"
1393
+ out += "─" * 40 + "\n"
1394
+ out += f"Mode: {'🏠 Local' if is_local else '🌐 Remote (SSH)'}\n"
1395
+ out += f"Voice: {merged['voice']}\n"
1396
+ if not is_local:
1397
+ out += f"SSH Key: {merged['sshKey']}\n"
1398
+ out += f"Host: {merged['host']}\n"
1399
+ out += f"Port: {merged['port']}\n"
1400
+ out += f"\nConfig file: {cfg_path}\n"
1401
+ out += "Run: hermes gateway restart\n"
1402
+ return out
1403
+
1404
+ # Helper methods
1405
+ def _build_script_env(self) -> dict:
1406
+ """Build environment dict for script execution (shared by all script runners)"""
1407
+ env = os.environ.copy()
1408
+
1409
+ # Determine where to save settings based on context:
1410
+ # 1. If cwd has .claude/ → Use cwd (real Claude Code project)
1411
+ # 2. Otherwise Use global ~/.claude/ (Claude Desktop, Warp, etc.)
1412
+ # Note: Hooks are ALWAYS from package .claude/ (self.claude_dir)
1413
+ cwd = Path.cwd()
1414
+ if (cwd / ".claude").is_dir() and cwd != self.agentvibes_root:
1415
+ env["CLAUDE_PROJECT_DIR"] = str(cwd)
1416
+
1417
+ # Augment PATH with platform-specific binary locations (Unix only).
1418
+ # MCP servers launched by Claude Desktop inherit a sanitized launchd/dbus PATH
1419
+ # that omits Homebrew (Mac) and pipx (all POSIX) locations.
1420
+ if not self.is_windows:
1421
+ home_dir = Path.home()
1422
+ extra_paths = [
1423
+ str(home_dir / ".local" / "bin"),
1424
+ str(home_dir / ".local" / "share" / "pipx" / "venvs" / "piper-tts" / "bin"),
1425
+ ]
1426
+ # Mac: add Homebrew prefix for both Apple Silicon (/opt/homebrew) and Intel (/usr/local)
1427
+ if self.is_darwin:
1428
+ extra_paths = ["/opt/homebrew/bin", "/usr/local/bin"] + extra_paths
1429
+
1430
+ current_path = env.get("PATH", "")
1431
+ path_parts = current_path.split(os.pathsep) if current_path else []
1432
+ new_dirs = [p for p in extra_paths if p not in path_parts]
1433
+ if new_dirs:
1434
+ env["PATH"] = os.pathsep.join(new_dirs) + os.pathsep + current_path
1435
+
1436
+ return env
1437
+
1438
+ async def _run_script(
1439
+ self,
1440
+ script_name: str,
1441
+ args: list[str],
1442
+ timeout: float = DEFAULT_SCRIPT_TIMEOUT,
1443
+ ) -> ScriptResult:
1444
+ """Run a script and return its (returncode, stdout, stderr) as a ScriptResult.
1445
+
1446
+ Callers MUST branch on `.ok`/`.returncode` never on text/emoji
1447
+ content because Windows manager scripts (.ps1) print plain text
1448
+ where the Unix (.sh) scripts print an emoji marker.
1449
+
1450
+ `stdin` is always DEVNULL and a timeout is always enforced so a
1451
+ script that reaches an interactive prompt (e.g. `read -p`) cannot
1452
+ inherit the MCP stdio JSON-RPC stream or hang the server forever.
1453
+ """
1454
+ # Auto-resolve .sh .ps1 on Windows (class constants handle special cases)
1455
+ if self.is_windows and script_name.endswith('.sh'):
1456
+ script_name = script_name[:-3] + '.ps1'
1457
+ script_path = self.hooks_dir / script_name
1458
+ if not script_path.exists():
1459
+ return ScriptResult(127, "", f"Script not found: {script_path}")
1460
+
1461
+ # Build command — PowerShell on Windows, bash on Unix
1462
+ if self.is_windows:
1463
+ cmd = [
1464
+ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
1465
+ "-File", str(script_path)
1466
+ ] + args
1467
+ else:
1468
+ cmd = ["bash", str(script_path)] + args
1469
+
1470
+ env = self._build_script_env()
1471
+
1472
+ proc = None
1473
+ try:
1474
+ # Run the child in its OWN process group/session (Unix) so a timeout
1475
+ # can kill the WHOLE tree. A plain proc.kill() only kills the direct
1476
+ # child (e.g. bash); a `sleep`/piper grandchild survives, keeps the
1477
+ # stdout pipe OPEN, and communicate() then blocks until it exits — so
1478
+ # the timeout is never honored (CI: 1s timeout let a 5s script run 5s).
1479
+ _popen_kwargs = {}
1480
+ if os.name != "nt":
1481
+ _popen_kwargs["start_new_session"] = True
1482
+ proc = await asyncio.create_subprocess_exec(
1483
+ *cmd,
1484
+ stdin=asyncio.subprocess.DEVNULL,
1485
+ stdout=asyncio.subprocess.PIPE,
1486
+ stderr=asyncio.subprocess.PIPE,
1487
+ env=env,
1488
+ **_popen_kwargs,
1489
+ )
1490
+ # asyncio.wait() with a timeout returns at the deadline WITHOUT
1491
+ # cancelling the task (unlike wait_for, whose cancellation of
1492
+ # communicate() awaits the child). So we get control back on time,
1493
+ # then kill the whole group and drain.
1494
+ comm_task = asyncio.ensure_future(proc.communicate())
1495
+ done, _pending = await asyncio.wait({comm_task}, timeout=timeout)
1496
+ if comm_task not in done:
1497
+ try:
1498
+ if os.name != "nt":
1499
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # whole group
1500
+ else:
1501
+ proc.kill()
1502
+ except (ProcessLookupError, PermissionError, OSError):
1503
+ pass
1504
+ # Bounded drain: the group is dead, so pipes close and this returns
1505
+ # immediately; the timeout guards against any lingering handle.
1506
+ try:
1507
+ await asyncio.wait_for(comm_task, timeout=2.0)
1508
+ except Exception:
1509
+ comm_task.cancel()
1510
+ return ScriptResult(
1511
+ -1, "",
1512
+ f"Script '{script_name}' timed out after {timeout:.0f}s "
1513
+ "(it may have reached an interactive prompt)"
1514
+ )
1515
+ stdout, stderr = comm_task.result()
1516
+ return ScriptResult(
1517
+ proc.returncode if proc.returncode is not None else -1,
1518
+ stdout.decode(errors="replace").strip(),
1519
+ stderr.decode(errors="replace").strip(),
1520
+ )
1521
+ except Exception as e:
1522
+ return ScriptResult(-2, "", f"Error running script: {e}")
1523
+ finally:
1524
+ # Ensure process cleanup
1525
+ if proc is not None and proc.returncode is None:
1526
+ proc.kill()
1527
+ await proc.wait()
1528
+
1529
+ async def _get_current_voice(self) -> str:
1530
+ """Get the currently active voice"""
1531
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["get"])
1532
+ return result.stdout.strip() if result.ok and result.stdout else "Unknown"
1533
+
1534
+ async def _get_personality(self) -> str:
1535
+ """Get the current personality setting"""
1536
+ personality_file = self.claude_dir / "tts-personality.txt"
1537
+ if not personality_file.exists():
1538
+ # Try global
1539
+ personality_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-personality.txt"
1540
+
1541
+ try:
1542
+ if personality_file.exists():
1543
+ return personality_file.read_text().strip()
1544
+ except (PermissionError, UnicodeDecodeError, OSError) as e:
1545
+ # Log error but don't crash - return default
1546
+ import sys
1547
+ print(f"Warning: Could not read personality file: {e}", file=sys.stderr)
1548
+ return "normal"
1549
+
1550
+ async def _get_language(self) -> str:
1551
+ """Get the current language setting"""
1552
+ result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["code"])
1553
+ return result.stdout.strip() if result.ok and result.stdout else "english"
1554
+
1555
+ async def _get_provider(self) -> str:
1556
+ """Get the active TTS provider"""
1557
+ provider_file = self.claude_dir / "tts-provider.txt"
1558
+ if not provider_file.exists():
1559
+ provider_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-provider.txt"
1560
+
1561
+ provider_labels = {
1562
+ "macos": "macOS TTS",
1563
+ "piper": "Piper TTS (Free, Offline)",
1564
+ "termux-ssh": "Termux SSH (Android)",
1565
+ "windows-piper": "Windows Piper TTS (Free, Offline)",
1566
+ "windows-sapi": "Windows SAPI (Built-in)",
1567
+ "soprano": "Soprano TTS (Ultra-fast Neural)",
1568
+ }
1569
+ try:
1570
+ if provider_file.exists():
1571
+ provider = provider_file.read_text().strip()
1572
+ # Strip BOM from PowerShell-written files
1573
+ provider = provider.lstrip('\ufeff')
1574
+ return provider_labels.get(provider, provider)
1575
+ except (PermissionError, UnicodeDecodeError, OSError) as e:
1576
+ # Log error but don't crash - return default
1577
+ import sys
1578
+ print(f"Warning: Could not read provider file: {e}", file=sys.stderr)
1579
+ # Default based on platform
1580
+ if self.is_windows:
1581
+ return "Windows SAPI (Built-in)"
1582
+ return "Piper TTS (Free, Offline)"
1583
+
1584
+
1585
+ # Create the MCP server
1586
+ app = Server("agentvibes")
1587
+ agent_vibes = AgentVibesServer()
1588
+
1589
+
1590
+ @app.list_tools()
1591
+ async def list_tools() -> list[Tool]:
1592
+ """List all available AgentVibes tools"""
1593
+ return [
1594
+ Tool(
1595
+ name="text_to_speech",
1596
+ description="""Convert text to speech using AgentVibes TTS.
1597
+
1598
+ Supports both macOS TTS and Piper (free, offline) providers.
1599
+ Can use different voices, personalities, and languages.
1600
+
1601
+ Perfect for:
1602
+ - Speaking acknowledgments and confirmations
1603
+ - Adding voice to Claude responses
1604
+ - Multi-language communication
1605
+ - Personality-driven interactions
1606
+
1607
+ Examples:
1608
+ - text_to_speech(text="Hello, I'm ready to help!")
1609
+ - text_to_speech(text="Task completed!", personality="flirty")
1610
+ - text_to_speech(text="Hola, ¿cómo estás?", language="spanish")
1611
+ """,
1612
+ inputSchema={
1613
+ "type": "object",
1614
+ "properties": {
1615
+ "text": {
1616
+ "type": "string",
1617
+ "description": "Text to convert to speech (max 500 characters)",
1618
+ },
1619
+ "voice": {
1620
+ "type": "string",
1621
+ "description": "Voice name (optional). Use list_voices to see options.",
1622
+ },
1623
+ "personality": {
1624
+ "type": "string",
1625
+ "description": "Personality style (optional). Examples: flirty, sarcastic, pirate, robot, zen",
1626
+ },
1627
+ "language": {
1628
+ "type": "string",
1629
+ "description": "Language to speak in (optional). Examples: spanish, french, german, italian",
1630
+ },
1631
+ },
1632
+ "required": ["text"],
1633
+ },
1634
+ ),
1635
+ Tool(
1636
+ name="list_voices",
1637
+ description="List all available TTS voices with current selection",
1638
+ inputSchema={"type": "object", "properties": {}},
1639
+ ),
1640
+ Tool(
1641
+ name="set_voice",
1642
+ description="Switch to a different TTS voice",
1643
+ inputSchema={
1644
+ "type": "object",
1645
+ "properties": {
1646
+ "voice_name": {
1647
+ "type": "string",
1648
+ "description": "Name of the voice to switch to",
1649
+ }
1650
+ },
1651
+ "required": ["voice_name"],
1652
+ },
1653
+ ),
1654
+ Tool(
1655
+ name="list_personalities",
1656
+ description="List all available personality styles with descriptions",
1657
+ inputSchema={"type": "object", "properties": {}},
1658
+ ),
1659
+ Tool(
1660
+ name="set_personality",
1661
+ description="Set the personality style for TTS messages",
1662
+ inputSchema={
1663
+ "type": "object",
1664
+ "properties": {
1665
+ "personality": {
1666
+ "type": "string",
1667
+ "description": "Personality name (e.g., flirty, sarcastic, pirate)",
1668
+ }
1669
+ },
1670
+ "required": ["personality"],
1671
+ },
1672
+ ),
1673
+ Tool(
1674
+ name="set_language",
1675
+ description="Set the language for TTS speech (supports 25+ languages)",
1676
+ inputSchema={
1677
+ "type": "object",
1678
+ "properties": {
1679
+ "language": {
1680
+ "type": "string",
1681
+ "description": "Language name (e.g., spanish, french, german)",
1682
+ }
1683
+ },
1684
+ "required": ["language"],
1685
+ },
1686
+ ),
1687
+ Tool(
1688
+ name="get_config",
1689
+ description="Get current voice, personality, language, and provider configuration",
1690
+ inputSchema={"type": "object", "properties": {}},
1691
+ ),
1692
+ Tool(
1693
+ name="replay_audio",
1694
+ description="Replay recently generated TTS audio",
1695
+ inputSchema={
1696
+ "type": "object",
1697
+ "properties": {
1698
+ "n": {
1699
+ "type": "integer",
1700
+ "description": "Which audio to replay (1 = most recent, default: 1)",
1701
+ "minimum": 1,
1702
+ "maximum": 10,
1703
+ }
1704
+ },
1705
+ },
1706
+ ),
1707
+ Tool(
1708
+ name="set_provider",
1709
+ description="Switch between TTS providers" + (
1710
+ ": Windows Piper, Windows SAPI, or Soprano" if agent_vibes.is_windows
1711
+ else ": macOS TTS, Piper (free, offline), Soprano, or Termux SSH (Android)"
1712
+ ),
1713
+ inputSchema={
1714
+ "type": "object",
1715
+ "properties": {
1716
+ "provider": {
1717
+ "type": "string",
1718
+ "description": (
1719
+ "Provider name: 'windows-piper', 'windows-sapi', or 'soprano'"
1720
+ if agent_vibes.is_windows
1721
+ else "Provider name: 'piper', 'macos', 'soprano', or 'termux-ssh'"
1722
+ ),
1723
+ "enum": (
1724
+ ["windows-piper", "windows-sapi", "soprano"]
1725
+ if agent_vibes.is_windows
1726
+ else ["piper", "macos", "soprano", "termux-ssh"]
1727
+ ),
1728
+ }
1729
+ },
1730
+ "required": ["provider"],
1731
+ },
1732
+ ),
1733
+ Tool(
1734
+ name="set_speed",
1735
+ description="Set speech speed for main or target voice. Works with both Piper and macOS providers. Use this to make voices faster or slower.",
1736
+ inputSchema={
1737
+ "type": "object",
1738
+ "properties": {
1739
+ "speed": {
1740
+ "type": "string",
1741
+ "description": "Speed value: '0.5x' or 'slow/slower' (half speed, slower), '1x' or 'normal' (normal speed), '2x' or 'fast' (double speed, faster), '3x' or 'faster' (triple speed, very fast)"
1742
+ },
1743
+ "target": {
1744
+ "type": "boolean",
1745
+ "description": "If true, sets target language speed (for learning mode); if false or omitted, sets main voice speed",
1746
+ "default": False
1747
+ }
1748
+ },
1749
+ "required": ["speed"],
1750
+ },
1751
+ ),
1752
+ Tool(
1753
+ name="get_speed",
1754
+ description="Get current speech speed settings for main and target voices",
1755
+ inputSchema={"type": "object", "properties": {}},
1756
+ ),
1757
+ Tool(
1758
+ name="download_extra_voices",
1759
+ description=(
1760
+ "Download extra high-quality custom Piper voices from HuggingFace. "
1761
+ "Includes: Kristin (US female), Jenny (UK female with Irish accent), "
1762
+ "and Tracy/16Speakers (multi-speaker). Perfect for adding variety to "
1763
+ "your TTS voices. This tool never proceeds without explicit consent: "
1764
+ "call it with auto_yes=True to actually start the download, or it "
1765
+ "returns a 'confirmation required' message and does nothing."
1766
+ ),
1767
+ inputSchema={
1768
+ "type": "object",
1769
+ "properties": {
1770
+ "auto_yes": {
1771
+ "type": "boolean",
1772
+ "description": (
1773
+ "Must be True to download. False (the default) returns a "
1774
+ "confirmation-required message without downloading anything "
1775
+ "this tool cannot answer an interactive Y/n prompt."
1776
+ ),
1777
+ "default": False
1778
+ }
1779
+ },
1780
+ },
1781
+ ),
1782
+ Tool(
1783
+ name="get_verbosity",
1784
+ description="Get current AgentVibes verbosity level (low/medium/high/caveman). Verbosity controls how much Claude speaks while working - from minimal (acknowledgments only) to maximum transparency (all reasoning spoken) to caveman (ultra-terse fragments, max token savings).",
1785
+ inputSchema={"type": "object", "properties": {}},
1786
+ ),
1787
+ Tool(
1788
+ name="set_verbosity",
1789
+ description="""Set AgentVibes verbosity level to control how much Claude speaks while working.
1790
+
1791
+ Verbosity Levels:
1792
+ - LOW: Only acknowledgments (start) and completions (end). Minimal interruption.
1793
+ - MEDIUM: + Major decisions and key findings. Balanced transparency.
1794
+ - HIGH: All reasoning, decisions, and findings. Maximum transparency.
1795
+ - CAVEMAN: Ultra-terse fragments. Drops articles, filler, hedging. Abbreviates heavily. 65-75% fewer output tokens.
1796
+
1797
+ Perfect for:
1798
+ - LOW: Quiet work sessions, minimal distraction
1799
+ - MEDIUM: Understanding major decisions without full narration
1800
+ - HIGH: Full transparency, learning mode, debugging complex tasks
1801
+ - CAVEMAN: Maximum token savings, minimal prose
1802
+
1803
+ Note: Changes take effect on next Claude Code session restart.""",
1804
+ inputSchema={
1805
+ "type": "object",
1806
+ "properties": {
1807
+ "level": {
1808
+ "type": "string",
1809
+ "description": "Verbosity level to set",
1810
+ "enum": ["low", "medium", "high", "caveman"]
1811
+ }
1812
+ },
1813
+ "required": ["level"],
1814
+ },
1815
+ ),
1816
+ Tool(
1817
+ name="mute",
1818
+ description="Mute all AgentVibes TTS output. Creates a persistent mute flag that silences all voice output until unmuted. Persists across sessions.",
1819
+ inputSchema={"type": "object", "properties": {}},
1820
+ ),
1821
+ Tool(
1822
+ name="unmute",
1823
+ description="Unmute AgentVibes TTS output. Removes the mute flag and restores voice output.",
1824
+ inputSchema={"type": "object", "properties": {}},
1825
+ ),
1826
+ Tool(
1827
+ name="is_muted",
1828
+ description="Check if TTS is currently muted.",
1829
+ inputSchema={"type": "object", "properties": {}},
1830
+ ),
1831
+ Tool(
1832
+ name="list_background_music",
1833
+ description="List all available pre-packaged background music tracks. Shows all audio files that can be used as background music for TTS.",
1834
+ inputSchema={"type": "object", "properties": {}},
1835
+ ),
1836
+ Tool(
1837
+ name="set_background_music",
1838
+ description="""Set background music track for a specific agent, all agents, or as default. Supports smart fuzzy matching.
1839
+
1840
+ Perfect for:
1841
+ - "change background music to flamenco" - Sets for all agents
1842
+ - "set John's background music to celtic harp" - Agent-specific
1843
+ - "use chillwave as default background" - Default for new agents
1844
+
1845
+ Fuzzy matching examples:
1846
+ - "flamenco" matches "agentvibes_soft_flamenco_loop.mp3"
1847
+ - "celtic" matches "agent_vibes_celtic_harp_v1_loop.mp3"
1848
+ - "bossa" matches "agent_vibes_bossa_nova_v2_loop.mp3"
1849
+ """,
1850
+ inputSchema={
1851
+ "type": "object",
1852
+ "properties": {
1853
+ "track_name": {
1854
+ "type": "string",
1855
+ "description": "Track filename or partial name for fuzzy matching (e.g., 'celtic', 'flamenco', 'bossa nova')",
1856
+ },
1857
+ "agent_name": {
1858
+ "type": "string",
1859
+ "description": "Agent name to configure (optional). Use 'all' for all agents, omit for default",
1860
+ },
1861
+ },
1862
+ "required": ["track_name"],
1863
+ },
1864
+ ),
1865
+ Tool(
1866
+ name="enable_background_music",
1867
+ description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 20%).",
1868
+ inputSchema={
1869
+ "type": "object",
1870
+ "properties": {
1871
+ "enabled": {
1872
+ "type": "boolean",
1873
+ "description": "True to enable background music, False to disable",
1874
+ }
1875
+ },
1876
+ "required": ["enabled"],
1877
+ },
1878
+ ),
1879
+ Tool(
1880
+ name="set_background_music_volume",
1881
+ description="Set the volume level for background music (0.0-1.0). Recommended: 0.20-0.40 for subtle background ambiance.",
1882
+ inputSchema={
1883
+ "type": "object",
1884
+ "properties": {
1885
+ "volume": {
1886
+ "type": "number",
1887
+ "description": "Volume level (0.0 = silent, 0.20 = default, 1.0 = full volume)",
1888
+ "minimum": 0.0,
1889
+ "maximum": 1.0,
1890
+ }
1891
+ },
1892
+ "required": ["volume"],
1893
+ },
1894
+ ),
1895
+ Tool(
1896
+ name="get_background_music_status",
1897
+ description="Get current background music configuration including enabled status, volume, default track, and number of available tracks.",
1898
+ inputSchema={"type": "object", "properties": {}},
1899
+ ),
1900
+ Tool(
1901
+ name="set_reverb",
1902
+ description="""Set reverb level for TTS audio. Can apply globally (default agent), to a specific agent, or to all agents.
1903
+
1904
+ Reverb adds room/space ambiance to the voice, making it sound like it's in a small room, conference room, or large hall.
1905
+
1906
+ Examples:
1907
+ - set_reverb(level="medium") - Set reverb for default agent
1908
+ - set_reverb(level="cathedral", agent="Winston") - Set cathedral reverb for Winston
1909
+ - set_reverb(level="light", apply_all=True) - Set light reverb for all agents
1910
+ - set_reverb(level="off") - Turn off reverb for default agent
1911
+ """,
1912
+ inputSchema={
1913
+ "type": "object",
1914
+ "properties": {
1915
+ "level": {
1916
+ "type": "string",
1917
+ "description": "Reverb level",
1918
+ "enum": ["off", "light", "medium", "heavy", "cathedral"]
1919
+ },
1920
+ "agent": {
1921
+ "type": "string",
1922
+ "description": "Agent name (optional, defaults to 'default'). Examples: Winston, John, Mary, Amelia",
1923
+ },
1924
+ "apply_all": {
1925
+ "type": "boolean",
1926
+ "description": "Apply to all agents (optional, default: false)",
1927
+ }
1928
+ },
1929
+ "required": ["level"],
1930
+ },
1931
+ ),
1932
+ Tool(
1933
+ name="get_reverb",
1934
+ description="Get current reverb level for a specific agent or default",
1935
+ inputSchema={
1936
+ "type": "object",
1937
+ "properties": {
1938
+ "agent": {
1939
+ "type": "string",
1940
+ "description": "Agent name (optional, defaults to 'default')",
1941
+ }
1942
+ },
1943
+ },
1944
+ ),
1945
+ Tool(
1946
+ name="list_audio_effects",
1947
+ description="List current audio effects configuration for all agents, including reverb levels and other effects",
1948
+ inputSchema={"type": "object", "properties": {}},
1949
+ ),
1950
+ Tool(
1951
+ name="clean_audio_cache",
1952
+ description="Clean all TTS audio cache files and report space freed. Non-interactive cleanup that removes all wav/mp3/aiff files while preserving background music tracks.",
1953
+ inputSchema={"type": "object", "properties": {}},
1954
+ ),
1955
+ Tool(
1956
+ name="get_hermes_config",
1957
+ description="Get current Hermes AgentVibes SSH configuration (SSH key path, host, port, voice). Use this to check what's currently set before changing it.",
1958
+ inputSchema={"type": "object", "properties": {}},
1959
+ ),
1960
+ Tool(
1961
+ name="set_hermes_config",
1962
+ description="Configure Hermes AgentVibes TTS settings. Choose 'local' mode when Hermes runs on the same machine as your speakers (no SSH needed), or 'remote' mode to send audio over SSH to a receiver. Omit any field to keep its current value.",
1963
+ inputSchema={
1964
+ "type": "object",
1965
+ "properties": {
1966
+ "mode": {
1967
+ "type": "string",
1968
+ "enum": ["local", "remote"],
1969
+ "description": "'local' = Hermes and speakers on same machine (no SSH). 'remote' = send audio over SSH to a receiver machine.",
1970
+ },
1971
+ "ssh_key": {
1972
+ "type": "string",
1973
+ "description": "Absolute path to SSH private key (e.g. /home/user/.ssh/id_ed25519_agentvibes)only used in remote mode",
1974
+ },
1975
+ "host": {
1976
+ "type": "string",
1977
+ "description": "Tailscale IP or hostname of the machine with speakers — only used in remote mode",
1978
+ },
1979
+ "port": {
1980
+ "type": "string",
1981
+ "description": "AgentVibes receiver SSH port (e.g. '2222') — only used in remote mode",
1982
+ },
1983
+ "voice": {
1984
+ "type": "string",
1985
+ "description": "Piper voice model (e.g. 'en_US-libritts-high::Leo-8')",
1986
+ },
1987
+ },
1988
+ },
1989
+ ),
1990
+ ]
1991
+
1992
+
1993
+ @app.call_tool()
1994
+ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolResult:
1995
+ """Handle tool calls.
1996
+
1997
+ Every AgentVibesServer method below returns a string that starts with
1998
+ "❌" on failure (a marker produced by our own code from the script's
1999
+ *exit code*, not sniffed from the child script's stdout — see H1/#1 in
2000
+ story 8.2). We use that marker here, once, to set MCP's `isError` so
2001
+ clients can distinguish failure without parsing text.
2002
+ """
2003
+ try:
2004
+ if name == "text_to_speech":
2005
+ result = await agent_vibes.text_to_speech(
2006
+ text=arguments["text"],
2007
+ voice=arguments.get("voice"),
2008
+ personality=arguments.get("personality"),
2009
+ language=arguments.get("language"),
2010
+ )
2011
+ elif name == "list_voices":
2012
+ result = await agent_vibes.list_voices()
2013
+ elif name == "set_voice":
2014
+ result = await agent_vibes.set_voice(arguments["voice_name"])
2015
+ elif name == "list_personalities":
2016
+ result = await agent_vibes.list_personalities()
2017
+ elif name == "set_personality":
2018
+ result = await agent_vibes.set_personality(arguments["personality"])
2019
+ elif name == "set_language":
2020
+ result = await agent_vibes.set_language(arguments["language"])
2021
+ elif name == "get_config":
2022
+ result = await agent_vibes.get_config()
2023
+ elif name == "replay_audio":
2024
+ n = arguments.get("n", 1)
2025
+ result = await agent_vibes.replay_audio(n)
2026
+ elif name == "set_provider":
2027
+ result = await agent_vibes.set_provider(arguments["provider"])
2028
+ elif name == "set_speed":
2029
+ target = arguments.get("target", False)
2030
+ result = await agent_vibes.set_speed(arguments["speed"], target)
2031
+ elif name == "get_speed":
2032
+ result = await agent_vibes.get_speed()
2033
+ elif name == "download_extra_voices":
2034
+ auto_yes = arguments.get("auto_yes", False)
2035
+ result = await agent_vibes.download_extra_voices(auto_yes)
2036
+ elif name == "get_verbosity":
2037
+ result = await agent_vibes.get_verbosity()
2038
+ elif name == "set_verbosity":
2039
+ result = await agent_vibes.set_verbosity(arguments["level"])
2040
+ elif name == "mute":
2041
+ result = await agent_vibes.mute()
2042
+ elif name == "unmute":
2043
+ result = await agent_vibes.unmute()
2044
+ elif name == "is_muted":
2045
+ result = await agent_vibes.is_muted()
2046
+ elif name == "list_background_music":
2047
+ result = await agent_vibes.list_background_music()
2048
+ elif name == "set_background_music":
2049
+ track_name = arguments.get("track_name")
2050
+ agent_name = arguments.get("agent_name")
2051
+ result = await agent_vibes.set_background_music(track_name, agent_name)
2052
+ elif name == "enable_background_music":
2053
+ enabled = arguments.get("enabled")
2054
+ result = await agent_vibes.enable_background_music(enabled)
2055
+ elif name == "set_background_music_volume":
2056
+ volume = arguments.get("volume")
2057
+ result = await agent_vibes.set_background_music_volume(volume)
2058
+ elif name == "get_background_music_status":
2059
+ result = await agent_vibes.get_background_music_status()
2060
+ elif name == "set_reverb":
2061
+ level = arguments["level"]
2062
+ agent = arguments.get("agent", "default")
2063
+ apply_all = arguments.get("apply_all", False)
2064
+ result = await agent_vibes.set_reverb(level, agent, apply_all)
2065
+ elif name == "get_reverb":
2066
+ agent = arguments.get("agent", "default")
2067
+ result = await agent_vibes.get_reverb(agent)
2068
+ elif name == "list_audio_effects":
2069
+ result = await agent_vibes.list_audio_effects()
2070
+ elif name == "clean_audio_cache":
2071
+ result = await agent_vibes.clean_audio_cache()
2072
+ elif name == "get_hermes_config":
2073
+ result = await agent_vibes.get_hermes_config()
2074
+ elif name == "set_hermes_config":
2075
+ result = await agent_vibes.set_hermes_config(
2076
+ mode=arguments.get("mode"),
2077
+ ssh_key=arguments.get("ssh_key"),
2078
+ host=arguments.get("host"),
2079
+ port=arguments.get("port"),
2080
+ voice=arguments.get("voice"),
2081
+ )
2082
+ else:
2083
+ return CallToolResult(
2084
+ content=[TextContent(type="text", text=f"Unknown tool: {name}")],
2085
+ isError=True,
2086
+ )
2087
+
2088
+ content = [TextContent(type="text", text=result)]
2089
+ if isinstance(result, str) and result.startswith("❌"):
2090
+ return CallToolResult(content=content, isError=True)
2091
+ return content
2092
+
2093
+ except Exception as e:
2094
+ return CallToolResult(
2095
+ content=[TextContent(type="text", text=f"Error: {str(e)}")],
2096
+ isError=True,
2097
+ )
2098
+
2099
+
2100
+ async def main():
2101
+ """Run the MCP server"""
2102
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
2103
+ await app.run(
2104
+ read_stream,
2105
+ write_stream,
2106
+ app.create_initialization_options(),
2107
+ )
2108
+
2109
+
2110
+ if __name__ == "__main__":
2111
+ asyncio.run(main())