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