agentvibes 5.2.1 → 5.4.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 (222) hide show
  1. package/.agentvibes/LITE-MODE.md +236 -0
  2. package/.agentvibes/README.md +136 -0
  3. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +141 -0
  4. package/.agentvibes/backups/agents/analyst_20260204_144958.md +78 -0
  5. package/.agentvibes/backups/agents/architect_20260204_144958.md +72 -0
  6. package/.agentvibes/backups/agents/dev_20260204_144958.md +74 -0
  7. package/.agentvibes/backups/agents/pm_20260204_144958.md +72 -0
  8. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +64 -0
  9. package/.agentvibes/backups/agents/sm_20260204_144958.md +87 -0
  10. package/.agentvibes/backups/agents/tea_20260204_144958.md +79 -0
  11. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +82 -0
  12. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +80 -0
  13. package/.agentvibes/bmad/bmad-voices.md +69 -69
  14. package/.agentvibes/config/README-personality-defaults.md +162 -0
  15. package/.agentvibes/config/mode.txt +1 -0
  16. package/.agentvibes/config/personality-voice-defaults.default.json +21 -0
  17. package/.agentvibes/config/save-audio.txt +1 -0
  18. package/.agentvibes/config/voice-metadata.json +160 -0
  19. package/.agentvibes/config.json +24 -15
  20. package/.agentvibes/hooks/help.sh +191 -0
  21. package/.agentvibes/hooks/post-tool-use-lite.sh +111 -0
  22. package/.agentvibes/hooks/save-audio-manager.sh +162 -0
  23. package/.agentvibes/hooks/session-start-full-optimized.sh +102 -0
  24. package/.agentvibes/hooks/session-start-full.sh +142 -0
  25. package/.agentvibes/hooks/session-start-lite-v2.sh +34 -0
  26. package/.agentvibes/hooks/session-start-lite.sh +29 -0
  27. package/.agentvibes/hooks/stop-lite.sh +115 -0
  28. package/.agentvibes/hooks/switch-mode.sh +215 -0
  29. package/.agentvibes/output-styles/audio-summary.md +30 -0
  30. package/.claude/activation-instructions +54 -54
  31. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  32. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  33. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  34. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  35. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  36. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  37. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  38. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  39. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  40. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  41. package/.claude/commands/agent-vibes/add.md +21 -21
  42. package/.claude/commands/agent-vibes/agent-vibes.md +101 -101
  43. package/.claude/commands/agent-vibes/agent.md +79 -79
  44. package/.claude/commands/agent-vibes/background-music.md +111 -111
  45. package/.claude/commands/agent-vibes/bmad.md +198 -198
  46. package/.claude/commands/agent-vibes/clean.md +18 -18
  47. package/.claude/commands/agent-vibes/cleanup.md +18 -18
  48. package/.claude/commands/agent-vibes/commands.json +145 -145
  49. package/.claude/commands/agent-vibes/effects.md +97 -97
  50. package/.claude/commands/agent-vibes/get.md +9 -9
  51. package/.claude/commands/agent-vibes/hide.md +91 -91
  52. package/.claude/commands/agent-vibes/language.md +23 -23
  53. package/.claude/commands/agent-vibes/learn.md +67 -67
  54. package/.claude/commands/agent-vibes/list.md +13 -13
  55. package/.claude/commands/agent-vibes/mute.md +37 -37
  56. package/.claude/commands/agent-vibes/preview.md +17 -17
  57. package/.claude/commands/agent-vibes/provider.md +68 -68
  58. package/.claude/commands/agent-vibes/replay-target.md +14 -14
  59. package/.claude/commands/agent-vibes/sample.md +12 -12
  60. package/.claude/commands/agent-vibes/set-favorite-voice.md +84 -84
  61. package/.claude/commands/agent-vibes/set-pretext.md +65 -65
  62. package/.claude/commands/agent-vibes/set-speed.md +41 -41
  63. package/.claude/commands/agent-vibes/show.md +84 -84
  64. package/.claude/commands/agent-vibes/switch.md +87 -87
  65. package/.claude/commands/agent-vibes/target-voice.md +26 -26
  66. package/.claude/commands/agent-vibes/target.md +30 -30
  67. package/.claude/commands/agent-vibes/translate.md +68 -68
  68. package/.claude/commands/agent-vibes/unmute.md +45 -45
  69. package/.claude/commands/agent-vibes/whoami.md +7 -7
  70. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  71. package/.claude/commands/agent-vibes-rdp.md +24 -24
  72. package/.claude/config/audio-effects.cfg +4 -11
  73. package/.claude/config/audio-effects.cfg.sample +52 -52
  74. package/.claude/config/background-music-position.txt +27 -0
  75. package/.claude/config/background-music-volume.txt +1 -1
  76. package/.claude/config/background-music.cfg +1 -0
  77. package/.claude/config/background-music.txt +1 -0
  78. package/.claude/config/tts-speech-rate.txt +1 -4
  79. package/.claude/config/tts-verbosity.txt +1 -0
  80. package/.claude/docs/TERMUX_SETUP.md +408 -408
  81. package/.claude/github-star-reminder.txt +1 -1
  82. package/.claude/hooks/README-TTS-QUEUE.md +135 -135
  83. package/.claude/hooks/audio-cache-utils.sh +0 -0
  84. package/.claude/hooks/audio-processor.sh +60 -14
  85. package/.claude/hooks/background-music-manager.sh +0 -0
  86. package/.claude/hooks/bmad-party-manager.sh +225 -0
  87. package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
  88. package/.claude/hooks/bmad-speak.sh +6 -13
  89. package/.claude/hooks/bmad-tts-injector.sh +0 -0
  90. package/.claude/hooks/bmad-voice-manager.sh +0 -0
  91. package/.claude/hooks/clawdbot-receiver-SECURE.sh +25 -23
  92. package/.claude/hooks/clawdbot-receiver.sh +4 -28
  93. package/.claude/hooks/clean-audio-cache.sh +0 -0
  94. package/.claude/hooks/cleanup-cache.sh +0 -0
  95. package/.claude/hooks/configure-rdp-mode.sh +0 -0
  96. package/.claude/hooks/download-extra-voices.sh +0 -0
  97. package/.claude/hooks/effects-manager.sh +0 -0
  98. package/.claude/hooks/github-star-reminder.sh +0 -0
  99. package/.claude/hooks/language-manager.sh +0 -0
  100. package/.claude/hooks/learn-manager.sh +0 -0
  101. package/.claude/hooks/macos-voice-manager.sh +0 -0
  102. package/.claude/hooks/migrate-background-music.sh +0 -0
  103. package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
  104. package/.claude/hooks/optimize-background-music.sh +0 -0
  105. package/.claude/hooks/personality-manager.sh +0 -0
  106. package/.claude/hooks/piper-download-voices.sh +0 -0
  107. package/.claude/hooks/piper-installer.sh +1 -1
  108. package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
  109. package/.claude/hooks/piper-voice-manager.sh +0 -0
  110. package/.claude/hooks/play-tts-enhanced.sh +0 -0
  111. package/.claude/hooks/play-tts-macos.sh +6 -12
  112. package/.claude/hooks/play-tts-piper.sh +50 -79
  113. package/.claude/hooks/play-tts-soprano.sh +9 -43
  114. package/.claude/hooks/play-tts-ssh-remote.sh +42 -120
  115. package/.claude/hooks/play-tts-termux-ssh.sh +0 -0
  116. package/.claude/hooks/play-tts.sh +48 -37
  117. package/.claude/hooks/post-response.sh +41 -0
  118. package/.claude/hooks/prepare-release.sh +0 -0
  119. package/.claude/hooks/provider-commands.sh +0 -0
  120. package/.claude/hooks/provider-manager.sh +0 -0
  121. package/.claude/hooks/replay-target-audio.sh +0 -0
  122. package/.claude/hooks/requirements.txt +6 -6
  123. package/.claude/hooks/sentiment-manager.sh +0 -0
  124. package/.claude/hooks/session-start-tts.sh +56 -39
  125. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  126. package/.claude/hooks/speed-manager.sh +0 -0
  127. package/.claude/hooks/stop.sh +63 -0
  128. package/.claude/hooks/termux-installer.sh +0 -0
  129. package/.claude/hooks/translate-manager.sh +0 -0
  130. package/.claude/hooks/translator.py +237 -237
  131. package/.claude/hooks/tts-queue-worker.sh +0 -0
  132. package/.claude/hooks/tts-queue.sh +0 -0
  133. package/.claude/hooks/verbosity-manager.sh +0 -0
  134. package/.claude/hooks/voice-manager.sh +26 -4
  135. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  136. package/.claude/hooks-windows/bmad-party-speak.ps1 +278 -274
  137. package/.claude/hooks-windows/bmad-speak.ps1 +264 -264
  138. package/.claude/hooks-windows/clean-audio-cache.ps1 +53 -53
  139. package/.claude/hooks-windows/effects-manager.ps1 +294 -294
  140. package/.claude/hooks-windows/language-manager.ps1 +193 -193
  141. package/.claude/hooks-windows/learn-manager.ps1 +241 -241
  142. package/.claude/hooks-windows/personality-manager.ps1 +266 -266
  143. package/.claude/hooks-windows/play-tts-soprano.ps1 +5 -5
  144. package/.claude/hooks-windows/play-tts-termux-ssh.ps1 +138 -138
  145. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +164 -0
  146. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -0
  147. package/.claude/hooks-windows/play-tts.ps1 +104 -481
  148. package/.claude/hooks-windows/provider-manager.ps1 +158 -192
  149. package/.claude/hooks-windows/session-start-tts.ps1 +55 -46
  150. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  151. package/.claude/hooks-windows/speed-manager.ps1 +166 -166
  152. package/.claude/hooks-windows/voice-manager-windows.ps1 +176 -260
  153. package/.claude/output-styles/agent-vibes.md +202 -202
  154. package/.claude/personalities/angry.md +14 -14
  155. package/.claude/personalities/annoying.md +14 -14
  156. package/.claude/personalities/crass.md +14 -14
  157. package/.claude/personalities/dramatic.md +14 -14
  158. package/.claude/personalities/dry-humor.md +50 -50
  159. package/.claude/personalities/flirty.md +20 -20
  160. package/.claude/personalities/funny.md +14 -14
  161. package/.claude/personalities/grandpa.md +32 -32
  162. package/.claude/personalities/millennial.md +14 -14
  163. package/.claude/personalities/moody.md +14 -14
  164. package/.claude/personalities/normal.md +16 -16
  165. package/.claude/personalities/pirate.md +14 -14
  166. package/.claude/personalities/poetic.md +14 -14
  167. package/.claude/personalities/professional.md +14 -14
  168. package/.claude/personalities/rapper.md +55 -55
  169. package/.claude/personalities/robot.md +14 -14
  170. package/.claude/personalities/sarcastic.md +38 -38
  171. package/.claude/personalities/sassy.md +14 -14
  172. package/.claude/personalities/surfer-dude.md +14 -14
  173. package/.claude/personalities/zen.md +14 -14
  174. package/.claude/piper-voices-dir.txt +1 -0
  175. package/.claude/settings.json +25 -15
  176. package/.claude/verbosity.txt +1 -1
  177. package/.clawdbot/README.md +105 -105
  178. package/.clawdbot/skill/SKILL.md +149 -145
  179. package/.mcp.json +30 -11
  180. package/CLAUDE.md +170 -215
  181. package/README.md +206 -515
  182. package/RELEASE_NOTES.md +1132 -1884
  183. package/WINDOWS-SETUP.md +208 -208
  184. package/bin/agent-vibes +0 -0
  185. package/bin/agentvibes-voice-browser.js +64 -1289
  186. package/bin/agentvibes.js +0 -0
  187. package/bin/ensure-soprano-running.sh +43 -0
  188. package/bin/mcp-server.js +121 -121
  189. package/bin/mcp-server.sh +0 -0
  190. package/bin/test-bmad-pr +78 -78
  191. package/mcp-server/QUICK_START.md +203 -203
  192. package/mcp-server/README.md +345 -345
  193. package/mcp-server/WINDOWS_SETUP.md +260 -260
  194. package/mcp-server/docs/troubleshooting-audio.md +313 -313
  195. package/mcp-server/examples/claude_desktop_config.json +11 -11
  196. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  197. package/mcp-server/examples/custom_instructions.md +169 -169
  198. package/mcp-server/install-deps.js +130 -130
  199. package/mcp-server/pyproject.toml +52 -52
  200. package/mcp-server/requirements.txt +2 -2
  201. package/mcp-server/server.py +1451 -1578
  202. package/mcp-server/test_server.py +395 -395
  203. package/package.json +1 -3
  204. package/setup-windows.ps1 +815 -815
  205. package/src/console/tabs/setup-tab.js +9 -6
  206. package/src/console/tabs/voices-tab.js +9 -3
  207. package/src/installer.js +42 -5
  208. package/src/services/llm-provider-service.js +13 -0
  209. package/templates/agentvibes-receiver.sh +158 -483
  210. package/templates/audio/welcome-music.mp3 +0 -0
  211. package/.agentvibes/bmad-voice-map.json +0 -104
  212. package/.agentvibes/copilot-sessions.log +0 -4
  213. package/.claude/config/audio-effects-bmad.cfg +0 -50
  214. package/.claude/config/background-music-enabled.txt +0 -1
  215. package/.claude/config/intro-text.txt +0 -1
  216. package/.claude/config/personality.txt +0 -1
  217. package/.claude/config/piper-speech-rate.txt +0 -4
  218. package/.claude/config/piper-target-speech-rate.txt +0 -1
  219. package/.claude/config/reverb-level.txt +0 -1
  220. package/.claude/config/tts-target-speech-rate.txt +0 -1
  221. package/voice-assignments.json +0 -8245
  222. /package/{.claude → .agentvibes}/config/agentvibes.json +0 -0
@@ -1,1578 +1,1451 @@
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 subprocess
48
- from pathlib import Path
49
- from typing import Optional
50
-
51
- from mcp.server import Server
52
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
53
- import mcp.server.stdio
54
- class AgentVibesServer:
55
- """MCP Server for AgentVibes TTS functionality"""
56
-
57
- # Script name constants (addresses SonarCloud S1192)
58
- VOICE_MANAGER_SCRIPT = "voice-manager.sh"
59
- PERSONALITY_MANAGER_SCRIPT = "personality-manager.sh"
60
- LANGUAGE_MANAGER_SCRIPT = "language-manager.sh"
61
- BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.sh"
62
- EFFECTS_MANAGER_SCRIPT = "effects-manager.sh"
63
-
64
- # Path constants (addresses SonarCloud S1192)
65
- CLAUDE_DIR_NAME = ".claude"
66
- MUTE_FILE_NAME = ".agentvibes-muted"
67
- SEPARATOR = "━" * 39
68
-
69
- def __init__(self):
70
- """Initialize the AgentVibes MCP server"""
71
- # Detect native Windows (not WSL)
72
- self.is_windows = platform.system() == "Windows" and not os.environ.get("WSL_DISTRO_NAME")
73
-
74
- # Script name constants — Windows uses .ps1, Unix uses .sh
75
- if self.is_windows:
76
- self.VOICE_MANAGER_SCRIPT = "voice-manager-windows.ps1"
77
- self.PERSONALITY_MANAGER_SCRIPT = "personality-manager.ps1"
78
- self.LANGUAGE_MANAGER_SCRIPT = "language-manager.ps1"
79
- self.BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.ps1"
80
- self.EFFECTS_MANAGER_SCRIPT = "effects-manager.ps1"
81
-
82
- # Find the .claude directory (project-local or global)
83
- self.claude_dir = self._find_claude_dir()
84
- self.hooks_dir = self.claude_dir / ("hooks-windows" if self.is_windows else "hooks")
85
- # Store AgentVibes root directory for environment variable
86
- self.agentvibes_root = self.claude_dir.parent
87
-
88
- def _find_claude_dir(self) -> Path:
89
- """Find the .claude directory relative to this script"""
90
- # Get the AgentVibes root directory (parent of mcp-server)
91
- script_dir = Path(__file__).resolve().parent # mcp-server/
92
- agentvibes_root = script_dir.parent # AgentVibes/
93
- claude_dir = agentvibes_root / self.CLAUDE_DIR_NAME
94
-
95
- # ALWAYS use package .claude for hooks (even in NPX cache)
96
- # The package ALWAYS has .claude/ with all the hooks
97
- if claude_dir.exists() and claude_dir.is_dir():
98
- return claude_dir
99
-
100
- # Fallback to global ~/.claude (should never happen in properly installed package)
101
- return Path.home() / self.CLAUDE_DIR_NAME
102
-
103
- def _resolve_friendly_name(self, voice_name: str) -> str:
104
- """
105
- Resolve friendly name to Piper voice ID using voice-metadata.json.
106
-
107
- Args:
108
- voice_name: Friendly name (e.g., "ryan") or Piper ID
109
-
110
- Returns:
111
- Resolved Piper voice ID, or original voice_name if not found
112
- """
113
- import re
114
-
115
- metadata_path = self.agentvibes_root / ".agentvibes" / "config" / "voice-metadata.json"
116
-
117
- # SECURITY: Verify file exists and is not a symlink
118
- if not metadata_path.exists() or metadata_path.is_symlink():
119
- return voice_name
120
-
121
- # SECURITY: Verify file ownership matches current user (Unix only)
122
- try:
123
- if hasattr(os, 'getuid'):
124
- stat_info = metadata_path.stat()
125
- if stat_info.st_uid != os.getuid():
126
- return voice_name
127
- except (OSError, AttributeError):
128
- pass
129
-
130
- try:
131
- with open(metadata_path, 'r') as f:
132
- metadata = json.load(f)
133
-
134
- voices = metadata.get('voices', {})
135
- voice_lower = voice_name.lower()
136
-
137
- resolved_id = None
138
-
139
- # Check if it's a friendly name key
140
- if voice_lower in voices:
141
- resolved_id = voices[voice_lower].get('id')
142
-
143
- # Check if it matches a displayName
144
- if not resolved_id:
145
- for friendly_name, voice_data in voices.items():
146
- if voice_data.get('displayName', '').lower() == voice_lower:
147
- resolved_id = voice_data.get('id')
148
- break
149
-
150
- # SECURITY: Validate resolved ID matches safe pattern
151
- if resolved_id and re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', resolved_id):
152
- return resolved_id
153
-
154
- except (json.JSONDecodeError, KeyError, IOError, TypeError):
155
- pass
156
-
157
- return voice_name
158
-
159
- async def text_to_speech(
160
- self,
161
- text: str,
162
- voice: Optional[str] = None,
163
- personality: Optional[str] = None,
164
- language: Optional[str] = None,
165
- ) -> str:
166
- """
167
- Convert text to speech using AgentVibes.
168
-
169
- Args:
170
- text: The text to speak
171
- voice: Optional voice name (e.g., "Aria", "Northern Terry")
172
- personality: Optional personality style (e.g., "flirty", "sarcastic")
173
- language: Optional language (e.g., "spanish", "french")
174
-
175
- Returns:
176
- Success message with audio file path
177
- """
178
- # Store original settings to restore later
179
- original_personality = None
180
- original_language = None
181
-
182
- try:
183
- # Temporarily set personality if specified
184
- if personality:
185
- original_personality = await self._get_personality()
186
- await self._run_script(
187
- self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
188
- )
189
-
190
- # Temporarily set language if specified
191
- if language:
192
- original_language = await self._get_language()
193
- await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
194
-
195
- # Call the TTS script via appropriate shell.
196
- #
197
- # The LLM key is determined with the following priority:
198
- #
199
- # 1. AGENTVIBES_LLM env var (explicit wins over everything).
200
- # Set in each provider's MCP launcher config:
201
- # ~/.codex/config.toml -> "codex"
202
- # ~/.copilot/mcp-config.json -> "copilot"
203
- #
204
- # 2. Auto-detection via CLAUDECODE=1 env var (Claude Code sets
205
- # this automatically when it spawns any subprocess). This
206
- # lets project-level .mcp.json files omit the env block —
207
- # which is critical because GitHub Copilot CLI also reads
208
- # .mcp.json and would otherwise pick up claude-code's env.
209
- #
210
- # 3. Unset — play-tts falls back to llm:default or global config.
211
- #
212
- # Validation mirrors play-tts.sh line 92 — alphanumeric /
213
- # hyphen / underscore only. Prevents weird values from
214
- # poisoning the audio-effects.cfg lookup or child-shell args.
215
- import re as _re
216
- llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
217
- if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
218
- print(
219
- f"[AgentVibes] WARN: Ignoring invalid AGENTVIBES_LLM='{llm_key}' "
220
- "(must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$); falling back to auto-detect",
221
- file=__import__('sys').stderr,
222
- )
223
- llm_key = ""
224
- # Claude Code sets CLAUDECODE=1 when it spawns subprocesses.
225
- #
226
- # KNOWN LIMITATION: CLAUDECODE=1 can leak from a parent terminal.
227
- # If the user launches VS Code from a Claude-Code-started shell,
228
- # Copilot inherits CLAUDECODE=1 and its MCP server will be
229
- # identified as "claude-code" instead of "copilot". Workaround:
230
- # `unset CLAUDECODE` before launching VS Code.
231
- if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
232
- llm_key = "claude-code"
233
- # AGENTVIBES_MCP_FALLBACK is set in .mcp.json. It's the fallback
234
- # identity for non-Claude-Code tools that read .mcp.json (e.g.
235
- # VS Code Copilot, which reads .mcp.json with precedence over
236
- # its own .vscode/mcp.json). Claude Code hits the CLAUDECODE
237
- # check above first so it's not affected.
238
- if not llm_key:
239
- fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
240
- if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
241
- llm_key = fallback
242
- tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
243
- play_tts = self.hooks_dir / tts_script
244
- if self.is_windows:
245
- args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(play_tts), text]
246
- if voice:
247
- args.extend(["-VoiceOverride", voice])
248
- if llm_key:
249
- args.extend(["-llm", llm_key])
250
- else:
251
- args = ["bash", str(play_tts), text]
252
- if voice:
253
- args.append(voice)
254
- if llm_key:
255
- args.extend(["--llm", llm_key])
256
-
257
- env = self._build_script_env()
258
- # Set agent name for audio effects lookup (audio-effects.cfg, background music config)
259
- env["AGENTVIBES_AGENT_NAME"] = "default"
260
-
261
- result = await asyncio.create_subprocess_exec(
262
- *args,
263
- stdout=asyncio.subprocess.PIPE,
264
- stderr=asyncio.subprocess.PIPE,
265
- env=env,
266
- )
267
- try:
268
- stdout, stderr = await result.communicate()
269
-
270
- if result.returncode == 0:
271
- output = stdout.decode().strip()
272
- # Extract file path from output
273
- audio_file_path = None
274
- for line in output.split("\n"):
275
- if "Saved to:" in line:
276
- audio_file_path = line.split("Saved to:")[1].strip()
277
- break
278
-
279
- if audio_file_path:
280
- truncated = (
281
- f"{text[:50]}..." if len(text) > 50 else text
282
- )
283
- return f" Spoke: {truncated}\n📁 Audio saved: {audio_file_path}"
284
-
285
- return f"✅ Spoke: {text[:50]}..." if len(text) > 50 else f"✅ Spoke: {text}"
286
- else:
287
- error = stderr.decode().strip()
288
- stdout_output = stdout.decode().strip()
289
- full_error = f"{error}\nStdout: {stdout_output}" if stdout_output else error
290
- return f"❌ TTS failed: {full_error}"
291
- finally:
292
- # Ensure process cleanup
293
- if result.returncode is None:
294
- result.kill()
295
- await result.wait()
296
-
297
- finally:
298
- # Restore original settings
299
- if original_personality:
300
- await self._run_script(
301
- self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
302
- )
303
- if original_language:
304
- await self._run_script(
305
- self.LANGUAGE_MANAGER_SCRIPT, ["set", original_language]
306
- )
307
-
308
- async def list_voices(self) -> str:
309
- """
310
- List all available TTS voices for the active provider.
311
-
312
- Returns:
313
- Formatted list of available voices
314
- """
315
- # Get active provider for display purposes
316
- provider = await self._get_provider()
317
- current_voice = await self._get_current_voice()
318
-
319
- # voice-manager.sh list-simple is now provider-aware
320
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["list-simple"])
321
- if result:
322
- voices = result.strip().split("\n")
323
- voices = [v for v in voices if v] # Filter empty strings
324
-
325
- if not voices:
326
- return (
327
- f"📦 No voices available\n"
328
- f"{self.SEPARATOR}\n"
329
- f"For Piper: Download voices using /agent-vibes:provider download <voice-name>\n"
330
- f"Example: en_US-lessac-medium, en_GB-alba-medium"
331
- )
332
-
333
- # Determine provider label and alternative provider
334
- if "Piper" in provider:
335
- provider_label = "Piper TTS"
336
- alternative_provider = "macOS"
337
- elif "macOS" in provider:
338
- provider_label = "macOS TTS"
339
- alternative_provider = "Piper"
340
- elif "Termux" in provider or "Android" in provider:
341
- provider_label = "Termux SSH (Android)"
342
- alternative_provider = "Piper"
343
- else:
344
- provider_label = "TTS"
345
- alternative_provider = None
346
-
347
- output = f"🎤 Available {provider_label} Voices:\n"
348
- output += f"{self.SEPARATOR}\n"
349
- for voice in voices:
350
- marker = " ✓ (current)" if voice == current_voice else ""
351
- output += f" • {voice}{marker}\n"
352
- output += f"{self.SEPARATOR}\n"
353
-
354
- # Add provider switch hint
355
- if alternative_provider:
356
- output += f"\n💡 Switch to {alternative_provider}? Use: set_provider(provider=\"{alternative_provider.lower()}\")\n"
357
-
358
- return output
359
- return "❌ Failed to list voices"
360
-
361
- async def set_voice(self, voice_name: str) -> str:
362
- """
363
- Switch to a different voice (supports friendly names like "ryan" or "katherine").
364
-
365
- Args:
366
- voice_name: Friendly name (e.g., "ryan") or Piper voice ID
367
-
368
- Returns:
369
- Success or error message
370
- """
371
- # Resolve friendly name to Piper ID
372
- original_name = voice_name
373
- resolved_name = self._resolve_friendly_name(voice_name)
374
-
375
- result = await self._run_script(
376
- self.VOICE_MANAGER_SCRIPT, ["switch", resolved_name]
377
- )
378
-
379
- if result and ("[OK]" in result or "✅" in result):
380
- if original_name.lower() != resolved_name.lower():
381
- return f" Voice switched to: {original_name} ({resolved_name})"
382
- return f"✅ Voice switched to: {voice_name}"
383
- return f"❌ Failed to switch voice: {result}"
384
-
385
- async def list_personalities(self) -> str:
386
- """
387
- List all available personalities.
388
-
389
- Returns:
390
- Formatted list of personalities with descriptions
391
- """
392
- result = await self._run_script(self.PERSONALITY_MANAGER_SCRIPT, ["list"])
393
- return result if result else " Failed to list personalities"
394
-
395
- async def set_personality(self, personality: str) -> str:
396
- """
397
- Set the personality style for TTS messages.
398
-
399
- Args:
400
- personality: Personality name (e.g., "flirty", "sarcastic", "pirate")
401
-
402
- Returns:
403
- Success or error message
404
- """
405
- result = await self._run_script(
406
- self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
407
- )
408
- if result and "🎭" in result:
409
- return result
410
- return f"❌ Failed to set personality: {result}"
411
-
412
- async def get_config(self) -> str:
413
- """
414
- Get current AgentVibes configuration.
415
-
416
- Returns:
417
- Current voice, personality, language, provider, and LLM settings
418
- """
419
- import re as _re
420
- voice = await self._get_current_voice()
421
- personality = await self._get_personality()
422
- language = await self._get_language()
423
- provider = await self._get_provider()
424
-
425
- # Resolve the LLM key using the same priority as text_to_speech:
426
- # 1. AGENTVIBES_LLM 2. CLAUDECODE=1 3. AGENTVIBES_MCP_FALLBACK 4. "default"
427
- llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
428
- if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
429
- llm_key = ""
430
- if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
431
- llm_key = "claude-code"
432
- if not llm_key:
433
- fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
434
- if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
435
- llm_key = fallback
436
- if not llm_key:
437
- llm_key = "default"
438
-
439
- output = "🎤 Current AgentVibes Configuration\n"
440
- output += f"{self.SEPARATOR}\n"
441
- output += f"LLM: {llm_key}\n"
442
- output += f"Provider: {provider}\n"
443
- output += f"Voice: {voice}\n"
444
- output += f"Personality: {personality}\n"
445
- output += f"Language: {language}\n"
446
- output += f"{self.SEPARATOR}\n"
447
- return output
448
-
449
- async def set_language(self, language: str) -> str:
450
- """
451
- Set the language for TTS speech.
452
-
453
- Args:
454
- language: Language name (e.g., "spanish", "french", "german")
455
-
456
- Returns:
457
- Success or error message
458
- """
459
- result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
460
- if result and "✓" in result:
461
- return result
462
- return f"❌ Failed to set language: {result}"
463
-
464
- async def replay_audio(self, n: int = 1) -> str:
465
- """
466
- Replay recently generated TTS audio.
467
-
468
- Args:
469
- n: Which audio to replay (1 = most recent, 2 = second most recent, etc.)
470
-
471
- Returns:
472
- Success or error message
473
- """
474
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["replay", str(n)])
475
- if result and "🔊" in result:
476
- return result
477
- return f"❌ Failed to replay audio: {result}"
478
-
479
- async def set_provider(self, provider: str) -> str:
480
- """
481
- Switch TTS provider between Piper, macOS, and Termux SSH.
482
-
483
- Args:
484
- provider: Provider name ("piper", "macos", or "termux-ssh")
485
-
486
- Returns:
487
- Success or error message
488
- """
489
- provider = provider.lower()
490
- if self.is_windows:
491
- valid_providers = ["piper", "sapi", "soprano"]
492
- else:
493
- valid_providers = ["piper", "macos", "termux-ssh", "soprano"]
494
- if provider not in valid_providers:
495
- return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
496
-
497
- result = await self._run_script("provider-manager.sh", ["switch", provider])
498
- if result and ("✓" in result or "[OK]" in result):
499
- # Automatically speak confirmation in the new provider's voice
500
- provider_names = {
501
- "macos": "macOS",
502
- "termux-ssh": "Termux SSH",
503
- "piper": "Piper",
504
- "sapi": "Windows SAPI",
505
- "soprano": "Soprano",
506
- }
507
- provider_name = provider_names.get(provider, provider.title())
508
- confirmation_text = f"Successfully switched to {provider_name} provider"
509
-
510
- try:
511
- # Speak the confirmation with 5 second timeout to prevent hanging
512
- await asyncio.wait_for(
513
- self.text_to_speech(confirmation_text),
514
- timeout=5.0
515
- )
516
- # Return the provider switch result plus TTS confirmation
517
- return f"{result}\n🔊 Spoken confirmation: {confirmation_text}"
518
- except asyncio.TimeoutError:
519
- # Timeout - provider may need setup (e.g., Piper not installed)
520
- return f"{result}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
521
- except Exception as e:
522
- # If TTS fails, still return success for the provider switch
523
- return f"{result}\n⚠️ Provider switched but TTS confirmation failed: {e}"
524
-
525
- return f"❌ Failed to switch provider: {result}"
526
-
527
- async def set_learn_mode(self, enabled: bool) -> str:
528
- """
529
- Enable or disable language learning mode.
530
-
531
- When enabled, TTS speaks in both your main language and target language.
532
-
533
- Args:
534
- enabled: True to enable, False to disable
535
-
536
- Returns:
537
- Success or error message
538
- """
539
- action = "enable" if enabled else "disable"
540
- result = await self._run_script("learn-manager.sh", [action])
541
- if result and "✓" in result:
542
- return result
543
- return f"❌ Failed to set learn mode: {result}"
544
-
545
- async def set_speed(self, speed: str, target: bool = False) -> str:
546
- """
547
- Set speech speed for main or target voice.
548
-
549
- Works with both Piper and macOS providers.
550
-
551
- Args:
552
- speed: Speed value (e.g., "0.5x", "1x", "2x", "normal", "fast", "slow")
553
- target: If True, sets target language speed; if False, sets main voice speed
554
-
555
- Returns:
556
- Success or error message
557
- """
558
- # Security: Using secrets.choice for cryptographically secure random selection
559
- # Even though this is just for UI variety, we use secrets to satisfy security scanners
560
- import secrets
561
-
562
- args = ["target", speed] if target else [speed]
563
- result = await self._run_script("speed-manager.sh", args)
564
- if result and "✓" in result:
565
- # Simple test messages to demonstrate the new speed
566
- test_messages = [
567
- "Testing speed change",
568
- "Speed test in progress",
569
- "Checking audio speed",
570
- "Speed configuration test",
571
- "Audio speed test",
572
- ]
573
-
574
- # Pick a random test message and speak it
575
- test_message = secrets.choice(test_messages)
576
-
577
- try:
578
- # Speak the test message to demonstrate the new speed
579
- await self.text_to_speech(test_message)
580
- return f"{result}\n🔊 Testing new speed: \"{test_message}\""
581
- except Exception as e:
582
- # If TTS fails, still return success for the speed change
583
- return f"{result}\n⚠️ Speed changed but demo failed: {e}"
584
-
585
- return f"❌ Failed to set speed: {result}"
586
-
587
- async def get_speed(self) -> str:
588
- """
589
- Get current speech speed settings.
590
-
591
- Returns:
592
- Current speed settings for main and target voices
593
- """
594
- result = await self._run_script("speed-manager.sh", ["get"])
595
- return result if result else "❌ Failed to get speed settings"
596
-
597
- async def download_extra_voices(self, auto_yes: bool = False) -> str:
598
- """
599
- Download extra high-quality Piper voices from HuggingFace.
600
-
601
- Downloads custom voices: Kristin, Jenny, and Tracy/16Speakers.
602
-
603
- Args:
604
- auto_yes: If True, skips confirmation prompt and downloads automatically
605
-
606
- Returns:
607
- Success message with download summary
608
- """
609
- args = ["--yes"] if auto_yes else []
610
- result = await self._run_script("download-extra-voices.sh", args)
611
- if result and ("✅" in result or "Successfully downloaded" in result or "already downloaded" in result):
612
- return result
613
- return f"❌ Failed to download extra voices: {result}"
614
-
615
- async def get_verbosity(self) -> str:
616
- """
617
- Get current verbosity level.
618
-
619
- Returns:
620
- Current verbosity level with description
621
- """
622
- result = await self._run_script("verbosity-manager.sh", ["get"])
623
- if result:
624
- level = result.strip()
625
- descriptions = {
626
- "low": "LOW - Acknowledgments + Completions only (minimal)",
627
- "medium": "MEDIUM - + Major decisions and findings (balanced)",
628
- "high": "HIGH - All reasoning (maximum transparency)"
629
- }
630
- desc = descriptions.get(level, level)
631
- return f"🎙️ Current Verbosity: {desc}\n\n💡 Change with: set_verbosity(level=\"low|medium|high\")"
632
- return "❌ Failed to get verbosity level"
633
-
634
- async def set_verbosity(self, level: str) -> str:
635
- """
636
- Set verbosity level to control how much Claude speaks.
637
-
638
- Args:
639
- level: Verbosity level (low, medium, or high)
640
-
641
- Returns:
642
- Success or error message
643
- """
644
- result = await self._run_script("verbosity-manager.sh", ["set", level])
645
- if result and "✅" in result:
646
- return f"{result}\n\n⚠️ Restart Claude Code for changes to take effect"
647
- return f"❌ Failed to set verbosity: {result}"
648
-
649
- def _get_mute_files(self) -> list:
650
- """Get all mute file paths for current platform"""
651
- files = [
652
- Path.home() / self.MUTE_FILE_NAME,
653
- Path.cwd() / self.CLAUDE_DIR_NAME / "agentvibes-muted",
654
- ]
655
- # Windows PowerShell scripts check tts-muted.txt in .claude dir
656
- if self.is_windows:
657
- files.append(Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt")
658
- return files
659
-
660
- async def mute(self) -> str:
661
- """
662
- Mute all TTS output. Creates a persistent mute flag.
663
-
664
- Returns:
665
- Success message confirming mute is active
666
- """
667
- try:
668
- mute_file = Path.home() / self.MUTE_FILE_NAME
669
- mute_file.touch()
670
- # On Windows, also write tts-muted.txt for PowerShell script compatibility
671
- if self.is_windows:
672
- win_mute = Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt"
673
- win_mute.parent.mkdir(parents=True, exist_ok=True)
674
- win_mute.write_text("true")
675
- return "🔇 AgentVibes TTS muted. All voice output is now silenced.\n\n💡 To unmute, use: unmute()"
676
- except Exception as e:
677
- return f"❌ Failed to mute: {e}"
678
-
679
- async def unmute(self) -> str:
680
- """
681
- Unmute TTS output. Removes the mute flag.
682
-
683
- Returns:
684
- Success message confirming TTS is restored
685
- """
686
- removed = []
687
- try:
688
- for mute_file in self._get_mute_files():
689
- if mute_file.exists():
690
- # tts-muted.txt uses content "true"/"false", others use file existence
691
- if mute_file.name == "tts-muted.txt":
692
- content = mute_file.read_text().strip()
693
- if content == "true":
694
- mute_file.write_text("false")
695
- removed.append(str(mute_file.name))
696
- else:
697
- mute_file.unlink()
698
- removed.append(str(mute_file.name))
699
-
700
- if removed:
701
- return f"🔊 AgentVibes TTS unmuted. Voice output is now restored.\n (Removed: {', '.join(removed)} mute flag)"
702
- else:
703
- return "🔊 AgentVibes TTS was not muted. Voice output is active."
704
- except Exception as e:
705
- return f"❌ Failed to unmute: {e}"
706
-
707
- async def is_muted(self) -> str:
708
- """
709
- Check if TTS is currently muted.
710
-
711
- Returns:
712
- Current mute status
713
- """
714
- for mute_file in self._get_mute_files():
715
- if mute_file.exists():
716
- # tts-muted.txt uses content "true"/"false"
717
- if mute_file.name == "tts-muted.txt":
718
- content = mute_file.read_text().strip()
719
- if content == "true":
720
- return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
721
- else:
722
- return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
723
- return "🔊 TTS is currently ACTIVE\n\n💡 To mute, use: mute()"
724
-
725
- async def list_background_music(self) -> str:
726
- """
727
- List all available background music tracks.
728
-
729
- Returns:
730
- Formatted list of all pre-packaged background music files
731
- """
732
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
733
- return result if result else "❌ Failed to list background music"
734
-
735
- async def set_background_music(self, track_name: str, agent_name: Optional[str] = None) -> str:
736
- """
737
- Set background music track for a specific agent, all agents, or as default.
738
-
739
- Args:
740
- track_name: Track filename or partial name for fuzzy matching
741
- agent_name: Agent name ('all' for all agents, None for default)
742
-
743
- Returns:
744
- Success or error message
745
- """
746
- import re
747
-
748
- # Get list of available tracks for fuzzy matching
749
- list_result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
750
- if not list_result or "❌" in list_result:
751
- return "❌ Failed to list background music tracks"
752
-
753
- # Parse track names
754
- tracks = []
755
- for line in list_result.split("\n"):
756
- match = re.match(r'\s*\d+\.\s+(.+)', line.strip())
757
- if match:
758
- tracks.append(match.group(1).strip())
759
-
760
- # Try to find a matching track (case-insensitive partial match)
761
- track_lower = track_name.lower()
762
- matched_track = None
763
-
764
- # First try exact match
765
- for track in tracks:
766
- if track.lower() == track_lower:
767
- matched_track = track
768
- break
769
-
770
- # If no exact match, try partial match
771
- if not matched_track:
772
- for track in tracks:
773
- if track_lower in track.lower():
774
- matched_track = track
775
- break
776
-
777
- if not matched_track:
778
- # Show available tracks to help user
779
- available = "\n".join([f" • {t}" for t in tracks])
780
- return f"❌ No track matching '{track_name}' found.\n\nAvailable tracks:\n{available}\n\n💡 Try a partial match like 'celtic' or 'chillwave'"
781
-
782
- # Determine which command to use based on agent_name
783
- if agent_name and agent_name.lower() == "all":
784
- # Set for all agents
785
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-all", matched_track])
786
- elif agent_name:
787
- # Set for specific agent
788
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-agent", agent_name, matched_track])
789
- else:
790
- # Set as default
791
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-default", matched_track])
792
-
793
- if result and ("✅" in result or "[OK]" in result):
794
- if matched_track.lower() != track_name.lower():
795
- return f"{result}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
796
- return result
797
- return f"❌ Failed to set background music: {result}"
798
-
799
- async def enable_background_music(self, enabled: bool) -> str:
800
- """
801
- Enable or disable background music globally.
802
-
803
- Args:
804
- enabled: True to enable, False to disable
805
-
806
- Returns:
807
- Success or error message
808
- """
809
- command = "on" if enabled else "off"
810
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, [command])
811
- # Sync to .agentvibes/config.json (TUI source of truth)
812
- try:
813
- import json
814
- cfg_path = self.agentvibes_root / ".agentvibes" / "config.json"
815
- cfg = {}
816
- if cfg_path.exists():
817
- cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
818
- if "backgroundMusic" not in cfg:
819
- cfg["backgroundMusic"] = {}
820
- cfg["backgroundMusic"]["enabled"] = enabled
821
- cfg_path.parent.mkdir(parents=True, exist_ok=True)
822
- cfg_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
823
- except Exception:
824
- pass # best-effort sync
825
- return result if result else f"❌ Failed to {'enable' if enabled else 'disable'} background music"
826
-
827
- async def set_background_music_volume(self, volume: float) -> str:
828
- """
829
- Set background music volume.
830
-
831
- Args:
832
- volume: Volume level (0.0-1.0)
833
-
834
- Returns:
835
- Success or error message
836
- """
837
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["volume", str(volume)])
838
- return result if result else "❌ Failed to set background music volume"
839
-
840
- async def get_background_music_status(self) -> str:
841
- """
842
- Get current background music configuration.
843
-
844
- Returns:
845
- Status information
846
- """
847
- result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["status"])
848
- return result if result else "❌ Failed to get background music status"
849
-
850
- async def set_reverb(self, level: str, agent: str = "default", apply_all: bool = False) -> str:
851
- """
852
- Set reverb level for an agent or globally.
853
-
854
- Args:
855
- level: Reverb level (off, light, medium, heavy, cathedral)
856
- agent: Agent name (default: "default")
857
- apply_all: Apply to all agents (default: False)
858
-
859
- Returns:
860
- Success message
861
- """
862
- args = ["set-reverb", level, agent]
863
- if apply_all:
864
- args.append("--all")
865
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, args)
866
- return result if result else f" Set reverb to {level}"
867
-
868
- async def get_reverb(self, agent: str = "default") -> str:
869
- """
870
- Get current reverb level for an agent.
871
-
872
- Args:
873
- agent: Agent name (default: "default")
874
-
875
- Returns:
876
- Current reverb level
877
- """
878
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["get-reverb", agent])
879
- if result:
880
- return f"Current reverb level for {agent}: {result.strip()}"
881
- return f"❌ Failed to get reverb for {agent}"
882
-
883
- async def list_audio_effects(self) -> str:
884
- """
885
- List all audio effects for all agents.
886
-
887
- Returns:
888
- Effects configuration
889
- """
890
- result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["list"])
891
- return result if result else "❌ Failed to list audio effects"
892
-
893
- async def clean_audio_cache(self) -> str:
894
- """
895
- Clean all TTS audio cache files and report space freed.
896
-
897
- Non-interactive cleanup suitable for MCP tool usage. Deletes all
898
- TTS-generated audio files (wav, mp3, aiff) while preserving
899
- background music tracks.
900
-
901
- Returns:
902
- Cleanup results with file count and space freed
903
- """
904
- result = await self._run_script("clean-audio-cache.sh", [])
905
- return result if result else "❌ Failed to clean audio cache"
906
-
907
- async def set_banner(self, enabled: bool) -> str:
908
- """
909
- Enable or disable the TTS output banner (voice info, file path, cache size).
910
-
911
- Args:
912
- enabled: True to show banner, False to hide it
913
-
914
- Returns:
915
- Confirmation message
916
- """
917
- banner_file = Path.home() / ".agentvibes" / "banner-disabled"
918
- if enabled:
919
- # Remove the disable flag
920
- try:
921
- banner_file.unlink(missing_ok=True)
922
- except Exception:
923
- pass
924
- return "✅ TTS banner enabled — voice info will show after each speech"
925
- else:
926
- # Create the disable flag
927
- banner_file.parent.mkdir(parents=True, exist_ok=True)
928
- banner_file.touch()
929
- return "🔇 TTS banner disabled — speech will play without output info"
930
-
931
- async def get_banner(self) -> str:
932
- """
933
- Check if the TTS output banner is enabled or disabled.
934
-
935
- Returns:
936
- Current banner status
937
- """
938
- banner_file = Path.home() / ".agentvibes" / "banner-disabled"
939
- if banner_file.exists():
940
- return "🔇 TTS banner: **disabled**\n\nSay: \"Turn on banner\" to re-enable"
941
- return "✅ TTS banner: **enabled**\n\nSay: \"Turn off banner\" to disable"
942
-
943
- # Helper methods
944
- def _resolve_project_dir(self) -> Optional[Path]:
945
- # Returns the nearest directory containing .claude/, or None.
946
- # Checked in order: explicit CLAUDE_PROJECT_DIR, npm's INIT_CWD,
947
- # shell PWD, os.getcwd() and its parents. Required because MCP
948
- # clients (VS Code Copilot, Codex, Warp, etc.) don't agree on
949
- # what cwd to spawn the server from, so relying on os.getcwd()
950
- # alone silently falls back to package config — which breaks
951
- # project-local per-LLM routing in audio-effects.cfg.
952
- candidates: list[Path] = []
953
- for var in ("CLAUDE_PROJECT_DIR", "INIT_CWD", "PWD"):
954
- val = os.environ.get(var, "").strip()
955
- if val:
956
- try:
957
- candidates.append(Path(val).resolve())
958
- except (OSError, ValueError):
959
- pass
960
- try:
961
- cwd = Path.cwd().resolve()
962
- candidates.append(cwd)
963
- candidates.extend(cwd.parents)
964
- except (OSError, ValueError):
965
- pass
966
-
967
- try:
968
- agentvibes_root = self.agentvibes_root.resolve()
969
- except (OSError, ValueError):
970
- agentvibes_root = self.agentvibes_root
971
-
972
- seen: set[Path] = set()
973
- for candidate in candidates:
974
- if candidate in seen:
975
- continue
976
- seen.add(candidate)
977
- if candidate == agentvibes_root:
978
- continue
979
- try:
980
- if (candidate / ".claude").is_dir():
981
- return candidate
982
- except OSError:
983
- continue
984
- return None
985
-
986
- def _build_script_env(self) -> dict:
987
- """Build environment dict for script execution (shared by all script runners)"""
988
- env = os.environ.copy()
989
-
990
- # Export CLAUDE_PROJECT_DIR so play-tts.{ps1,sh} reads the
991
- # project's .claude/config/audio-effects.cfg (per-LLM routing,
992
- # pretext, effects) instead of the package's bundled copy.
993
- project_dir = self._resolve_project_dir()
994
- if project_dir is not None:
995
- env["CLAUDE_PROJECT_DIR"] = str(project_dir)
996
-
997
- # Add common locations for piper to PATH (Unix only)
998
- if not self.is_windows:
999
- home_dir = Path.home()
1000
- local_bin = str(home_dir / ".local" / "bin")
1001
- if "PATH" in env:
1002
- if local_bin not in env["PATH"]:
1003
- env["PATH"] = f"{local_bin}:{env['PATH']}"
1004
- else:
1005
- env["PATH"] = local_bin
1006
-
1007
- return env
1008
-
1009
- async def _run_script(self, script_name: str, args: list[str]) -> str:
1010
- """Run a script and return output (bash on Unix, PowerShell on Windows)"""
1011
- # Auto-resolve .sh → .ps1 on Windows (class constants handle special cases)
1012
- if self.is_windows and script_name.endswith('.sh'):
1013
- script_name = script_name[:-3] + '.ps1'
1014
- script_path = self.hooks_dir / script_name
1015
- if not script_path.exists():
1016
- return f"Script not found: {script_path}"
1017
-
1018
- # Build command — PowerShell on Windows, bash on Unix
1019
- if self.is_windows:
1020
- cmd = [
1021
- "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
1022
- "-File", str(script_path)
1023
- ] + args
1024
- else:
1025
- cmd = ["bash", str(script_path)] + args
1026
-
1027
- env = self._build_script_env()
1028
-
1029
- try:
1030
- result = await asyncio.create_subprocess_exec(
1031
- *cmd,
1032
- stdout=asyncio.subprocess.PIPE,
1033
- stderr=asyncio.subprocess.PIPE,
1034
- env=env,
1035
- )
1036
- try:
1037
- stdout, stderr = await result.communicate()
1038
- if result.returncode == 0:
1039
- return stdout.decode().strip()
1040
- else:
1041
- error_msg = stderr.decode().strip()
1042
- if not error_msg: # If stderr is empty, include stdout for debugging
1043
- error_msg = f"Return code {result.returncode}. Stdout: {stdout.decode().strip()}"
1044
- return error_msg
1045
- finally:
1046
- # Ensure process cleanup
1047
- if result.returncode is None:
1048
- result.kill()
1049
- await result.wait()
1050
- except Exception as e:
1051
- return f"Error running script: {e}"
1052
-
1053
- async def _get_current_voice(self) -> str:
1054
- """Get the currently active voice"""
1055
- result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["get"])
1056
- return result.strip() if result else "Unknown"
1057
-
1058
- async def _get_personality(self) -> str:
1059
- """Get the current personality setting"""
1060
- personality_file = self.claude_dir / "tts-personality.txt"
1061
- if not personality_file.exists():
1062
- # Try global
1063
- personality_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-personality.txt"
1064
-
1065
- try:
1066
- if personality_file.exists():
1067
- return personality_file.read_text().strip()
1068
- except (PermissionError, UnicodeDecodeError, OSError) as e:
1069
- # Log error but don't crash - return default
1070
- import sys
1071
- print(f"Warning: Could not read personality file: {e}", file=sys.stderr)
1072
- return "normal"
1073
-
1074
- async def _get_language(self) -> str:
1075
- """Get the current language setting"""
1076
- result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["code"])
1077
- return result.strip() if result else "english"
1078
-
1079
- async def _get_provider(self) -> str:
1080
- """Get the active TTS provider"""
1081
- provider_file = self.claude_dir / "tts-provider.txt"
1082
- if not provider_file.exists():
1083
- provider_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-provider.txt"
1084
-
1085
- provider_labels = {
1086
- "macos": "macOS TTS",
1087
- "piper": "Piper TTS (Free, Offline)",
1088
- "termux-ssh": "Termux SSH (Android)",
1089
- "sapi": "Windows SAPI (Built-in)",
1090
- "soprano": "Soprano TTS (Ultra-fast Neural)",
1091
- }
1092
- try:
1093
- if provider_file.exists():
1094
- provider = provider_file.read_text().strip()
1095
- # Strip BOM from PowerShell-written files
1096
- provider = provider.lstrip('\ufeff')
1097
- return provider_labels.get(provider, provider)
1098
- except (PermissionError, UnicodeDecodeError, OSError) as e:
1099
- # Log error but don't crash - return default
1100
- import sys
1101
- print(f"Warning: Could not read provider file: {e}", file=sys.stderr)
1102
- # Default based on platform
1103
- if self.is_windows:
1104
- return "Windows SAPI (Built-in)"
1105
- return "Piper TTS (Free, Offline)"
1106
-
1107
-
1108
- # Create the MCP server
1109
- app = Server("agentvibes")
1110
- agent_vibes = AgentVibesServer()
1111
-
1112
-
1113
- @app.list_tools()
1114
- async def list_tools() -> list[Tool]:
1115
- """List all available AgentVibes tools"""
1116
- return [
1117
- Tool(
1118
- name="text_to_speech",
1119
- description="""Convert text to speech using AgentVibes TTS.
1120
-
1121
- Supports both macOS TTS and Piper (free, offline) providers.
1122
- Can use different voices, personalities, and languages.
1123
-
1124
- Perfect for:
1125
- - Speaking acknowledgments and confirmations
1126
- - Adding voice to Claude responses
1127
- - Multi-language communication
1128
- - Personality-driven interactions
1129
-
1130
- Examples:
1131
- - text_to_speech(text="Hello, I'm ready to help!")
1132
- - text_to_speech(text="Task completed!", personality="flirty")
1133
- - text_to_speech(text="Hola, ¿cómo estás?", language="spanish")
1134
- """,
1135
- inputSchema={
1136
- "type": "object",
1137
- "properties": {
1138
- "text": {
1139
- "type": "string",
1140
- "description": "Text to convert to speech (max 500 characters)",
1141
- },
1142
- "voice": {
1143
- "type": "string",
1144
- "description": "Voice name (optional). Use list_voices to see options.",
1145
- },
1146
- "personality": {
1147
- "type": "string",
1148
- "description": "Personality style (optional). Examples: flirty, sarcastic, pirate, robot, zen",
1149
- },
1150
- "language": {
1151
- "type": "string",
1152
- "description": "Language to speak in (optional). Examples: spanish, french, german, italian",
1153
- },
1154
- },
1155
- "required": ["text"],
1156
- },
1157
- ),
1158
- Tool(
1159
- name="list_voices",
1160
- description="List all available TTS voices with current selection",
1161
- inputSchema={"type": "object", "properties": {}},
1162
- ),
1163
- Tool(
1164
- name="set_voice",
1165
- description="Switch to a different TTS voice",
1166
- inputSchema={
1167
- "type": "object",
1168
- "properties": {
1169
- "voice_name": {
1170
- "type": "string",
1171
- "description": "Name of the voice to switch to",
1172
- }
1173
- },
1174
- "required": ["voice_name"],
1175
- },
1176
- ),
1177
- Tool(
1178
- name="list_personalities",
1179
- description="List all available personality styles with descriptions",
1180
- inputSchema={"type": "object", "properties": {}},
1181
- ),
1182
- Tool(
1183
- name="set_personality",
1184
- description="Set the personality style for TTS messages",
1185
- inputSchema={
1186
- "type": "object",
1187
- "properties": {
1188
- "personality": {
1189
- "type": "string",
1190
- "description": "Personality name (e.g., flirty, sarcastic, pirate)",
1191
- }
1192
- },
1193
- "required": ["personality"],
1194
- },
1195
- ),
1196
- Tool(
1197
- name="set_language",
1198
- description="Set the language for TTS speech (supports 25+ languages)",
1199
- inputSchema={
1200
- "type": "object",
1201
- "properties": {
1202
- "language": {
1203
- "type": "string",
1204
- "description": "Language name (e.g., spanish, french, german)",
1205
- }
1206
- },
1207
- "required": ["language"],
1208
- },
1209
- ),
1210
- Tool(
1211
- name="get_config",
1212
- description="Get current voice, personality, language, and provider configuration",
1213
- inputSchema={"type": "object", "properties": {}},
1214
- ),
1215
- Tool(
1216
- name="replay_audio",
1217
- description="Replay recently generated TTS audio",
1218
- inputSchema={
1219
- "type": "object",
1220
- "properties": {
1221
- "n": {
1222
- "type": "integer",
1223
- "description": "Which audio to replay (1 = most recent, default: 1)",
1224
- "minimum": 1,
1225
- "maximum": 10,
1226
- }
1227
- },
1228
- },
1229
- ),
1230
- Tool(
1231
- name="set_provider",
1232
- description="Switch between TTS providers" + (
1233
- ": Windows Piper, Windows SAPI, or Soprano" if agent_vibes.is_windows
1234
- else ": macOS TTS, Piper (free, offline), Soprano, or Termux SSH (Android)"
1235
- ),
1236
- inputSchema={
1237
- "type": "object",
1238
- "properties": {
1239
- "provider": {
1240
- "type": "string",
1241
- "description": (
1242
- "Provider name: 'piper', 'sapi', or 'soprano'"
1243
- if agent_vibes.is_windows
1244
- else "Provider name: 'piper', 'macos', 'soprano', or 'termux-ssh'"
1245
- ),
1246
- "enum": (
1247
- ["piper", "sapi", "soprano"]
1248
- if agent_vibes.is_windows
1249
- else ["piper", "macos", "soprano", "termux-ssh"]
1250
- ),
1251
- }
1252
- },
1253
- "required": ["provider"],
1254
- },
1255
- ),
1256
- Tool(
1257
- name="set_learn_mode",
1258
- description="Enable or disable language learning mode. When ON, TTS speaks in both your main language and target language for bilingual learning.",
1259
- inputSchema={
1260
- "type": "object",
1261
- "properties": {
1262
- "enabled": {
1263
- "type": "boolean",
1264
- "description": "True to enable learning mode, False to disable"
1265
- }
1266
- },
1267
- "required": ["enabled"],
1268
- },
1269
- ),
1270
- Tool(
1271
- name="set_speed",
1272
- description="Set speech speed for main or target voice. Works with both Piper and macOS providers. Use this to make voices faster or slower.",
1273
- inputSchema={
1274
- "type": "object",
1275
- "properties": {
1276
- "speed": {
1277
- "type": "string",
1278
- "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)"
1279
- },
1280
- "target": {
1281
- "type": "boolean",
1282
- "description": "If true, sets target language speed (for learning mode); if false or omitted, sets main voice speed",
1283
- "default": False
1284
- }
1285
- },
1286
- "required": ["speed"],
1287
- },
1288
- ),
1289
- Tool(
1290
- name="get_speed",
1291
- description="Get current speech speed settings for main and target voices",
1292
- inputSchema={"type": "object", "properties": {}},
1293
- ),
1294
- Tool(
1295
- name="download_extra_voices",
1296
- description="Download extra high-quality custom Piper voices from HuggingFace. Includes: Kristin (US female), Jenny (UK female with Irish accent), and Tracy/16Speakers (multi-speaker). Perfect for adding variety to your TTS voices.",
1297
- inputSchema={
1298
- "type": "object",
1299
- "properties": {
1300
- "auto_yes": {
1301
- "type": "boolean",
1302
- "description": "Skip confirmation prompt and download automatically (default: False)",
1303
- "default": False
1304
- }
1305
- },
1306
- },
1307
- ),
1308
- Tool(
1309
- name="get_verbosity",
1310
- 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).",
1311
- inputSchema={"type": "object", "properties": {}},
1312
- ),
1313
- Tool(
1314
- name="set_verbosity",
1315
- description="""Set AgentVibes verbosity level to control how much Claude speaks while working.
1316
-
1317
- Verbosity Levels:
1318
- - LOW: Only acknowledgments (start) and completions (end). Minimal interruption.
1319
- - MEDIUM: + Major decisions and key findings. Balanced transparency.
1320
- - HIGH: All reasoning, decisions, and findings. Maximum transparency.
1321
- - CAVEMAN: Ultra-terse fragments. Drops articles, filler, hedging. Abbreviates heavily. 65-75% fewer output tokens.
1322
-
1323
- Perfect for:
1324
- - LOW: Quiet work sessions, minimal distraction
1325
- - MEDIUM: Understanding major decisions without full narration
1326
- - HIGH: Full transparency, learning mode, debugging complex tasks
1327
- - CAVEMAN: Maximum token savings, minimal prose
1328
-
1329
- Note: Changes take effect on next Claude Code session restart.""",
1330
- inputSchema={
1331
- "type": "object",
1332
- "properties": {
1333
- "level": {
1334
- "type": "string",
1335
- "description": "Verbosity level to set",
1336
- "enum": ["low", "medium", "high", "caveman"]
1337
- }
1338
- },
1339
- "required": ["level"],
1340
- },
1341
- ),
1342
- Tool(
1343
- name="mute",
1344
- description="Mute all AgentVibes TTS output. Creates a persistent mute flag that silences all voice output until unmuted. Persists across sessions.",
1345
- inputSchema={"type": "object", "properties": {}},
1346
- ),
1347
- Tool(
1348
- name="unmute",
1349
- description="Unmute AgentVibes TTS output. Removes the mute flag and restores voice output.",
1350
- inputSchema={"type": "object", "properties": {}},
1351
- ),
1352
- Tool(
1353
- name="is_muted",
1354
- description="Check if TTS is currently muted.",
1355
- inputSchema={"type": "object", "properties": {}},
1356
- ),
1357
- Tool(
1358
- name="list_background_music",
1359
- description="List all available pre-packaged background music tracks. Shows all audio files that can be used as background music for TTS.",
1360
- inputSchema={"type": "object", "properties": {}},
1361
- ),
1362
- Tool(
1363
- name="set_background_music",
1364
- description="""Set background music track for a specific agent, all agents, or as default. Supports smart fuzzy matching.
1365
-
1366
- Perfect for:
1367
- - "change background music to flamenco" - Sets for all agents
1368
- - "set John's background music to celtic harp" - Agent-specific
1369
- - "use chillwave as default background" - Default for new agents
1370
-
1371
- Fuzzy matching examples:
1372
- - "flamenco" matches "agentvibes_soft_flamenco_loop.mp3"
1373
- - "celtic" matches "agent_vibes_celtic_harp_v1_loop.mp3"
1374
- - "bossa" matches "agent_vibes_bossa_nova_v2_loop.mp3"
1375
- """,
1376
- inputSchema={
1377
- "type": "object",
1378
- "properties": {
1379
- "track_name": {
1380
- "type": "string",
1381
- "description": "Track filename or partial name for fuzzy matching (e.g., 'celtic', 'flamenco', 'bossa nova')",
1382
- },
1383
- "agent_name": {
1384
- "type": "string",
1385
- "description": "Agent name to configure (optional). Use 'all' for all agents, omit for default",
1386
- },
1387
- },
1388
- "required": ["track_name"],
1389
- },
1390
- ),
1391
- Tool(
1392
- name="enable_background_music",
1393
- description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 30%).",
1394
- inputSchema={
1395
- "type": "object",
1396
- "properties": {
1397
- "enabled": {
1398
- "type": "boolean",
1399
- "description": "True to enable background music, False to disable",
1400
- }
1401
- },
1402
- "required": ["enabled"],
1403
- },
1404
- ),
1405
- Tool(
1406
- name="set_background_music_volume",
1407
- description="Set the volume level for background music (0.0-1.0). Recommended: 0.20-0.40 for subtle background ambiance.",
1408
- inputSchema={
1409
- "type": "object",
1410
- "properties": {
1411
- "volume": {
1412
- "type": "number",
1413
- "description": "Volume level (0.0 = silent, 0.30 = default, 1.0 = full volume)",
1414
- "minimum": 0.0,
1415
- "maximum": 1.0,
1416
- }
1417
- },
1418
- "required": ["volume"],
1419
- },
1420
- ),
1421
- Tool(
1422
- name="get_background_music_status",
1423
- description="Get current background music configuration including enabled status, volume, default track, and number of available tracks.",
1424
- inputSchema={"type": "object", "properties": {}},
1425
- ),
1426
- Tool(
1427
- name="set_reverb",
1428
- description="""Set reverb level for TTS audio. Can apply globally (default agent), to a specific agent, or to all agents.
1429
-
1430
- Reverb adds room/space ambiance to the voice, making it sound like it's in a small room, conference room, or large hall.
1431
-
1432
- Examples:
1433
- - set_reverb(level="medium") - Set reverb for default agent
1434
- - set_reverb(level="cathedral", agent="Winston") - Set cathedral reverb for Winston
1435
- - set_reverb(level="light", apply_all=True) - Set light reverb for all agents
1436
- - set_reverb(level="off") - Turn off reverb for default agent
1437
- """,
1438
- inputSchema={
1439
- "type": "object",
1440
- "properties": {
1441
- "level": {
1442
- "type": "string",
1443
- "description": "Reverb level",
1444
- "enum": ["off", "light", "medium", "heavy", "cathedral"]
1445
- },
1446
- "agent": {
1447
- "type": "string",
1448
- "description": "Agent name (optional, defaults to 'default'). Examples: Winston, John, Mary, Amelia",
1449
- },
1450
- "apply_all": {
1451
- "type": "boolean",
1452
- "description": "Apply to all agents (optional, default: false)",
1453
- }
1454
- },
1455
- "required": ["level"],
1456
- },
1457
- ),
1458
- Tool(
1459
- name="get_reverb",
1460
- description="Get current reverb level for a specific agent or default",
1461
- inputSchema={
1462
- "type": "object",
1463
- "properties": {
1464
- "agent": {
1465
- "type": "string",
1466
- "description": "Agent name (optional, defaults to 'default')",
1467
- }
1468
- },
1469
- },
1470
- ),
1471
- Tool(
1472
- name="list_audio_effects",
1473
- description="List current audio effects configuration for all agents, including reverb levels and other effects",
1474
- inputSchema={"type": "object", "properties": {}},
1475
- ),
1476
- Tool(
1477
- name="clean_audio_cache",
1478
- 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.",
1479
- inputSchema={"type": "object", "properties": {}},
1480
- ),
1481
- ]
1482
-
1483
-
1484
- @app.call_tool()
1485
- async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1486
- """Handle tool calls"""
1487
- try:
1488
- if name == "text_to_speech":
1489
- result = await agent_vibes.text_to_speech(
1490
- text=arguments["text"],
1491
- voice=arguments.get("voice"),
1492
- personality=arguments.get("personality"),
1493
- language=arguments.get("language"),
1494
- )
1495
- elif name == "list_voices":
1496
- result = await agent_vibes.list_voices()
1497
- elif name == "set_voice":
1498
- result = await agent_vibes.set_voice(arguments["voice_name"])
1499
- elif name == "list_personalities":
1500
- result = await agent_vibes.list_personalities()
1501
- elif name == "set_personality":
1502
- result = await agent_vibes.set_personality(arguments["personality"])
1503
- elif name == "set_language":
1504
- result = await agent_vibes.set_language(arguments["language"])
1505
- elif name == "get_config":
1506
- result = await agent_vibes.get_config()
1507
- elif name == "replay_audio":
1508
- n = arguments.get("n", 1)
1509
- result = await agent_vibes.replay_audio(n)
1510
- elif name == "set_provider":
1511
- result = await agent_vibes.set_provider(arguments["provider"])
1512
- elif name == "set_learn_mode":
1513
- result = await agent_vibes.set_learn_mode(arguments["enabled"])
1514
- elif name == "set_speed":
1515
- target = arguments.get("target", False)
1516
- result = await agent_vibes.set_speed(arguments["speed"], target)
1517
- elif name == "get_speed":
1518
- result = await agent_vibes.get_speed()
1519
- elif name == "download_extra_voices":
1520
- auto_yes = arguments.get("auto_yes", False)
1521
- result = await agent_vibes.download_extra_voices(auto_yes)
1522
- elif name == "get_verbosity":
1523
- result = await agent_vibes.get_verbosity()
1524
- elif name == "set_verbosity":
1525
- result = await agent_vibes.set_verbosity(arguments["level"])
1526
- elif name == "mute":
1527
- result = await agent_vibes.mute()
1528
- elif name == "unmute":
1529
- result = await agent_vibes.unmute()
1530
- elif name == "is_muted":
1531
- result = await agent_vibes.is_muted()
1532
- elif name == "list_background_music":
1533
- result = await agent_vibes.list_background_music()
1534
- elif name == "set_background_music":
1535
- track_name = arguments.get("track_name")
1536
- agent_name = arguments.get("agent_name")
1537
- result = await agent_vibes.set_background_music(track_name, agent_name)
1538
- elif name == "enable_background_music":
1539
- enabled = arguments.get("enabled")
1540
- result = await agent_vibes.enable_background_music(enabled)
1541
- elif name == "set_background_music_volume":
1542
- volume = arguments.get("volume")
1543
- result = await agent_vibes.set_background_music_volume(volume)
1544
- elif name == "get_background_music_status":
1545
- result = await agent_vibes.get_background_music_status()
1546
- elif name == "set_reverb":
1547
- level = arguments["level"]
1548
- agent = arguments.get("agent", "default")
1549
- apply_all = arguments.get("apply_all", False)
1550
- result = await agent_vibes.set_reverb(level, agent, apply_all)
1551
- elif name == "get_reverb":
1552
- agent = arguments.get("agent", "default")
1553
- result = await agent_vibes.get_reverb(agent)
1554
- elif name == "list_audio_effects":
1555
- result = await agent_vibes.list_audio_effects()
1556
- elif name == "clean_audio_cache":
1557
- result = await agent_vibes.clean_audio_cache()
1558
- else:
1559
- result = f"Unknown tool: {name}"
1560
-
1561
- return [TextContent(type="text", text=result)]
1562
-
1563
- except Exception as e:
1564
- return [TextContent(type="text", text=f"Error: {str(e)}")]
1565
-
1566
-
1567
- async def main():
1568
- """Run the MCP server"""
1569
- async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
1570
- await app.run(
1571
- read_stream,
1572
- write_stream,
1573
- app.create_initialization_options(),
1574
- )
1575
-
1576
-
1577
- if __name__ == "__main__":
1578
- 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 subprocess
48
+ from pathlib import Path
49
+ from typing import Optional
50
+
51
+ from mcp.server import Server
52
+ from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
53
+ import mcp.server.stdio
54
+ class AgentVibesServer:
55
+ """MCP Server for AgentVibes TTS functionality"""
56
+
57
+ # Script name constants (addresses SonarCloud S1192)
58
+ VOICE_MANAGER_SCRIPT = "voice-manager.sh"
59
+ PERSONALITY_MANAGER_SCRIPT = "personality-manager.sh"
60
+ LANGUAGE_MANAGER_SCRIPT = "language-manager.sh"
61
+ BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.sh"
62
+ EFFECTS_MANAGER_SCRIPT = "effects-manager.sh"
63
+
64
+ # Path constants (addresses SonarCloud S1192)
65
+ CLAUDE_DIR_NAME = ".claude"
66
+ MUTE_FILE_NAME = ".agentvibes-muted"
67
+ SEPARATOR = "━" * 39
68
+
69
+ def __init__(self):
70
+ """Initialize the AgentVibes MCP server"""
71
+ # Detect native Windows (not WSL)
72
+ self.is_windows = platform.system() == "Windows" and not os.environ.get("WSL_DISTRO_NAME")
73
+
74
+ # Script name constants — Windows uses .ps1, Unix uses .sh
75
+ if self.is_windows:
76
+ self.VOICE_MANAGER_SCRIPT = "voice-manager-windows.ps1"
77
+ self.PERSONALITY_MANAGER_SCRIPT = "personality-manager.ps1"
78
+ self.LANGUAGE_MANAGER_SCRIPT = "language-manager.ps1"
79
+ self.BACKGROUND_MUSIC_MANAGER_SCRIPT = "background-music-manager.ps1"
80
+ self.EFFECTS_MANAGER_SCRIPT = "effects-manager.ps1"
81
+
82
+ # Find the .claude directory (project-local or global)
83
+ self.claude_dir = self._find_claude_dir()
84
+ self.hooks_dir = self.claude_dir / ("hooks-windows" if self.is_windows else "hooks")
85
+ # Store AgentVibes root directory for environment variable
86
+ self.agentvibes_root = self.claude_dir.parent
87
+
88
+ def _find_claude_dir(self) -> Path:
89
+ """Find the .claude directory relative to this script"""
90
+ # Get the AgentVibes root directory (parent of mcp-server)
91
+ script_dir = Path(__file__).resolve().parent # mcp-server/
92
+ agentvibes_root = script_dir.parent # AgentVibes/
93
+ claude_dir = agentvibes_root / self.CLAUDE_DIR_NAME
94
+
95
+ # ALWAYS use package .claude for hooks (even in NPX cache)
96
+ # The package ALWAYS has .claude/ with all the hooks
97
+ if claude_dir.exists() and claude_dir.is_dir():
98
+ return claude_dir
99
+
100
+ # Fallback to global ~/.claude (should never happen in properly installed package)
101
+ return Path.home() / self.CLAUDE_DIR_NAME
102
+
103
+ def _resolve_friendly_name(self, voice_name: str) -> str:
104
+ """
105
+ Resolve friendly name to Piper voice ID using voice-metadata.json.
106
+
107
+ Args:
108
+ voice_name: Friendly name (e.g., "ryan") or Piper ID
109
+
110
+ Returns:
111
+ Resolved Piper voice ID, or original voice_name if not found
112
+ """
113
+ import re
114
+
115
+ metadata_path = self.agentvibes_root / ".agentvibes" / "config" / "voice-metadata.json"
116
+
117
+ # SECURITY: Verify file exists and is not a symlink
118
+ if not metadata_path.exists() or metadata_path.is_symlink():
119
+ return voice_name
120
+
121
+ # SECURITY: Verify file ownership matches current user (Unix only)
122
+ try:
123
+ if hasattr(os, 'getuid'):
124
+ stat_info = metadata_path.stat()
125
+ if stat_info.st_uid != os.getuid():
126
+ return voice_name
127
+ except (OSError, AttributeError):
128
+ pass
129
+
130
+ try:
131
+ with open(metadata_path, 'r') as f:
132
+ metadata = json.load(f)
133
+
134
+ voices = metadata.get('voices', {})
135
+ voice_lower = voice_name.lower()
136
+
137
+ resolved_id = None
138
+
139
+ # Check if it's a friendly name key
140
+ if voice_lower in voices:
141
+ resolved_id = voices[voice_lower].get('id')
142
+
143
+ # Check if it matches a displayName
144
+ if not resolved_id:
145
+ for friendly_name, voice_data in voices.items():
146
+ if voice_data.get('displayName', '').lower() == voice_lower:
147
+ resolved_id = voice_data.get('id')
148
+ break
149
+
150
+ # SECURITY: Validate resolved ID matches safe pattern
151
+ if resolved_id and re.match(r'^[a-zA-Z0-9_-]+$', resolved_id):
152
+ return resolved_id
153
+
154
+ except (json.JSONDecodeError, KeyError, IOError, TypeError):
155
+ pass
156
+
157
+ return voice_name
158
+
159
+ async def text_to_speech(
160
+ self,
161
+ text: str,
162
+ voice: Optional[str] = None,
163
+ personality: Optional[str] = None,
164
+ language: Optional[str] = None,
165
+ ) -> str:
166
+ """
167
+ Convert text to speech using AgentVibes.
168
+
169
+ Args:
170
+ text: The text to speak
171
+ voice: Optional voice name (e.g., "Aria", "Northern Terry")
172
+ personality: Optional personality style (e.g., "flirty", "sarcastic")
173
+ language: Optional language (e.g., "spanish", "french")
174
+
175
+ Returns:
176
+ Success message with audio file path
177
+ """
178
+ # Store original settings to restore later
179
+ original_personality = None
180
+ original_language = None
181
+
182
+ try:
183
+ # Temporarily set personality if specified
184
+ if personality:
185
+ original_personality = await self._get_personality()
186
+ await self._run_script(
187
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
188
+ )
189
+
190
+ # Temporarily set language if specified
191
+ if language:
192
+ original_language = await self._get_language()
193
+ await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
194
+
195
+ # Call the TTS script via appropriate shell
196
+ tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
197
+ play_tts = self.hooks_dir / tts_script
198
+ if self.is_windows:
199
+ args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(play_tts), text]
200
+ if voice:
201
+ args.extend(["-VoiceOverride", voice])
202
+ else:
203
+ args = ["bash", str(play_tts), text]
204
+ if voice:
205
+ args.append(voice)
206
+
207
+ env = self._build_script_env()
208
+
209
+ result = await asyncio.create_subprocess_exec(
210
+ *args,
211
+ stdout=asyncio.subprocess.PIPE,
212
+ stderr=asyncio.subprocess.PIPE,
213
+ env=env,
214
+ )
215
+ try:
216
+ stdout, stderr = await result.communicate()
217
+
218
+ if result.returncode == 0:
219
+ output = stdout.decode().strip()
220
+ # Extract file path from output
221
+ audio_file_path = None
222
+ for line in output.split("\n"):
223
+ if "Saved to:" in line:
224
+ audio_file_path = line.split("Saved to:")[1].strip()
225
+ break
226
+
227
+ if audio_file_path:
228
+ truncated = (
229
+ f"{text[:50]}..." if len(text) > 50 else text
230
+ )
231
+ return f"✅ Spoke: {truncated}\n📁 Audio saved: {audio_file_path}"
232
+
233
+ return f"✅ Spoke: {text[:50]}..." if len(text) > 50 else f"✅ Spoke: {text}"
234
+ else:
235
+ error = stderr.decode().strip()
236
+ stdout_output = stdout.decode().strip()
237
+ full_error = f"{error}\nStdout: {stdout_output}" if stdout_output else error
238
+ return f"❌ TTS failed: {full_error}"
239
+ finally:
240
+ # Ensure process cleanup
241
+ if result.returncode is None:
242
+ result.kill()
243
+ await result.wait()
244
+
245
+ finally:
246
+ # Restore original settings
247
+ if original_personality:
248
+ await self._run_script(
249
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
250
+ )
251
+ if original_language:
252
+ await self._run_script(
253
+ self.LANGUAGE_MANAGER_SCRIPT, ["set", original_language]
254
+ )
255
+
256
+ async def list_voices(self) -> str:
257
+ """
258
+ List all available TTS voices for the active provider.
259
+
260
+ Returns:
261
+ Formatted list of available voices
262
+ """
263
+ # Get active provider for display purposes
264
+ provider = await self._get_provider()
265
+ current_voice = await self._get_current_voice()
266
+
267
+ # voice-manager.sh list-simple is now provider-aware
268
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["list-simple"])
269
+ if result:
270
+ voices = result.strip().split("\n")
271
+ voices = [v for v in voices if v] # Filter empty strings
272
+
273
+ if not voices:
274
+ return (
275
+ f"📦 No voices available\n"
276
+ f"{self.SEPARATOR}\n"
277
+ f"For Piper: Download voices using /agent-vibes:provider download <voice-name>\n"
278
+ f"Example: en_US-lessac-medium, en_GB-alba-medium"
279
+ )
280
+
281
+ # Determine provider label and alternative provider
282
+ if "Piper" in provider:
283
+ provider_label = "Piper TTS"
284
+ alternative_provider = "macOS"
285
+ elif "macOS" in provider:
286
+ provider_label = "macOS TTS"
287
+ alternative_provider = "Piper"
288
+ elif "Termux" in provider or "Android" in provider:
289
+ provider_label = "Termux SSH (Android)"
290
+ alternative_provider = "Piper"
291
+ else:
292
+ provider_label = "TTS"
293
+ alternative_provider = None
294
+
295
+ output = f"🎤 Available {provider_label} Voices:\n"
296
+ output += f"{self.SEPARATOR}\n"
297
+ for voice in voices:
298
+ marker = " ✓ (current)" if voice == current_voice else ""
299
+ output += f" • {voice}{marker}\n"
300
+ output += f"{self.SEPARATOR}\n"
301
+
302
+ # Add provider switch hint
303
+ if alternative_provider:
304
+ output += f"\n💡 Switch to {alternative_provider}? Use: set_provider(provider=\"{alternative_provider.lower()}\")\n"
305
+
306
+ return output
307
+ return "❌ Failed to list voices"
308
+
309
+ async def set_voice(self, voice_name: str) -> str:
310
+ """
311
+ Switch to a different voice (supports friendly names like "ryan" or "katherine").
312
+
313
+ Args:
314
+ voice_name: Friendly name (e.g., "ryan") or Piper voice ID
315
+
316
+ Returns:
317
+ Success or error message
318
+ """
319
+ # Resolve friendly name to Piper ID
320
+ original_name = voice_name
321
+ resolved_name = self._resolve_friendly_name(voice_name)
322
+
323
+ result = await self._run_script(
324
+ self.VOICE_MANAGER_SCRIPT, ["switch", resolved_name, "--silent"]
325
+ )
326
+
327
+ if result and "✅" in result:
328
+ if original_name.lower() != resolved_name.lower():
329
+ return f" Voice switched to: {original_name} ({resolved_name})"
330
+ return f"✅ Voice switched to: {voice_name}"
331
+ return f"❌ Failed to switch voice: {result}"
332
+
333
+ async def list_personalities(self) -> str:
334
+ """
335
+ List all available personalities.
336
+
337
+ Returns:
338
+ Formatted list of personalities with descriptions
339
+ """
340
+ result = await self._run_script(self.PERSONALITY_MANAGER_SCRIPT, ["list"])
341
+ return result if result else " Failed to list personalities"
342
+
343
+ async def set_personality(self, personality: str) -> str:
344
+ """
345
+ Set the personality style for TTS messages.
346
+
347
+ Args:
348
+ personality: Personality name (e.g., "flirty", "sarcastic", "pirate")
349
+
350
+ Returns:
351
+ Success or error message
352
+ """
353
+ result = await self._run_script(
354
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
355
+ )
356
+ if result and "🎭" in result:
357
+ return result
358
+ return f"❌ Failed to set personality: {result}"
359
+
360
+ async def get_config(self) -> str:
361
+ """
362
+ Get current AgentVibes configuration.
363
+
364
+ Returns:
365
+ Current voice, personality, language, provider, and LLM settings
366
+ """
367
+ import re as _re
368
+ voice = await self._get_current_voice()
369
+ personality = await self._get_personality()
370
+ language = await self._get_language()
371
+ provider = await self._get_provider()
372
+
373
+ # Resolve the LLM key using the same priority as text_to_speech:
374
+ # 1. AGENTVIBES_LLM 2. CLAUDECODE=1 3. AGENTVIBES_MCP_FALLBACK 4. "default"
375
+ llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
376
+ if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
377
+ llm_key = ""
378
+ if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
379
+ llm_key = "claude-code"
380
+ if not llm_key:
381
+ fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
382
+ if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
383
+ llm_key = fallback
384
+ if not llm_key:
385
+ llm_key = "default"
386
+
387
+ output = "🎤 Current AgentVibes Configuration\n"
388
+ output += f"{self.SEPARATOR}\n"
389
+ output += f"LLM: {llm_key}\n"
390
+ output += f"Provider: {provider}\n"
391
+ output += f"Voice: {voice}\n"
392
+ output += f"Personality: {personality}\n"
393
+ output += f"Language: {language}\n"
394
+ output += f"{self.SEPARATOR}\n"
395
+ return output
396
+
397
+ async def set_language(self, language: str) -> str:
398
+ """
399
+ Set the language for TTS speech.
400
+
401
+ Args:
402
+ language: Language name (e.g., "spanish", "french", "german")
403
+
404
+ Returns:
405
+ Success or error message
406
+ """
407
+ result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
408
+ if result and "" in result:
409
+ return result
410
+ return f"❌ Failed to set language: {result}"
411
+
412
+ async def replay_audio(self, n: int = 1) -> str:
413
+ """
414
+ Replay recently generated TTS audio.
415
+
416
+ Args:
417
+ n: Which audio to replay (1 = most recent, 2 = second most recent, etc.)
418
+
419
+ Returns:
420
+ Success or error message
421
+ """
422
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["replay", str(n)])
423
+ if result and "🔊" in result:
424
+ return result
425
+ return f"❌ Failed to replay audio: {result}"
426
+
427
+ async def set_provider(self, provider: str) -> str:
428
+ """
429
+ Switch TTS provider between Piper, macOS, and Termux SSH.
430
+
431
+ Args:
432
+ provider: Provider name ("piper", "macos", or "termux-ssh")
433
+
434
+ Returns:
435
+ Success or error message
436
+ """
437
+ provider = provider.lower()
438
+ if self.is_windows:
439
+ valid_providers = ["windows-piper", "windows-sapi", "soprano"]
440
+ else:
441
+ valid_providers = ["piper", "macos", "termux-ssh", "soprano"]
442
+ if provider not in valid_providers:
443
+ return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
444
+
445
+ result = await self._run_script("provider-manager.sh", ["switch", provider])
446
+ if result and ("" in result or "[OK]" in result):
447
+ # Automatically speak confirmation in the new provider's voice
448
+ provider_names = {
449
+ "macos": "macOS",
450
+ "termux-ssh": "Termux SSH",
451
+ "piper": "Piper",
452
+ "windows-piper": "Windows Piper",
453
+ "windows-sapi": "Windows SAPI",
454
+ "soprano": "Soprano",
455
+ }
456
+ provider_name = provider_names.get(provider, provider.title())
457
+ confirmation_text = f"Successfully switched to {provider_name} provider"
458
+
459
+ try:
460
+ # Speak the confirmation with 5 second timeout to prevent hanging
461
+ await asyncio.wait_for(
462
+ self.text_to_speech(confirmation_text),
463
+ timeout=5.0
464
+ )
465
+ # Return the provider switch result plus TTS confirmation
466
+ return f"{result}\n🔊 Spoken confirmation: {confirmation_text}"
467
+ except asyncio.TimeoutError:
468
+ # Timeout - provider may need setup (e.g., Piper not installed)
469
+ return f"{result}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
470
+ except Exception as e:
471
+ # If TTS fails, still return success for the provider switch
472
+ return f"{result}\n⚠️ Provider switched but TTS confirmation failed: {e}"
473
+
474
+ return f"❌ Failed to switch provider: {result}"
475
+
476
+ async def set_learn_mode(self, enabled: bool) -> str:
477
+ """
478
+ Enable or disable language learning mode.
479
+
480
+ When enabled, TTS speaks in both your main language and target language.
481
+
482
+ Args:
483
+ enabled: True to enable, False to disable
484
+
485
+ Returns:
486
+ Success or error message
487
+ """
488
+ action = "enable" if enabled else "disable"
489
+ result = await self._run_script("learn-manager.sh", [action])
490
+ if result and "✓" in result:
491
+ return result
492
+ return f"❌ Failed to set learn mode: {result}"
493
+
494
+ async def set_speed(self, speed: str, target: bool = False) -> str:
495
+ """
496
+ Set speech speed for main or target voice.
497
+
498
+ Works with both Piper and macOS providers.
499
+
500
+ Args:
501
+ speed: Speed value (e.g., "0.5x", "1x", "2x", "normal", "fast", "slow")
502
+ target: If True, sets target language speed; if False, sets main voice speed
503
+
504
+ Returns:
505
+ Success or error message
506
+ """
507
+ # Security: Using secrets.choice for cryptographically secure random selection
508
+ # Even though this is just for UI variety, we use secrets to satisfy security scanners
509
+ import secrets
510
+
511
+ args = ["target", speed] if target else [speed]
512
+ result = await self._run_script("speed-manager.sh", args)
513
+ if result and "✓" in result:
514
+ # Simple test messages to demonstrate the new speed
515
+ test_messages = [
516
+ "Testing speed change",
517
+ "Speed test in progress",
518
+ "Checking audio speed",
519
+ "Speed configuration test",
520
+ "Audio speed test",
521
+ ]
522
+
523
+ # Pick a random test message and speak it
524
+ test_message = secrets.choice(test_messages)
525
+
526
+ try:
527
+ # Speak the test message to demonstrate the new speed
528
+ await self.text_to_speech(test_message)
529
+ return f"{result}\n🔊 Testing new speed: \"{test_message}\""
530
+ except Exception as e:
531
+ # If TTS fails, still return success for the speed change
532
+ return f"{result}\n⚠️ Speed changed but demo failed: {e}"
533
+
534
+ return f"❌ Failed to set speed: {result}"
535
+
536
+ async def get_speed(self) -> str:
537
+ """
538
+ Get current speech speed settings.
539
+
540
+ Returns:
541
+ Current speed settings for main and target voices
542
+ """
543
+ result = await self._run_script("speed-manager.sh", ["get"])
544
+ return result if result else "❌ Failed to get speed settings"
545
+
546
+ async def download_extra_voices(self, auto_yes: bool = False) -> str:
547
+ """
548
+ Download extra high-quality Piper voices from HuggingFace.
549
+
550
+ Downloads custom voices: Kristin, Jenny, and Tracy/16Speakers.
551
+
552
+ Args:
553
+ auto_yes: If True, skips confirmation prompt and downloads automatically
554
+
555
+ Returns:
556
+ Success message with download summary
557
+ """
558
+ args = ["--yes"] if auto_yes else []
559
+ result = await self._run_script("download-extra-voices.sh", args)
560
+ if result and ("✅" in result or "Successfully downloaded" in result or "already downloaded" in result):
561
+ return result
562
+ return f" Failed to download extra voices: {result}"
563
+
564
+ async def get_verbosity(self) -> str:
565
+ """
566
+ Get current verbosity level.
567
+
568
+ Returns:
569
+ Current verbosity level with description
570
+ """
571
+ result = await self._run_script("verbosity-manager.sh", ["get"])
572
+ if result:
573
+ level = result.strip()
574
+ descriptions = {
575
+ "low": "LOW - Acknowledgments + Completions only (minimal)",
576
+ "medium": "MEDIUM - + Major decisions and findings (balanced)",
577
+ "high": "HIGH - All reasoning (maximum transparency)"
578
+ }
579
+ desc = descriptions.get(level, level)
580
+ return f"🎙️ Current Verbosity: {desc}\n\n💡 Change with: set_verbosity(level=\"low|medium|high\")"
581
+ return "❌ Failed to get verbosity level"
582
+
583
+ async def set_verbosity(self, level: str) -> str:
584
+ """
585
+ Set verbosity level to control how much Claude speaks.
586
+
587
+ Args:
588
+ level: Verbosity level (low, medium, or high)
589
+
590
+ Returns:
591
+ Success or error message
592
+ """
593
+ result = await self._run_script("verbosity-manager.sh", ["set", level])
594
+ if result and "" in result:
595
+ return f"{result}\n\n⚠️ Restart Claude Code for changes to take effect"
596
+ return f"❌ Failed to set verbosity: {result}"
597
+
598
+ def _get_mute_files(self) -> list:
599
+ """Get all mute file paths for current platform"""
600
+ files = [
601
+ Path.home() / self.MUTE_FILE_NAME,
602
+ Path.cwd() / self.CLAUDE_DIR_NAME / "agentvibes-muted",
603
+ ]
604
+ # Windows PowerShell scripts check tts-muted.txt in .claude dir
605
+ if self.is_windows:
606
+ files.append(Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt")
607
+ return files
608
+
609
+ async def mute(self) -> str:
610
+ """
611
+ Mute all TTS output. Creates a persistent mute flag.
612
+
613
+ Returns:
614
+ Success message confirming mute is active
615
+ """
616
+ try:
617
+ mute_file = Path.home() / self.MUTE_FILE_NAME
618
+ mute_file.touch()
619
+ # On Windows, also write tts-muted.txt for PowerShell script compatibility
620
+ if self.is_windows:
621
+ win_mute = Path.home() / self.CLAUDE_DIR_NAME / "tts-muted.txt"
622
+ win_mute.parent.mkdir(parents=True, exist_ok=True)
623
+ win_mute.write_text("true")
624
+ return "🔇 AgentVibes TTS muted. All voice output is now silenced.\n\n💡 To unmute, use: unmute()"
625
+ except Exception as e:
626
+ return f" Failed to mute: {e}"
627
+
628
+ async def unmute(self) -> str:
629
+ """
630
+ Unmute TTS output. Removes the mute flag.
631
+
632
+ Returns:
633
+ Success message confirming TTS is restored
634
+ """
635
+ removed = []
636
+ try:
637
+ for mute_file in self._get_mute_files():
638
+ if mute_file.exists():
639
+ # tts-muted.txt uses content "true"/"false", others use file existence
640
+ if mute_file.name == "tts-muted.txt":
641
+ content = mute_file.read_text().strip()
642
+ if content == "true":
643
+ mute_file.write_text("false")
644
+ removed.append(str(mute_file.name))
645
+ else:
646
+ mute_file.unlink()
647
+ removed.append(str(mute_file.name))
648
+
649
+ if removed:
650
+ return f"🔊 AgentVibes TTS unmuted. Voice output is now restored.\n (Removed: {', '.join(removed)} mute flag)"
651
+ else:
652
+ return "🔊 AgentVibes TTS was not muted. Voice output is active."
653
+ except Exception as e:
654
+ return f"❌ Failed to unmute: {e}"
655
+
656
+ async def is_muted(self) -> str:
657
+ """
658
+ Check if TTS is currently muted.
659
+
660
+ Returns:
661
+ Current mute status
662
+ """
663
+ for mute_file in self._get_mute_files():
664
+ if mute_file.exists():
665
+ # tts-muted.txt uses content "true"/"false"
666
+ if mute_file.name == "tts-muted.txt":
667
+ content = mute_file.read_text().strip()
668
+ if content == "true":
669
+ return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
670
+ else:
671
+ return "🔇 TTS is currently MUTED\n\n💡 To unmute, use: unmute()"
672
+ return "🔊 TTS is currently ACTIVE\n\n💡 To mute, use: mute()"
673
+
674
+ async def list_background_music(self) -> str:
675
+ """
676
+ List all available background music tracks.
677
+
678
+ Returns:
679
+ Formatted list of all pre-packaged background music files
680
+ """
681
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
682
+ return result if result else "❌ Failed to list background music"
683
+
684
+ async def set_background_music(self, track_name: str, agent_name: Optional[str] = None) -> str:
685
+ """
686
+ Set background music track for a specific agent, all agents, or as default.
687
+
688
+ Args:
689
+ track_name: Track filename or partial name for fuzzy matching
690
+ agent_name: Agent name ('all' for all agents, None for default)
691
+
692
+ Returns:
693
+ Success or error message
694
+ """
695
+ import re
696
+
697
+ # Get list of available tracks for fuzzy matching
698
+ list_result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
699
+ if not list_result or "❌" in list_result:
700
+ return "❌ Failed to list background music tracks"
701
+
702
+ # Parse track names
703
+ tracks = []
704
+ for line in list_result.split("\n"):
705
+ match = re.match(r'\s*\d+\.\s+(.+)', line.strip())
706
+ if match:
707
+ tracks.append(match.group(1).strip())
708
+
709
+ # Try to find a matching track (case-insensitive partial match)
710
+ track_lower = track_name.lower()
711
+ matched_track = None
712
+
713
+ # First try exact match
714
+ for track in tracks:
715
+ if track.lower() == track_lower:
716
+ matched_track = track
717
+ break
718
+
719
+ # If no exact match, try partial match
720
+ if not matched_track:
721
+ for track in tracks:
722
+ if track_lower in track.lower():
723
+ matched_track = track
724
+ break
725
+
726
+ if not matched_track:
727
+ # Show available tracks to help user
728
+ available = "\n".join([f" • {t}" for t in tracks])
729
+ return f"❌ No track matching '{track_name}' found.\n\nAvailable tracks:\n{available}\n\n💡 Try a partial match like 'celtic' or 'chillwave'"
730
+
731
+ # Determine which command to use based on agent_name
732
+ if agent_name and agent_name.lower() == "all":
733
+ # Set for all agents
734
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-all", matched_track])
735
+ elif agent_name:
736
+ # Set for specific agent
737
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-agent", agent_name, matched_track])
738
+ else:
739
+ # Set as default
740
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-default", matched_track])
741
+
742
+ if result and ("✅" in result or "[OK]" in result):
743
+ if matched_track.lower() != track_name.lower():
744
+ return f"{result}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
745
+ return result
746
+ return f"❌ Failed to set background music: {result}"
747
+
748
+ async def enable_background_music(self, enabled: bool) -> str:
749
+ """
750
+ Enable or disable background music globally.
751
+
752
+ Args:
753
+ enabled: True to enable, False to disable
754
+
755
+ Returns:
756
+ Success or error message
757
+ """
758
+ command = "on" if enabled else "off"
759
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, [command])
760
+ # Sync to .agentvibes/config.json (TUI source of truth)
761
+ try:
762
+ import json
763
+ cfg_path = self.agentvibes_root / ".agentvibes" / "config.json"
764
+ cfg = {}
765
+ if cfg_path.exists():
766
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
767
+ if "backgroundMusic" not in cfg:
768
+ cfg["backgroundMusic"] = {}
769
+ cfg["backgroundMusic"]["enabled"] = enabled
770
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
771
+ cfg_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
772
+ except Exception:
773
+ pass # best-effort sync
774
+ return result if result else f"❌ Failed to {'enable' if enabled else 'disable'} background music"
775
+
776
+ async def set_background_music_volume(self, volume: float) -> str:
777
+ """
778
+ Set background music volume.
779
+
780
+ Args:
781
+ volume: Volume level (0.0-1.0)
782
+
783
+ Returns:
784
+ Success or error message
785
+ """
786
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["volume", str(volume)])
787
+ return result if result else "❌ Failed to set background music volume"
788
+
789
+ async def get_background_music_status(self) -> str:
790
+ """
791
+ Get current background music configuration.
792
+
793
+ Returns:
794
+ Status information
795
+ """
796
+ result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["status"])
797
+ return result if result else "❌ Failed to get background music status"
798
+
799
+ async def set_reverb(self, level: str, agent: str = "default", apply_all: bool = False) -> str:
800
+ """
801
+ Set reverb level for an agent or globally.
802
+
803
+ Args:
804
+ level: Reverb level (off, light, medium, heavy, cathedral)
805
+ agent: Agent name (default: "default")
806
+ apply_all: Apply to all agents (default: False)
807
+
808
+ Returns:
809
+ Success message
810
+ """
811
+ args = ["set-reverb", level, agent]
812
+ if apply_all:
813
+ args.append("--all")
814
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, args)
815
+ return result if result else f"✅ Set reverb to {level}"
816
+
817
+ async def get_reverb(self, agent: str = "default") -> str:
818
+ """
819
+ Get current reverb level for an agent.
820
+
821
+ Args:
822
+ agent: Agent name (default: "default")
823
+
824
+ Returns:
825
+ Current reverb level
826
+ """
827
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["get-reverb", agent])
828
+ if result:
829
+ return f"Current reverb level for {agent}: {result.strip()}"
830
+ return f"❌ Failed to get reverb for {agent}"
831
+
832
+ async def list_audio_effects(self) -> str:
833
+ """
834
+ List all audio effects for all agents.
835
+
836
+ Returns:
837
+ Effects configuration
838
+ """
839
+ result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["list"])
840
+ return result if result else "❌ Failed to list audio effects"
841
+
842
+ async def clean_audio_cache(self) -> str:
843
+ """
844
+ Clean all TTS audio cache files and report space freed.
845
+
846
+ Non-interactive cleanup suitable for MCP tool usage. Deletes all
847
+ TTS-generated audio files (wav, mp3, aiff) while preserving
848
+ background music tracks.
849
+
850
+ Returns:
851
+ Cleanup results with file count and space freed
852
+ """
853
+ result = await self._run_script("clean-audio-cache.sh", [])
854
+ return result if result else "❌ Failed to clean audio cache"
855
+
856
+ # Helper methods
857
+ def _build_script_env(self) -> dict:
858
+ """Build environment dict for script execution (shared by all script runners)"""
859
+ env = os.environ.copy()
860
+
861
+ # Determine where to save settings based on context:
862
+ # 1. If cwd has .claude/ → Use cwd (real Claude Code project)
863
+ # 2. Otherwise → Use global ~/.claude/ (Claude Desktop, Warp, etc.)
864
+ # Note: Hooks are ALWAYS from package .claude/ (self.claude_dir)
865
+ cwd = Path.cwd()
866
+ if (cwd / ".claude").is_dir() and cwd != self.agentvibes_root:
867
+ env["CLAUDE_PROJECT_DIR"] = str(cwd)
868
+
869
+ # Add common locations for piper to PATH (Unix only)
870
+ if not self.is_windows:
871
+ home_dir = Path.home()
872
+ local_bin = str(home_dir / ".local" / "bin")
873
+ if "PATH" in env:
874
+ if local_bin not in env["PATH"]:
875
+ env["PATH"] = f"{local_bin}:{env['PATH']}"
876
+ else:
877
+ env["PATH"] = local_bin
878
+
879
+ return env
880
+
881
+ async def _run_script(self, script_name: str, args: list[str]) -> str:
882
+ """Run a script and return output (bash on Unix, PowerShell on Windows)"""
883
+ # Auto-resolve .sh → .ps1 on Windows (class constants handle special cases)
884
+ if self.is_windows and script_name.endswith('.sh'):
885
+ script_name = script_name[:-3] + '.ps1'
886
+ script_path = self.hooks_dir / script_name
887
+ if not script_path.exists():
888
+ return f"Script not found: {script_path}"
889
+
890
+ # Build command — PowerShell on Windows, bash on Unix
891
+ if self.is_windows:
892
+ cmd = [
893
+ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
894
+ "-File", str(script_path)
895
+ ] + args
896
+ else:
897
+ cmd = ["bash", str(script_path)] + args
898
+
899
+ env = self._build_script_env()
900
+
901
+ try:
902
+ result = await asyncio.create_subprocess_exec(
903
+ *cmd,
904
+ stdout=asyncio.subprocess.PIPE,
905
+ stderr=asyncio.subprocess.PIPE,
906
+ env=env,
907
+ )
908
+ try:
909
+ stdout, stderr = await result.communicate()
910
+ if result.returncode == 0:
911
+ return stdout.decode().strip()
912
+ else:
913
+ error_msg = stderr.decode().strip()
914
+ if not error_msg: # If stderr is empty, include stdout for debugging
915
+ error_msg = f"Return code {result.returncode}. Stdout: {stdout.decode().strip()}"
916
+ return error_msg
917
+ finally:
918
+ # Ensure process cleanup
919
+ if result.returncode is None:
920
+ result.kill()
921
+ await result.wait()
922
+ except Exception as e:
923
+ return f"Error running script: {e}"
924
+
925
+ async def _get_current_voice(self) -> str:
926
+ """Get the currently active voice"""
927
+ result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["get"])
928
+ return result.strip() if result else "Unknown"
929
+
930
+ async def _get_personality(self) -> str:
931
+ """Get the current personality setting"""
932
+ personality_file = self.claude_dir / "tts-personality.txt"
933
+ if not personality_file.exists():
934
+ # Try global
935
+ personality_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-personality.txt"
936
+
937
+ try:
938
+ if personality_file.exists():
939
+ return personality_file.read_text().strip()
940
+ except (PermissionError, UnicodeDecodeError, OSError) as e:
941
+ # Log error but don't crash - return default
942
+ import sys
943
+ print(f"Warning: Could not read personality file: {e}", file=sys.stderr)
944
+ return "normal"
945
+
946
+ async def _get_language(self) -> str:
947
+ """Get the current language setting"""
948
+ result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["code"])
949
+ return result.strip() if result else "english"
950
+
951
+ async def _get_provider(self) -> str:
952
+ """Get the active TTS provider"""
953
+ provider_file = self.claude_dir / "tts-provider.txt"
954
+ if not provider_file.exists():
955
+ provider_file = Path.home() / self.CLAUDE_DIR_NAME / "tts-provider.txt"
956
+
957
+ provider_labels = {
958
+ "macos": "macOS TTS",
959
+ "piper": "Piper TTS (Free, Offline)",
960
+ "termux-ssh": "Termux SSH (Android)",
961
+ "windows-piper": "Windows Piper TTS (Free, Offline)",
962
+ "windows-sapi": "Windows SAPI (Built-in)",
963
+ "soprano": "Soprano TTS (Ultra-fast Neural)",
964
+ }
965
+ try:
966
+ if provider_file.exists():
967
+ provider = provider_file.read_text().strip()
968
+ # Strip BOM from PowerShell-written files
969
+ provider = provider.lstrip('\ufeff')
970
+ return provider_labels.get(provider, provider)
971
+ except (PermissionError, UnicodeDecodeError, OSError) as e:
972
+ # Log error but don't crash - return default
973
+ import sys
974
+ print(f"Warning: Could not read provider file: {e}", file=sys.stderr)
975
+ # Default based on platform
976
+ if self.is_windows:
977
+ return "Windows SAPI (Built-in)"
978
+ return "Piper TTS (Free, Offline)"
979
+
980
+
981
+ # Create the MCP server
982
+ app = Server("agentvibes")
983
+ agent_vibes = AgentVibesServer()
984
+
985
+
986
+ @app.list_tools()
987
+ async def list_tools() -> list[Tool]:
988
+ """List all available AgentVibes tools"""
989
+ return [
990
+ Tool(
991
+ name="text_to_speech",
992
+ description="""Convert text to speech using AgentVibes TTS.
993
+
994
+ Supports both macOS TTS and Piper (free, offline) providers.
995
+ Can use different voices, personalities, and languages.
996
+
997
+ Perfect for:
998
+ - Speaking acknowledgments and confirmations
999
+ - Adding voice to Claude responses
1000
+ - Multi-language communication
1001
+ - Personality-driven interactions
1002
+
1003
+ Examples:
1004
+ - text_to_speech(text="Hello, I'm ready to help!")
1005
+ - text_to_speech(text="Task completed!", personality="flirty")
1006
+ - text_to_speech(text="Hola, ¿cómo estás?", language="spanish")
1007
+ """,
1008
+ inputSchema={
1009
+ "type": "object",
1010
+ "properties": {
1011
+ "text": {
1012
+ "type": "string",
1013
+ "description": "Text to convert to speech (max 500 characters)",
1014
+ },
1015
+ "voice": {
1016
+ "type": "string",
1017
+ "description": "Voice name (optional). Use list_voices to see options.",
1018
+ },
1019
+ "personality": {
1020
+ "type": "string",
1021
+ "description": "Personality style (optional). Examples: flirty, sarcastic, pirate, robot, zen",
1022
+ },
1023
+ "language": {
1024
+ "type": "string",
1025
+ "description": "Language to speak in (optional). Examples: spanish, french, german, italian",
1026
+ },
1027
+ },
1028
+ "required": ["text"],
1029
+ },
1030
+ ),
1031
+ Tool(
1032
+ name="list_voices",
1033
+ description="List all available TTS voices with current selection",
1034
+ inputSchema={"type": "object", "properties": {}},
1035
+ ),
1036
+ Tool(
1037
+ name="set_voice",
1038
+ description="Switch to a different TTS voice",
1039
+ inputSchema={
1040
+ "type": "object",
1041
+ "properties": {
1042
+ "voice_name": {
1043
+ "type": "string",
1044
+ "description": "Name of the voice to switch to",
1045
+ }
1046
+ },
1047
+ "required": ["voice_name"],
1048
+ },
1049
+ ),
1050
+ Tool(
1051
+ name="list_personalities",
1052
+ description="List all available personality styles with descriptions",
1053
+ inputSchema={"type": "object", "properties": {}},
1054
+ ),
1055
+ Tool(
1056
+ name="set_personality",
1057
+ description="Set the personality style for TTS messages",
1058
+ inputSchema={
1059
+ "type": "object",
1060
+ "properties": {
1061
+ "personality": {
1062
+ "type": "string",
1063
+ "description": "Personality name (e.g., flirty, sarcastic, pirate)",
1064
+ }
1065
+ },
1066
+ "required": ["personality"],
1067
+ },
1068
+ ),
1069
+ Tool(
1070
+ name="set_language",
1071
+ description="Set the language for TTS speech (supports 25+ languages)",
1072
+ inputSchema={
1073
+ "type": "object",
1074
+ "properties": {
1075
+ "language": {
1076
+ "type": "string",
1077
+ "description": "Language name (e.g., spanish, french, german)",
1078
+ }
1079
+ },
1080
+ "required": ["language"],
1081
+ },
1082
+ ),
1083
+ Tool(
1084
+ name="get_config",
1085
+ description="Get current voice, personality, language, and provider configuration",
1086
+ inputSchema={"type": "object", "properties": {}},
1087
+ ),
1088
+ Tool(
1089
+ name="replay_audio",
1090
+ description="Replay recently generated TTS audio",
1091
+ inputSchema={
1092
+ "type": "object",
1093
+ "properties": {
1094
+ "n": {
1095
+ "type": "integer",
1096
+ "description": "Which audio to replay (1 = most recent, default: 1)",
1097
+ "minimum": 1,
1098
+ "maximum": 10,
1099
+ }
1100
+ },
1101
+ },
1102
+ ),
1103
+ Tool(
1104
+ name="set_provider",
1105
+ description="Switch between TTS providers" + (
1106
+ ": Windows Piper, Windows SAPI, or Soprano" if agent_vibes.is_windows
1107
+ else ": macOS TTS, Piper (free, offline), Soprano, or Termux SSH (Android)"
1108
+ ),
1109
+ inputSchema={
1110
+ "type": "object",
1111
+ "properties": {
1112
+ "provider": {
1113
+ "type": "string",
1114
+ "description": (
1115
+ "Provider name: 'windows-piper', 'windows-sapi', or 'soprano'"
1116
+ if agent_vibes.is_windows
1117
+ else "Provider name: 'piper', 'macos', 'soprano', or 'termux-ssh'"
1118
+ ),
1119
+ "enum": (
1120
+ ["windows-piper", "windows-sapi", "soprano"]
1121
+ if agent_vibes.is_windows
1122
+ else ["piper", "macos", "soprano", "termux-ssh"]
1123
+ ),
1124
+ }
1125
+ },
1126
+ "required": ["provider"],
1127
+ },
1128
+ ),
1129
+ Tool(
1130
+ name="set_learn_mode",
1131
+ description="Enable or disable language learning mode. When ON, TTS speaks in both your main language and target language for bilingual learning.",
1132
+ inputSchema={
1133
+ "type": "object",
1134
+ "properties": {
1135
+ "enabled": {
1136
+ "type": "boolean",
1137
+ "description": "True to enable learning mode, False to disable"
1138
+ }
1139
+ },
1140
+ "required": ["enabled"],
1141
+ },
1142
+ ),
1143
+ Tool(
1144
+ name="set_speed",
1145
+ description="Set speech speed for main or target voice. Works with both Piper and macOS providers. Use this to make voices faster or slower.",
1146
+ inputSchema={
1147
+ "type": "object",
1148
+ "properties": {
1149
+ "speed": {
1150
+ "type": "string",
1151
+ "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)"
1152
+ },
1153
+ "target": {
1154
+ "type": "boolean",
1155
+ "description": "If true, sets target language speed (for learning mode); if false or omitted, sets main voice speed",
1156
+ "default": False
1157
+ }
1158
+ },
1159
+ "required": ["speed"],
1160
+ },
1161
+ ),
1162
+ Tool(
1163
+ name="get_speed",
1164
+ description="Get current speech speed settings for main and target voices",
1165
+ inputSchema={"type": "object", "properties": {}},
1166
+ ),
1167
+ Tool(
1168
+ name="download_extra_voices",
1169
+ description="Download extra high-quality custom Piper voices from HuggingFace. Includes: Kristin (US female), Jenny (UK female with Irish accent), and Tracy/16Speakers (multi-speaker). Perfect for adding variety to your TTS voices.",
1170
+ inputSchema={
1171
+ "type": "object",
1172
+ "properties": {
1173
+ "auto_yes": {
1174
+ "type": "boolean",
1175
+ "description": "Skip confirmation prompt and download automatically (default: False)",
1176
+ "default": False
1177
+ }
1178
+ },
1179
+ },
1180
+ ),
1181
+ Tool(
1182
+ name="get_verbosity",
1183
+ 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).",
1184
+ inputSchema={"type": "object", "properties": {}},
1185
+ ),
1186
+ Tool(
1187
+ name="set_verbosity",
1188
+ description="""Set AgentVibes verbosity level to control how much Claude speaks while working.
1189
+
1190
+ Verbosity Levels:
1191
+ - LOW: Only acknowledgments (start) and completions (end). Minimal interruption.
1192
+ - MEDIUM: + Major decisions and key findings. Balanced transparency.
1193
+ - HIGH: All reasoning, decisions, and findings. Maximum transparency.
1194
+ - CAVEMAN: Ultra-terse fragments. Drops articles, filler, hedging. Abbreviates heavily. 65-75% fewer output tokens.
1195
+
1196
+ Perfect for:
1197
+ - LOW: Quiet work sessions, minimal distraction
1198
+ - MEDIUM: Understanding major decisions without full narration
1199
+ - HIGH: Full transparency, learning mode, debugging complex tasks
1200
+ - CAVEMAN: Maximum token savings, minimal prose
1201
+
1202
+ Note: Changes take effect on next Claude Code session restart.""",
1203
+ inputSchema={
1204
+ "type": "object",
1205
+ "properties": {
1206
+ "level": {
1207
+ "type": "string",
1208
+ "description": "Verbosity level to set",
1209
+ "enum": ["low", "medium", "high", "caveman"]
1210
+ }
1211
+ },
1212
+ "required": ["level"],
1213
+ },
1214
+ ),
1215
+ Tool(
1216
+ name="mute",
1217
+ description="Mute all AgentVibes TTS output. Creates a persistent mute flag that silences all voice output until unmuted. Persists across sessions.",
1218
+ inputSchema={"type": "object", "properties": {}},
1219
+ ),
1220
+ Tool(
1221
+ name="unmute",
1222
+ description="Unmute AgentVibes TTS output. Removes the mute flag and restores voice output.",
1223
+ inputSchema={"type": "object", "properties": {}},
1224
+ ),
1225
+ Tool(
1226
+ name="is_muted",
1227
+ description="Check if TTS is currently muted.",
1228
+ inputSchema={"type": "object", "properties": {}},
1229
+ ),
1230
+ Tool(
1231
+ name="list_background_music",
1232
+ description="List all available pre-packaged background music tracks. Shows all audio files that can be used as background music for TTS.",
1233
+ inputSchema={"type": "object", "properties": {}},
1234
+ ),
1235
+ Tool(
1236
+ name="set_background_music",
1237
+ description="""Set background music track for a specific agent, all agents, or as default. Supports smart fuzzy matching.
1238
+
1239
+ Perfect for:
1240
+ - "change background music to flamenco" - Sets for all agents
1241
+ - "set John's background music to celtic harp" - Agent-specific
1242
+ - "use chillwave as default background" - Default for new agents
1243
+
1244
+ Fuzzy matching examples:
1245
+ - "flamenco" matches "agentvibes_soft_flamenco_loop.mp3"
1246
+ - "celtic" matches "agent_vibes_celtic_harp_v1_loop.mp3"
1247
+ - "bossa" matches "agent_vibes_bossa_nova_v2_loop.mp3"
1248
+ """,
1249
+ inputSchema={
1250
+ "type": "object",
1251
+ "properties": {
1252
+ "track_name": {
1253
+ "type": "string",
1254
+ "description": "Track filename or partial name for fuzzy matching (e.g., 'celtic', 'flamenco', 'bossa nova')",
1255
+ },
1256
+ "agent_name": {
1257
+ "type": "string",
1258
+ "description": "Agent name to configure (optional). Use 'all' for all agents, omit for default",
1259
+ },
1260
+ },
1261
+ "required": ["track_name"],
1262
+ },
1263
+ ),
1264
+ Tool(
1265
+ name="enable_background_music",
1266
+ description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 30%).",
1267
+ inputSchema={
1268
+ "type": "object",
1269
+ "properties": {
1270
+ "enabled": {
1271
+ "type": "boolean",
1272
+ "description": "True to enable background music, False to disable",
1273
+ }
1274
+ },
1275
+ "required": ["enabled"],
1276
+ },
1277
+ ),
1278
+ Tool(
1279
+ name="set_background_music_volume",
1280
+ description="Set the volume level for background music (0.0-1.0). Recommended: 0.20-0.40 for subtle background ambiance.",
1281
+ inputSchema={
1282
+ "type": "object",
1283
+ "properties": {
1284
+ "volume": {
1285
+ "type": "number",
1286
+ "description": "Volume level (0.0 = silent, 0.30 = default, 1.0 = full volume)",
1287
+ "minimum": 0.0,
1288
+ "maximum": 1.0,
1289
+ }
1290
+ },
1291
+ "required": ["volume"],
1292
+ },
1293
+ ),
1294
+ Tool(
1295
+ name="get_background_music_status",
1296
+ description="Get current background music configuration including enabled status, volume, default track, and number of available tracks.",
1297
+ inputSchema={"type": "object", "properties": {}},
1298
+ ),
1299
+ Tool(
1300
+ name="set_reverb",
1301
+ description="""Set reverb level for TTS audio. Can apply globally (default agent), to a specific agent, or to all agents.
1302
+
1303
+ Reverb adds room/space ambiance to the voice, making it sound like it's in a small room, conference room, or large hall.
1304
+
1305
+ Examples:
1306
+ - set_reverb(level="medium") - Set reverb for default agent
1307
+ - set_reverb(level="cathedral", agent="Winston") - Set cathedral reverb for Winston
1308
+ - set_reverb(level="light", apply_all=True) - Set light reverb for all agents
1309
+ - set_reverb(level="off") - Turn off reverb for default agent
1310
+ """,
1311
+ inputSchema={
1312
+ "type": "object",
1313
+ "properties": {
1314
+ "level": {
1315
+ "type": "string",
1316
+ "description": "Reverb level",
1317
+ "enum": ["off", "light", "medium", "heavy", "cathedral"]
1318
+ },
1319
+ "agent": {
1320
+ "type": "string",
1321
+ "description": "Agent name (optional, defaults to 'default'). Examples: Winston, John, Mary, Amelia",
1322
+ },
1323
+ "apply_all": {
1324
+ "type": "boolean",
1325
+ "description": "Apply to all agents (optional, default: false)",
1326
+ }
1327
+ },
1328
+ "required": ["level"],
1329
+ },
1330
+ ),
1331
+ Tool(
1332
+ name="get_reverb",
1333
+ description="Get current reverb level for a specific agent or default",
1334
+ inputSchema={
1335
+ "type": "object",
1336
+ "properties": {
1337
+ "agent": {
1338
+ "type": "string",
1339
+ "description": "Agent name (optional, defaults to 'default')",
1340
+ }
1341
+ },
1342
+ },
1343
+ ),
1344
+ Tool(
1345
+ name="list_audio_effects",
1346
+ description="List current audio effects configuration for all agents, including reverb levels and other effects",
1347
+ inputSchema={"type": "object", "properties": {}},
1348
+ ),
1349
+ Tool(
1350
+ name="clean_audio_cache",
1351
+ 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.",
1352
+ inputSchema={"type": "object", "properties": {}},
1353
+ ),
1354
+ ]
1355
+
1356
+
1357
+ @app.call_tool()
1358
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1359
+ """Handle tool calls"""
1360
+ try:
1361
+ if name == "text_to_speech":
1362
+ result = await agent_vibes.text_to_speech(
1363
+ text=arguments["text"],
1364
+ voice=arguments.get("voice"),
1365
+ personality=arguments.get("personality"),
1366
+ language=arguments.get("language"),
1367
+ )
1368
+ elif name == "list_voices":
1369
+ result = await agent_vibes.list_voices()
1370
+ elif name == "set_voice":
1371
+ result = await agent_vibes.set_voice(arguments["voice_name"])
1372
+ elif name == "list_personalities":
1373
+ result = await agent_vibes.list_personalities()
1374
+ elif name == "set_personality":
1375
+ result = await agent_vibes.set_personality(arguments["personality"])
1376
+ elif name == "set_language":
1377
+ result = await agent_vibes.set_language(arguments["language"])
1378
+ elif name == "get_config":
1379
+ result = await agent_vibes.get_config()
1380
+ elif name == "replay_audio":
1381
+ n = arguments.get("n", 1)
1382
+ result = await agent_vibes.replay_audio(n)
1383
+ elif name == "set_provider":
1384
+ result = await agent_vibes.set_provider(arguments["provider"])
1385
+ elif name == "set_learn_mode":
1386
+ result = await agent_vibes.set_learn_mode(arguments["enabled"])
1387
+ elif name == "set_speed":
1388
+ target = arguments.get("target", False)
1389
+ result = await agent_vibes.set_speed(arguments["speed"], target)
1390
+ elif name == "get_speed":
1391
+ result = await agent_vibes.get_speed()
1392
+ elif name == "download_extra_voices":
1393
+ auto_yes = arguments.get("auto_yes", False)
1394
+ result = await agent_vibes.download_extra_voices(auto_yes)
1395
+ elif name == "get_verbosity":
1396
+ result = await agent_vibes.get_verbosity()
1397
+ elif name == "set_verbosity":
1398
+ result = await agent_vibes.set_verbosity(arguments["level"])
1399
+ elif name == "mute":
1400
+ result = await agent_vibes.mute()
1401
+ elif name == "unmute":
1402
+ result = await agent_vibes.unmute()
1403
+ elif name == "is_muted":
1404
+ result = await agent_vibes.is_muted()
1405
+ elif name == "list_background_music":
1406
+ result = await agent_vibes.list_background_music()
1407
+ elif name == "set_background_music":
1408
+ track_name = arguments.get("track_name")
1409
+ agent_name = arguments.get("agent_name")
1410
+ result = await agent_vibes.set_background_music(track_name, agent_name)
1411
+ elif name == "enable_background_music":
1412
+ enabled = arguments.get("enabled")
1413
+ result = await agent_vibes.enable_background_music(enabled)
1414
+ elif name == "set_background_music_volume":
1415
+ volume = arguments.get("volume")
1416
+ result = await agent_vibes.set_background_music_volume(volume)
1417
+ elif name == "get_background_music_status":
1418
+ result = await agent_vibes.get_background_music_status()
1419
+ elif name == "set_reverb":
1420
+ level = arguments["level"]
1421
+ agent = arguments.get("agent", "default")
1422
+ apply_all = arguments.get("apply_all", False)
1423
+ result = await agent_vibes.set_reverb(level, agent, apply_all)
1424
+ elif name == "get_reverb":
1425
+ agent = arguments.get("agent", "default")
1426
+ result = await agent_vibes.get_reverb(agent)
1427
+ elif name == "list_audio_effects":
1428
+ result = await agent_vibes.list_audio_effects()
1429
+ elif name == "clean_audio_cache":
1430
+ result = await agent_vibes.clean_audio_cache()
1431
+ else:
1432
+ result = f"Unknown tool: {name}"
1433
+
1434
+ return [TextContent(type="text", text=result)]
1435
+
1436
+ except Exception as e:
1437
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
1438
+
1439
+
1440
+ async def main():
1441
+ """Run the MCP server"""
1442
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
1443
+ await app.run(
1444
+ read_stream,
1445
+ write_stream,
1446
+ app.create_initialization_options(),
1447
+ )
1448
+
1449
+
1450
+ if __name__ == "__main__":
1451
+ asyncio.run(main())