agentvibes 5.12.0-alpha.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/commands/agent-vibes/commands.json +0 -20
  5. package/.claude/commands/agent-vibes/unmute.md +6 -2
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +6 -6
  9. package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/tts-speech-rate.txt +1 -0
  18. package/.claude/config/tts-verbosity.txt +1 -0
  19. package/.claude/docs/TERMUX_SETUP.md +408 -408
  20. package/.claude/github-star-reminder.txt +1 -1
  21. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  22. package/.claude/hooks/bmad-party-speak.sh +20 -4
  23. package/.claude/hooks/bmad-speak.sh +60 -2
  24. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  25. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  26. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  27. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  28. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  29. package/.claude/hooks/kokoro-installer.sh +20 -10
  30. package/.claude/hooks/language-manager.sh +10 -3
  31. package/.claude/hooks/party-set-room.sh +71 -0
  32. package/.claude/hooks/party-stage-roster.py +328 -0
  33. package/.claude/hooks/personality-manager.sh +19 -2
  34. package/.claude/hooks/piper-voice-manager.sh +3 -2
  35. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  36. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  37. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  38. package/.claude/hooks/play-tts-soprano.sh +3 -2
  39. package/.claude/hooks/play-tts-ssh-remote.sh +72 -27
  40. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  41. package/.claude/hooks/play-tts.sh +86 -69
  42. package/.claude/hooks/provider-catalog.json +352 -0
  43. package/.claude/hooks/provider-catalog.sh +161 -0
  44. package/.claude/hooks/provider-commands.sh +2 -1
  45. package/.claude/hooks/provider-manager.sh +47 -9
  46. package/.claude/hooks/python-resolver.sh +117 -0
  47. package/.claude/hooks/session-id.sh +56 -0
  48. package/.claude/hooks/session-start-tts.sh +39 -0
  49. package/.claude/hooks/speed-manager.sh +1 -1
  50. package/.claude/hooks/translate-manager.sh +3 -2
  51. package/.claude/hooks/translator.py +1 -1
  52. package/.claude/hooks/voice-manager.sh +242 -10
  53. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  54. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  55. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  56. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  57. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  58. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  59. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  60. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  61. package/.claude/hooks-windows/tts-watcher.ps1 +84 -8
  62. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  63. package/.claude/piper-voices-dir.txt +1 -0
  64. package/.clawdbot/README.md +105 -105
  65. package/.mcp.json +15 -0
  66. package/LICENSE +190 -190
  67. package/README.md +20 -6
  68. package/RELEASE_NOTES.md +81 -0
  69. package/WINDOWS-SETUP.md +208 -208
  70. package/bin/agent-vibes +39 -39
  71. package/bin/mcp-server.js +121 -121
  72. package/bin/test-bmad-pr +78 -78
  73. package/mcp-server/QUICK_START.md +203 -203
  74. package/mcp-server/README.md +345 -345
  75. package/mcp-server/examples/claude_desktop_config.json +11 -11
  76. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  77. package/mcp-server/examples/custom_instructions.md +169 -169
  78. package/mcp-server/pyproject.toml +52 -52
  79. package/mcp-server/requirements.txt +2 -2
  80. package/mcp-server/server.py +146 -49
  81. package/mcp-server/test_mcp_correctness.py +20 -2
  82. package/mcp-server/test_server.py +395 -395
  83. package/mcp-server/test_windows_script_parity.py +0 -2
  84. package/package.json +1 -1
  85. package/src/cli/list-personalities.js +110 -110
  86. package/src/cli/list-voices.js +115 -11
  87. package/src/commands/bmad-voices.js +394 -394
  88. package/src/console/app.js +1 -9
  89. package/src/console/bling.js +71 -0
  90. package/src/console/brand-colors.js +13 -13
  91. package/src/console/constants/personalities.js +44 -44
  92. package/src/console/footer-config.js +0 -4
  93. package/src/console/music-preview.js +79 -0
  94. package/src/console/navigation.js +0 -1
  95. package/src/console/tabs/agents-tab.js +14 -2
  96. package/src/console/tabs/help-tab.js +314 -314
  97. package/src/console/tabs/music-tab.js +81 -5
  98. package/src/console/tabs/placeholder-tab.js +0 -2
  99. package/src/console/tabs/readme-tab.js +272 -272
  100. package/src/console/tabs/settings-tab.js +207 -14
  101. package/src/console/tabs/setup-tab.js +39 -36
  102. package/src/console/tabs/voices-tab.js +102 -23
  103. package/src/console/widgets/destroy-list.js +25 -25
  104. package/src/console/widgets/notice.js +55 -55
  105. package/src/console/widgets/track-picker.js +82 -0
  106. package/src/installer/language-screen.js +31 -31
  107. package/src/installer/music-file-input.js +304 -304
  108. package/src/installer.js +124 -10
  109. package/src/services/language-service.js +47 -47
  110. package/src/services/navigation-service.js +1 -1
  111. package/src/services/provider-catalog.js +412 -0
  112. package/src/services/provider-voice-catalog.js +52 -73
  113. package/src/services/tts-engine-service.js +29 -0
  114. package/src/utils/audio-format-validator.js +277 -277
  115. package/src/utils/dependency-checker.js +469 -469
  116. package/src/utils/file-ownership-verifier.js +358 -358
  117. package/src/utils/music-file-validator.js +285 -285
  118. package/src/utils/preview-list-prompt.js +144 -144
  119. package/src/utils/provider-validator.js +62 -12
  120. package/src/utils/secure-music-storage.js +412 -412
  121. package/voice-assignments.json +8244 -8244
  122. package/.claude/commands/agent-vibes/language.md +0 -23
  123. package/.claude/commands/agent-vibes/learn.md +0 -67
  124. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  125. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  126. package/.claude/commands/agent-vibes/target.md +0 -30
  127. package/.claude/hooks/learn-manager.sh +0 -492
  128. package/.claude/hooks/replay-target-audio.sh +0 -95
  129. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
6
+
7
+ A receiver normally learns an avatar only when it speaks (one /speak event per
8
+ line). This doorbell stages the WHOLE cast up front: it resolves the active
9
+ party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
10
+ its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
11
+ endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
12
+ can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
13
+ down/absent one) simply never hears the doorbell and the party runs unchanged.
14
+
15
+ Session key
16
+ -----------
17
+ The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
18
+ ``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
19
+ way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
20
+ ``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
21
+ on its OWN session, distinct from the plain-TTS session for the same project.
22
+
23
+ Open-cast vs fixed-cast (the forward-compat contract)
24
+ -----------------------------------------------------
25
+ * Fixed-cast (members present): POST
26
+ {"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
27
+ * Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
28
+ members, only a freeform ``scene`` theme): POST
29
+ {"session","project","roster":[...anchors if any...],
30
+ "openCast":true,"room":{"id","name","scene"}}
31
+ Open-cast rooms are broadcast rather than skipped, so a receiver that
32
+ understands the room theme has everything it needs to render it.
33
+
34
+ ``project`` is sent as a back-compat alias of ``session`` (current receivers key
35
+ on ``project``).
36
+
37
+ Best-effort contract
38
+ --------------------
39
+ Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
40
+ timeout -- prints a benign note to stderr and returns 0. A down or absent
41
+ receiver must NEVER block or fail the party.
42
+
43
+ party-stage-roster.py --project-root P [--skill S] [--party ID]
44
+ [--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
45
+ """
46
+
47
+ import argparse
48
+ import json
49
+ import os
50
+ import re
51
+ import subprocess
52
+ import sys
53
+ import urllib.request
54
+ import urllib.error
55
+ from pathlib import Path
56
+
57
+
58
+ def _resolve_roster(project_root: Path, skill_root: Path, party):
59
+ """Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
60
+ script = skill_root / "scripts" / "resolve_party.py"
61
+ cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
62
+ if party:
63
+ cmd += ["--party", party]
64
+ try:
65
+ # encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
66
+ # which crashes on the non-Latin icon bytes the resolver can emit.
67
+ # PYTHONUTF8 forces the child to write UTF-8 rather than crash.
68
+ child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
69
+ out = subprocess.run(cmd, capture_output=True, text=True,
70
+ encoding="utf-8", errors="replace",
71
+ env=child_env, timeout=60)
72
+ except (OSError, subprocess.SubprocessError) as e:
73
+ sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
74
+ return None
75
+ if out.returncode != 0 or not out.stdout.strip():
76
+ sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
77
+ return None
78
+ try:
79
+ return json.loads(out.stdout)
80
+ except json.JSONDecodeError:
81
+ sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
82
+ return None
83
+
84
+
85
+ def _home_dir() -> Path:
86
+ """User home, resilient to the launch environment.
87
+
88
+ os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
89
+ HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
90
+ hand these scripts. Check the vars actually present, Windows-then-POSIX.
91
+ """
92
+ for var in ("USERPROFILE", "HOME"):
93
+ v = os.environ.get(var)
94
+ if v:
95
+ return Path(v)
96
+ hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
97
+ if hd and hp:
98
+ return Path(hd + hp)
99
+ return Path(os.path.expanduser("~"))
100
+
101
+
102
+ def _find_skill_root(project_root: Path):
103
+ """Locate the installed bmad-party-mode skill dir (project-local, else global).
104
+
105
+ Returns a Path whose scripts/resolve_party.py exists, or None.
106
+ """
107
+ candidates = [
108
+ project_root / ".claude" / "skills" / "bmad-party-mode",
109
+ _home_dir() / ".claude" / "skills" / "bmad-party-mode",
110
+ ]
111
+ for c in candidates:
112
+ if (c / "scripts" / "resolve_party.py").exists():
113
+ return c
114
+ return None
115
+
116
+
117
+ def _load_json_map(name: str) -> dict:
118
+ """Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
119
+ path = _home_dir() / ".agentvibes" / name
120
+ try:
121
+ with path.open(encoding="utf-8") as f:
122
+ data = json.load(f)
123
+ except (OSError, json.JSONDecodeError):
124
+ return {}
125
+ return data if isinstance(data, dict) else {}
126
+
127
+
128
+ def _voice_for(code: str, vmap: dict) -> str:
129
+ """Saved voice string for an agent code, or '' if none.
130
+
131
+ Prefers the richer agents[code].voice; falls back to the flat
132
+ voiceMap[code] (older shape). Both key by the canonical agent code.
133
+ """
134
+ agents = vmap.get("agents") or {}
135
+ entry = agents.get(code)
136
+ if isinstance(entry, dict) and entry.get("voice"):
137
+ return str(entry["voice"])
138
+ flat = vmap.get("voiceMap") or {}
139
+ if isinstance(flat.get(code), str):
140
+ return flat[code]
141
+ return ""
142
+
143
+
144
+ def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
145
+ """Saved avatarId for an agent code, or '' if none.
146
+
147
+ The dedicated avatar map wins; the voice map's agents[id].avatarId is a
148
+ legacy fallback (honoured if present).
149
+ """
150
+ ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
151
+ entry = ag.get(code)
152
+ if isinstance(entry, dict) and entry.get("avatarId"):
153
+ return str(entry["avatarId"])
154
+ agents = vmap.get("agents") or {}
155
+ ventry = agents.get(code)
156
+ if isinstance(ventry, dict) and ventry.get("avatarId"):
157
+ return str(ventry["avatarId"])
158
+ return ""
159
+
160
+
161
+ def _session_key(project_root: Path) -> str:
162
+ """Canonical routing SESSION id -- the same one the TTS forward paths send.
163
+
164
+ Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
165
+ CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
166
+ slugified so the id is a safe JSON string and a stable receiver map key.
167
+ """
168
+ cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
169
+ base = Path(cpd) if cpd else project_root
170
+ name = base.name or "unknown"
171
+ # Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
172
+ # to '-', then trim leading/trailing dashes.
173
+ name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
174
+ return name or "unknown"
175
+
176
+
177
+ def _active_party(session_key: str, explicit):
178
+ """Resolve which room to stage.
179
+
180
+ Explicit --party wins. Otherwise read the AgentVibes-side active-room state
181
+ file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
182
+ by party-set-room.sh). If neither is present, return None so the DEFAULT
183
+ party resolves (the common installed-agent room).
184
+ """
185
+ if explicit:
186
+ return explicit
187
+ path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
188
+ try:
189
+ with path.open(encoding="utf-8") as f:
190
+ data = json.load(f)
191
+ except (OSError, json.JSONDecodeError):
192
+ return None
193
+ if isinstance(data, dict):
194
+ group = data.get("group")
195
+ if isinstance(group, str) and group.strip():
196
+ return group.strip()
197
+ return None
198
+
199
+
200
+ def build_roster(resolved: dict, vmap: dict, amap: dict):
201
+ """Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
202
+ members = resolved.get("members") or []
203
+ roster, skipped = [], []
204
+ for m in members:
205
+ code = m.get("code") or ""
206
+ name = m.get("name") or code or "Agent"
207
+ voice = _voice_for(code, vmap)
208
+ if not voice:
209
+ # Without a voice string the member can't key-match a later /speak
210
+ # event, so staging it would orphan an avatar -- skip it.
211
+ skipped.append(name)
212
+ continue
213
+ member = {"name": name, "agentId": code, "voice": voice}
214
+ title = (m.get("title") or "").strip()
215
+ if title:
216
+ member["role"] = title
217
+ avatar = _avatar_for(code, vmap, amap)
218
+ if avatar:
219
+ member["avatarId"] = avatar
220
+ roster.append(member)
221
+ return roster, skipped
222
+
223
+
224
+ def post_payload(url: str, payload: dict):
225
+ """POST the payload to /stage-roster. Returns (ok, message)."""
226
+ body = json.dumps(payload).encode("utf-8")
227
+ base = url.rstrip("/")
228
+ req = urllib.request.Request(
229
+ base + "/stage-roster",
230
+ data=body,
231
+ headers={"Content-Type": "application/json", "Origin": base},
232
+ method="POST",
233
+ )
234
+ try:
235
+ with urllib.request.urlopen(req, timeout=10) as resp:
236
+ return True, resp.read().decode("utf-8", "replace")
237
+ except urllib.error.HTTPError as e:
238
+ return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
239
+ except (urllib.error.URLError, OSError) as e:
240
+ # Receiver not running / unreachable -- benign; the party runs regardless.
241
+ return False, f"could not reach receiver ({e}); is a receiver up?"
242
+
243
+
244
+ def _default_url() -> str:
245
+ port = (os.environ.get("AGENTVIBES_TH_PORT")
246
+ or os.environ.get("TALKING_HEAD_PORT")
247
+ or "3747").strip()
248
+ if not port.isdigit():
249
+ port = "3747"
250
+ return f"http://127.0.0.1:{port}"
251
+
252
+
253
+ def main() -> int:
254
+ ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
255
+ ap.add_argument("--project-root", required=True)
256
+ ap.add_argument("--skill", default=None,
257
+ help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
258
+ ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
259
+ ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
260
+ default="bmad-party-mode",
261
+ help="Append '-<suffix>' to the session key so the party stages on its "
262
+ "OWN session (default: bmad-party-mode).")
263
+ ap.add_argument("--url", default=None)
264
+ args = ap.parse_args()
265
+
266
+ project_root = Path(args.project_root).resolve()
267
+
268
+ skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
269
+ if skill_root is None:
270
+ sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
271
+ return 0
272
+
273
+ session = _session_key(project_root)
274
+ party = _active_party(session, args.party)
275
+
276
+ resolved = _resolve_roster(project_root, skill_root, party)
277
+ if resolved is None:
278
+ return 0 # resolver failure already warned; never break the party
279
+
280
+ suffix = (args.session_suffix or "").strip().strip("-")
281
+ session_key = f"{session}-{suffix}" if suffix else session
282
+
283
+ vmap = _load_json_map("bmad-voice-map.json")
284
+ amap = _load_json_map("bmad-avatar-map.json")
285
+ roster, skipped = build_roster(resolved, vmap, amap)
286
+ if skipped:
287
+ sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
288
+
289
+ open_cast = bool(resolved.get("open_cast"))
290
+
291
+ if open_cast:
292
+ # Broadcast open-cast rooms rather than skipping them: the room theme is
293
+ # all a receiver needs to render the scene.
294
+ room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
295
+ scene = resolved.get("scene")
296
+ if scene:
297
+ room["scene"] = scene
298
+ payload = {"session": session_key, "project": session_key,
299
+ "roster": roster, "openCast": True, "room": room}
300
+ else:
301
+ if not roster:
302
+ sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
303
+ return 0
304
+ payload = {"session": session_key, "project": session_key, "roster": roster}
305
+
306
+ # Test hook: capture the exact payload to a file instead of hitting the
307
+ # network, so the hermetic bats suite can assert the wire shape.
308
+ capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
309
+ if capture:
310
+ try:
311
+ Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
312
+ except OSError as e:
313
+ sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
314
+ print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
315
+ return 0
316
+
317
+ url = args.url or _default_url()
318
+ ok, msg = post_payload(url, payload)
319
+ kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
320
+ if ok:
321
+ print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
322
+ else:
323
+ print(f"stage-roster: {msg}")
324
+ return 0 # benign: a down receiver must never block the party
325
+
326
+
327
+ if __name__ == "__main__":
328
+ sys.exit(main())
@@ -62,6 +62,17 @@ fi
62
62
 
63
63
  PERSONALITY_FILE="$CLAUDE_DIR/tts-personality.txt"
64
64
 
65
+ # Source the generated Provider Catalog (bash-3.2-safe SSOT accessors) when present.
66
+ # FAIL-SAFE: consumers probe `type catalog_* >/dev/null 2>&1` and fall back to the
67
+ # legacy hardcoded literal when the artifact is missing (installed-tree skew) —
68
+ # mirrors voice-manager.sh's identical pattern (AVI-S9.3/9.4).
69
+ _load_provider_catalog() {
70
+ if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
71
+ # shellcheck source=/dev/null
72
+ source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
73
+ fi
74
+ }
75
+
65
76
  # Function to get personality data from markdown file
66
77
  get_personality_data() {
67
78
  local personality="$1"
@@ -200,8 +211,14 @@ case "$1" in
200
211
  # Try to get Piper-specific voice first
201
212
  ASSIGNED_VOICE=$(get_personality_data "$PERSONALITY" "piper_voice")
202
213
  if [[ -z "$ASSIGNED_VOICE" ]]; then
203
- # Fallback to default Piper voice
204
- ASSIGNED_VOICE="en_US-lessac-medium"
214
+ # Fallback to default Piper voice — sourced from the generated Provider
215
+ # Catalog SSOT (AVI-S9.4). FAIL-SAFE: legacy literal if artifact missing.
216
+ _load_provider_catalog
217
+ if type catalog_default_voice >/dev/null 2>&1; then
218
+ ASSIGNED_VOICE="$(catalog_default_voice piper)"
219
+ else
220
+ ASSIGNED_VOICE="en_US-lessac-medium"
221
+ fi
205
222
  fi
206
223
  else
207
224
  # Use Piper voice (reads from piper_voice: field)
@@ -296,6 +296,7 @@ patch_libritts_speaker_names() {
296
296
  # Find voice-assignments.json relative to this script (SCRIPT_DIR/../.. = project root)
297
297
  local script_dir
298
298
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
299
+ source "$script_dir/python-resolver.sh"
299
300
  local project_root
300
301
  project_root="$(cd "$script_dir/../.." 2>/dev/null && pwd)"
301
302
  local catalog="$project_root/voice-assignments.json"
@@ -315,8 +316,8 @@ patch_libritts_speaker_names() {
315
316
  fi
316
317
 
317
318
  # Check if already patched (first key doesn't start with 'p' + digits)
318
- if command -v python3 &>/dev/null; then
319
- python3 -c "
319
+ if [[ -n "$PYTHON_BIN" ]]; then
320
+ "$PYTHON_BIN" -c "
320
321
  import json, sys
321
322
 
322
323
  json_path = sys.argv[1]
@@ -14,7 +14,7 @@
14
14
  set -euo pipefail
15
15
 
16
16
  TEXT="${1:-}"
17
- VOICE="${2:-en_US-lessac-medium}"
17
+ VOICE="${2:-}"
18
18
  AGENT_NAME="${3:-default}"
19
19
 
20
20
  # Validate required input
@@ -26,8 +26,27 @@ fi
26
26
 
27
27
  # Get script directory
28
28
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
29
+ source "$SCRIPT_DIR/python-resolver.sh"
29
30
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
30
31
 
32
+ # Default voice: prefer the Provider Catalog's piper default (AVI-S9.6 AC3,
33
+ # design row 23), falling back to the legacy literal if the generated catalog
34
+ # artifact is missing (installed-tree skew). NOTE: any voice-format allowlist
35
+ # added to this receiver in the future must stay independent of the catalog —
36
+ # a security allowlist is deliberately narrower than the full inventory (see
37
+ # provider-catalog.js module header).
38
+ if [[ -z "$VOICE" ]]; then
39
+ if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
40
+ # shellcheck source=/dev/null
41
+ source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
42
+ fi
43
+ if type catalog_default_voice >/dev/null 2>&1; then
44
+ VOICE="$(catalog_default_voice piper)"
45
+ else
46
+ VOICE="en_US-lessac-medium" # legacy fallback
47
+ fi
48
+ fi
49
+
31
50
  # ---------------------------------------------------------------------------
32
51
  # Get SSH connection details from config
33
52
  # Prefer ~/.agentvibes/transport-config.json, fall back to legacy host file
@@ -38,10 +57,10 @@ SSH_KEY=""
38
57
  SSH_PORT=""
39
58
 
40
59
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
41
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
42
- SSH_HOST=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('host',''))" 2>/dev/null || echo "")
43
- SSH_KEY=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
44
- SSH_PORT=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('port',''))" 2>/dev/null || echo "")
60
+ if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
61
+ SSH_HOST=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('host',''))" 2>/dev/null || echo "")
62
+ SSH_KEY=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
63
+ SSH_PORT=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('port',''))" 2>/dev/null || echo "")
45
64
  fi
46
65
 
47
66
  if [[ -z "$SSH_HOST" ]]; then
@@ -16,7 +16,8 @@
16
16
  # @related play-tts.sh, provider-manager.sh, language-manager.sh
17
17
  #
18
18
  # Voice can be a name (e.g. "Rachel") or a raw ElevenLabs voice ID.
19
- # Set ELEVENLABS_API_KEY in your shell profile or via Infisical.
19
+ # Set ELEVENLABS_API_KEY in your shell profile, or store it in
20
+ # ~/.agentvibes/elevenlabs-key.txt (chmod 600).
20
21
  # Default voice: Rachel (English, warm female)
21
22
  #
22
23
 
@@ -31,6 +32,7 @@ SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
31
32
  SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
32
33
 
33
34
  source "$SCRIPT_DIR/audio-cache-utils.sh"
35
+ source "$SCRIPT_DIR/python-resolver.sh"
34
36
  source "$SCRIPT_DIR/language-manager.sh"
35
37
 
36
38
  if [[ -z "$TEXT" ]]; then
@@ -44,58 +46,7 @@ if [[ ${#TEXT} -gt 2000 ]]; then
44
46
  fi
45
47
 
46
48
  # ---------------------------------------------------------------------------
47
- # Optional: fetch the key from Infisical on demand, so no key is stored on disk.
48
- # Opt-in via ~/.agentvibes/infisical.env, which sets (env vars take precedence):
49
- # INFISICAL_SECRET_NAME name of the secret in Infisical (e.g. ELEVEN_LABS)
50
- # INFISICAL_PROJECT_ID project/workspace id
51
- # INFISICAL_ENV environment slug (default: prod)
52
- # INFISICAL_DOMAIN API base URL (default: http://127.0.0.1:8200/api)
53
- # INFISICAL_BOOTSTRAP universal-auth creds file (default: ~/.palace-bootstrap)
54
- # Requires the infisical CLI + python3. Fails soft: prints nothing and returns
55
- # non-zero when unconfigured or unreachable, so the normal error path still runs.
56
- # The fetched value is never printed.
57
- _fetch_key_from_infisical() {
58
- local cfg="${HOME}/.agentvibes/infisical.env"
59
- if [[ -f "$cfg" ]]; then
60
- set -o allexport; source "$cfg"; set +o allexport
61
- fi
62
-
63
- local secret_name="${INFISICAL_SECRET_NAME:-}"
64
- local project_id="${INFISICAL_PROJECT_ID:-}"
65
- local env_name="${INFISICAL_ENV:-prod}"
66
- local domain="${INFISICAL_DOMAIN:-http://127.0.0.1:8200/api}"
67
- local bootstrap="${INFISICAL_BOOTSTRAP:-${HOME}/.palace-bootstrap}"
68
-
69
- [[ -n "$secret_name" && -n "$project_id" ]] || return 1
70
- command -v infisical >/dev/null 2>&1 || return 1
71
- command -v python3 >/dev/null 2>&1 || return 1
72
-
73
- # Obtain an access token: reuse an exported INFISICAL_TOKEN, else exchange the
74
- # universal-auth client credentials from the bootstrap file (creds via env only).
75
- local token="${INFISICAL_TOKEN:-}"
76
- if [[ -z "$token" ]]; then
77
- [[ -f "$bootstrap" ]] || return 1
78
- set -o allexport; source "$bootstrap"; set +o allexport
79
- [[ -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" && -n "${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" ]] || return 1
80
- token="$(_AV_DOMAIN="$domain" python3 -c '
81
- import os, json, urllib.request
82
- d = os.environ["_AV_DOMAIN"].rstrip("/")
83
- req = urllib.request.Request(d + "/v1/auth/universal-auth/login",
84
- data=json.dumps({"clientId": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"],
85
- "clientSecret": os.environ["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]}).encode(),
86
- headers={"Content-Type": "application/json"})
87
- print(json.loads(urllib.request.urlopen(req, timeout=10).read())["accessToken"])
88
- ' 2>/dev/null || true)"
89
- fi
90
- [[ -n "$token" ]] || return 1
91
-
92
- INFISICAL_TOKEN="$token" infisical secrets get "$secret_name" \
93
- --projectId "$project_id" --env "$env_name" --domain "$domain" \
94
- --plain 2>/dev/null | head -1
95
- }
96
-
97
- # ---------------------------------------------------------------------------
98
- # API key — env var (only if it looks valid), then key file, then Infisical.
49
+ # API key env var (only if it looks valid), then key file.
99
50
  # A malformed/truncated ELEVENLABS_API_KEY (e.g. a half-pasted key) is ignored so
100
51
  # it can't silently shadow a good key file — a common foot-gun that yields
101
52
  # confusing "API key fail" errors despite a valid key on disk.
@@ -122,91 +73,55 @@ if [[ -z "$API_KEY" ]]; then
122
73
  API_KEY="$(tr -d '[:space:]' < "$_key_file")"
123
74
  fi
124
75
  fi
125
- if [[ -z "$API_KEY" ]]; then
126
- API_KEY="$(_fetch_key_from_infisical || true)"
127
- API_KEY="$(printf '%s' "$API_KEY" | tr -d '[:space:]')"
128
- fi
129
76
 
130
77
  if [[ -z "$API_KEY" ]]; then
131
78
  echo "❌ ElevenLabs API key not set." >&2
132
- echo " Set it one of three ways:" >&2
79
+ echo " Set it one of two ways:" >&2
133
80
  echo " 1. export ELEVENLABS_API_KEY=your_key (add to ~/.bashrc or ~/.zshrc)" >&2
134
81
  echo " 2. echo 'your_key' > ~/.agentvibes/elevenlabs-key.txt && chmod 600 ~/.agentvibes/elevenlabs-key.txt" >&2
135
- echo " 3. Configure Infisical in ~/.agentvibes/infisical.env (INFISICAL_SECRET_NAME + INFISICAL_PROJECT_ID)" >&2
136
82
  echo " Get a free key at: https://elevenlabs.io" >&2
137
83
  exit 2
138
84
  fi
139
85
 
140
86
  # ---------------------------------------------------------------------------
141
- # Inline voice name ID map (covers common built-in ElevenLabs voices)
142
- declare -A VOICE_IDS
143
- VOICE_IDS["Rachel"]="21m00Tcm4TlvDq8ikWAM"
144
- VOICE_IDS["Adam"]="pNInz6obpgDQGcFmaJgB"
145
- VOICE_IDS["Antoni"]="ErXwobaYiN019PkySvjV"
146
- VOICE_IDS["Arnold"]="VR6AewLTigWG4xSOukaG"
147
- VOICE_IDS["Bella"]="EXAVITQu4vr4xnSDxMaL"
148
- VOICE_IDS["Callum"]="N2lVS1w4EtoT3dr4eOWO"
149
- VOICE_IDS["Charlie"]="IKne3meq5aSn9XLyUdCD"
150
- VOICE_IDS["Charlotte"]="XB0fDUnXU5powFXDhCwa"
151
- VOICE_IDS["Clyde"]="2EiwWnXFnvU5JabPnv8n"
152
- VOICE_IDS["Daniel"]="onwK4e9ZLuTAKqWW03F9"
153
- VOICE_IDS["Dave"]="CYw3kZ02Hs0563khs1Fj"
154
- VOICE_IDS["Dorothy"]="ThT5KcBeYPX3keUQqHPh"
155
- VOICE_IDS["Domi"]="AZnzlk1XvdvUeBnXmlld"
156
- VOICE_IDS["Drew"]="29vD33N1CtxCmqQRPOHJ"
157
- VOICE_IDS["Emily"]="LcfcDJNUP1GQjkzn1xUU"
158
- VOICE_IDS["Ethan"]="g5CIjZEefAph4nQFvHAz"
159
- VOICE_IDS["Fin"]="D38z5RcWu1voky8WS1ja"
160
- VOICE_IDS["Freya"]="jsCqWAovK2LkecY7zXl4"
161
- VOICE_IDS["Gigi"]="jBpfuIE2acCO8z3wKNLl"
162
- VOICE_IDS["Giovanni"]="zcAOhNBS3c14rBihAFp1"
163
- VOICE_IDS["Glinda"]="z9fAnlkpzviPz146aGWa"
164
- VOICE_IDS["Grace"]="oWAxZDx7w5VEj9dCyTzz"
165
- VOICE_IDS["Harry"]="SOYHLrjzK2X1ezoPC6cr"
166
- VOICE_IDS["James"]="ZQe5CZNOzWyzPSCn5a3c"
167
- VOICE_IDS["Jessie"]="t0jbNlBVZ17f02VDIeMI"
168
- VOICE_IDS["Josh"]="TxGEqnHWrfWFTfGW9XjX"
169
- VOICE_IDS["Liam"]="TX3LPaxmHKxFdv7VOQHJ"
170
- VOICE_IDS["Lily"]="pFZP5JQG7iQjIQuC4Bku"
171
- VOICE_IDS["Matilda"]="XrExE9yKIg1WjnnlVkGX"
172
- VOICE_IDS["Michael"]="flq6f7yk4E4fJM5XTYuZ"
173
- VOICE_IDS["Mimi"]="zrHiDhphv9ZnVXBqCLjz"
174
- VOICE_IDS["Nicole"]="piTKgcLEGmPE4e6mEKli"
175
- VOICE_IDS["Patrick"]="ODq5zmih8GrVes37Dizd"
176
- VOICE_IDS["Paul"]="5Q0t7uMcjvnagumLfvZi"
177
- VOICE_IDS["Sam"]="yoZ06aMxZJJ28mfd3POQ"
178
- VOICE_IDS["Sarah"]="EXAVITQu4vr4xnSDxMaL"
179
- VOICE_IDS["Serena"]="pMsXgVXv3BLzUgSXRplE"
180
- VOICE_IDS["Thomas"]="GBv7mTt0atIp3Br8iCZE"
181
-
182
- DEFAULT_VOICE_ID="${VOICE_IDS[Rachel]}"
87
+ # ElevenLabs voice catalog single source of truth (shared with voice-manager.sh).
88
+ # Provides ELEVENLABS_DEFAULT_VOICE, elevenlabs_resolve_voice(), elevenlabs_voice_names().
89
+ if [[ -f "$SCRIPT_DIR/elevenlabs-voices.sh" ]]; then
90
+ # shellcheck source=/dev/null
91
+ source "$SCRIPT_DIR/elevenlabs-voices.sh"
92
+ else
93
+ # Minimal fallback so speech still works if the catalog file is missing.
94
+ # bash-3.2-safe: no associative array, case-statement resolver.
95
+ ELEVENLABS_DEFAULT_VOICE="Sarah"
96
+ elevenlabs_resolve_voice() {
97
+ local q="${1:-}"; [[ -z "$q" ]] && return 1
98
+ case "$(printf '%s' "$q" | tr '[:upper:]' '[:lower:]')" in
99
+ sarah) printf %s "EXAVITQu4vr4xnSDxMaL"; return 0 ;;
100
+ esac
101
+ [[ "$q" =~ ^[A-Za-z0-9]{20}$ ]] && { printf %s "$q"; return 0; }
102
+ return 1
103
+ }
104
+ fi
105
+ DEFAULT_VOICE_ID="$(elevenlabs_resolve_voice "$ELEVENLABS_DEFAULT_VOICE")"
183
106
 
184
107
  # ---------------------------------------------------------------------------
185
108
  # Resolve voice ID from override or config
186
109
  VOICE_ID=""
187
110
 
188
111
  if [[ -n "$VOICE_OVERRIDE" ]]; then
189
- if [[ -n "${VOICE_IDS[$VOICE_OVERRIDE]:-}" ]]; then
190
- VOICE_ID="${VOICE_IDS[$VOICE_OVERRIDE]}"
191
- elif [[ "$VOICE_OVERRIDE" =~ ^[a-zA-Z0-9]{10,40}$ ]]; then
192
- # Looks like a raw voice ID
193
- VOICE_ID="$VOICE_OVERRIDE"
194
- else
195
- echo "⚠️ Unknown ElevenLabs voice '$VOICE_OVERRIDE', using Rachel" >&2
112
+ VOICE_ID="$(elevenlabs_resolve_voice "$VOICE_OVERRIDE")" || {
113
+ echo "[WARN] Unknown ElevenLabs voice '$VOICE_OVERRIDE', using $ELEVENLABS_DEFAULT_VOICE" >&2
196
114
  VOICE_ID="$DEFAULT_VOICE_ID"
197
- fi
115
+ }
198
116
  else
199
- # Check voice manager for configured voice
117
+ # Config path: read the voice saved by /agent-vibes:switch (a raw voice_id or
118
+ # a friendly name) and resolve it the same way — this is what hook-driven
119
+ # speech uses, so a switched voice actually plays (not a silent default).
200
120
  VOICE_NAME=""
201
121
  if [[ -f "$SCRIPT_DIR/voice-manager.sh" ]]; then
202
122
  VOICE_NAME="$("$SCRIPT_DIR/voice-manager.sh" get 2>/dev/null || true)"
203
123
  fi
204
-
205
- if [[ -n "$VOICE_NAME" && -n "${VOICE_IDS[$VOICE_NAME]:-}" ]]; then
206
- VOICE_ID="${VOICE_IDS[$VOICE_NAME]}"
207
- else
208
- VOICE_ID="$DEFAULT_VOICE_ID"
209
- fi
124
+ VOICE_ID="$(elevenlabs_resolve_voice "$VOICE_NAME")" || VOICE_ID="$DEFAULT_VOICE_ID"
210
125
  fi
211
126
 
212
127
  # ---------------------------------------------------------------------------
@@ -246,7 +161,12 @@ trap 'rm -f "${TEMP_FILE:-}" "${BODY_FILE:-}" 2>/dev/null || true' EXIT
246
161
  # ---------------------------------------------------------------------------
247
162
  # Build the JSON request body with python so all escaping is handled correctly
248
163
  # (text is passed via env, never interpolated into a shell-quoted payload).
249
- if ! TTS_TEXT="$TEXT" TTS_MODEL="$MODEL_ID" TTS_LANG="$LANGUAGE_CODE" python3 -c '
164
+ if [[ -z "$PYTHON_BIN" ]]; then
165
+ echo "❌ ElevenLabs needs Python 3, but none was found." >&2
166
+ echo " Install Python 3, or set AGENTVIBES_PYTHON=/path/to/python." >&2
167
+ exit 3
168
+ fi
169
+ if ! TTS_TEXT="$TEXT" TTS_MODEL="$MODEL_ID" TTS_LANG="$LANGUAGE_CODE" "$PYTHON_BIN" -c '
250
170
  import os, json
251
171
  print(json.dumps({
252
172
  "text": os.environ["TTS_TEXT"],
@@ -254,7 +174,7 @@ print(json.dumps({
254
174
  "language_code": os.environ["TTS_LANG"],
255
175
  "voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
256
176
  }))' > "$BODY_FILE" 2>/dev/null; then
257
- echo "❌ Failed to build ElevenLabs request body (python3 required)" >&2
177
+ echo "❌ Failed to build ElevenLabs request body ($PYTHON_BIN required)" >&2
258
178
  exit 3
259
179
  fi
260
180