agentvibes 5.12.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 (72) hide show
  1. package/.claude/commands/agent-vibes/commands.json +0 -20
  2. package/.claude/commands/agent-vibes/unmute.md +6 -2
  3. package/.claude/config/audio-effects.cfg +6 -6
  4. package/.claude/github-star-reminder.txt +1 -1
  5. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  6. package/.claude/hooks/bmad-party-speak.sh +20 -4
  7. package/.claude/hooks/bmad-speak.sh +60 -2
  8. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  9. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  11. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  12. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  13. package/.claude/hooks/kokoro-installer.sh +20 -10
  14. package/.claude/hooks/language-manager.sh +10 -3
  15. package/.claude/hooks/party-set-room.sh +71 -0
  16. package/.claude/hooks/party-stage-roster.py +328 -0
  17. package/.claude/hooks/personality-manager.sh +19 -2
  18. package/.claude/hooks/piper-voice-manager.sh +3 -2
  19. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  20. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  21. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  22. package/.claude/hooks/play-tts-soprano.sh +3 -2
  23. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  24. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  25. package/.claude/hooks/play-tts.sh +66 -61
  26. package/.claude/hooks/provider-catalog.json +352 -0
  27. package/.claude/hooks/provider-catalog.sh +161 -0
  28. package/.claude/hooks/provider-commands.sh +2 -1
  29. package/.claude/hooks/provider-manager.sh +47 -9
  30. package/.claude/hooks/python-resolver.sh +117 -0
  31. package/.claude/hooks/session-id.sh +56 -0
  32. package/.claude/hooks/session-start-tts.sh +39 -0
  33. package/.claude/hooks/speed-manager.sh +1 -1
  34. package/.claude/hooks/translate-manager.sh +3 -2
  35. package/.claude/hooks/translator.py +1 -1
  36. package/.claude/hooks/voice-manager.sh +242 -10
  37. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  38. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  39. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  40. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  41. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  42. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  43. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  44. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  45. package/.mcp.json +0 -7
  46. package/README.md +12 -3
  47. package/RELEASE_NOTES.md +43 -0
  48. package/mcp-server/server.py +146 -49
  49. package/mcp-server/test_mcp_correctness.py +20 -2
  50. package/mcp-server/test_windows_script_parity.py +0 -2
  51. package/package.json +1 -1
  52. package/src/cli/list-voices.js +218 -114
  53. package/src/console/bling.js +71 -0
  54. package/src/console/music-preview.js +79 -0
  55. package/src/console/tabs/music-tab.js +16 -39
  56. package/src/console/tabs/settings-tab.js +195 -13
  57. package/src/console/tabs/setup-tab.js +9 -34
  58. package/src/console/tabs/voices-tab.js +83 -14
  59. package/src/console/widgets/track-picker.js +82 -0
  60. package/src/installer.js +124 -10
  61. package/src/services/provider-catalog.js +412 -0
  62. package/src/services/provider-voice-catalog.js +52 -73
  63. package/src/services/tts-engine-service.js +29 -0
  64. package/src/utils/provider-validator.js +62 -12
  65. package/.claude/commands/agent-vibes/language.md +0 -23
  66. package/.claude/commands/agent-vibes/learn.md +0 -67
  67. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  68. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  69. package/.claude/commands/agent-vibes/target.md +0 -30
  70. package/.claude/hooks/learn-manager.sh +0 -492
  71. package/.claude/hooks/replay-target-audio.sh +0 -95
  72. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -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
 
@@ -31,28 +31,55 @@ SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
31
31
  SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
32
32
 
33
33
  source "$SCRIPT_DIR/audio-cache-utils.sh"
34
+ source "$SCRIPT_DIR/python-resolver.sh"
34
35
 
35
36
  if [[ -z "$TEXT" ]]; then
36
37
  echo "Usage: $0 \"text to speak\" [voice]" >&2
37
38
  exit 1
38
39
  fi
39
40
 
41
+ # Distinguish "no Python at all" from "Python present but kokoro module missing"
42
+ # — the old check conflated them, so a Windows box where python3 simply wasn't on
43
+ # PATH wrongly reported "Kokoro not installed" (see python-resolver.sh).
44
+ if [[ -z "$PYTHON_BIN" ]]; then
45
+ echo "❌ No Python 3 interpreter found." >&2
46
+ echo " Kokoro needs Python 3. Install it (Windows: https://python.org, or 'py')," >&2
47
+ echo " or set AGENTVIBES_PYTHON=/path/to/python.exe if it's installed elsewhere." >&2
48
+ exit 2
49
+ fi
40
50
  # Check kokoro is installed (use find_spec to avoid slow torch import)
41
- if ! python3 -c "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)" 2>/dev/null; then
42
- echo "❌ Kokoro TTS not installed." >&2
51
+ if ! "$PYTHON_BIN" -c "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)" 2>/dev/null; then
52
+ echo "❌ Kokoro TTS module not installed for: $PYTHON_BIN" >&2
43
53
  echo " Install with: ${SCRIPT_DIR}/kokoro-installer.sh" >&2
44
- echo " Or manually: pip install kokoro-onnx soundfile numpy" >&2
54
+ echo " Or manually: \"$PYTHON_BIN\" -m pip install kokoro soundfile numpy" >&2
45
55
  exit 2
46
56
  fi
47
57
 
48
58
  # ---------------------------------------------------------------------------
49
59
  # Resolve voice
60
+ # Default voice sourced from the generated Provider Catalog (SSOT) when present.
61
+ # FAIL-SAFE: keep the literal fallback for installed-tree skew (mirrors play-tts.sh
62
+ # PLAN_OK) — synth never breaks on a missing catalog.
50
63
  DEFAULT_VOICE="af_heart"
64
+ if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
65
+ # shellcheck source=/dev/null
66
+ source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
67
+ if type catalog_default_voice >/dev/null 2>&1; then
68
+ _CATALOG_DEFAULT="$(catalog_default_voice kokoro 2>/dev/null || true)"
69
+ [[ -n "$_CATALOG_DEFAULT" ]] && DEFAULT_VOICE="$_CATALOG_DEFAULT"
70
+ fi
71
+ fi
72
+
73
+ # Synth-time shape check (LENIENT per design §3.3): a voice from a NEWER kokoro
74
+ # model that the shipped catalog doesn't know must still degrade audibly, not
75
+ # brick — so this stays a shape check + loud-warning fallback, NOT membership.
76
+ # One literal, reused at both call sites below.
77
+ KOKORO_SHAPE_RE='^[a-z]{2}_[a-z0-9_]+$'
51
78
 
52
79
  if [[ -n "$VOICE_OVERRIDE" ]]; then
53
80
  # Accept kokoro-style voice IDs (2-letter prefix + underscore + name)
54
81
  # e.g. af_heart, am_adam, bf_emma, bm_george
55
- if [[ "$VOICE_OVERRIDE" =~ ^[a-z]{2}_[a-z0-9_]+$ ]]; then
82
+ if [[ "$VOICE_OVERRIDE" =~ $KOKORO_SHAPE_RE ]]; then
56
83
  VOICE="$VOICE_OVERRIDE"
57
84
  else
58
85
  echo "⚠️ Unrecognized kokoro voice '$VOICE_OVERRIDE', using $DEFAULT_VOICE" >&2
@@ -62,7 +89,7 @@ else
62
89
  # Check voice manager
63
90
  if [[ -f "$SCRIPT_DIR/voice-manager.sh" ]]; then
64
91
  _CONFIGURED="$("$SCRIPT_DIR/voice-manager.sh" get 2>/dev/null || true)"
65
- if [[ "$_CONFIGURED" =~ ^[a-z]{2}_[a-z0-9_]+$ ]]; then
92
+ if [[ "$_CONFIGURED" =~ $KOKORO_SHAPE_RE ]]; then
66
93
  VOICE="$_CONFIGURED"
67
94
  else
68
95
  VOICE="$DEFAULT_VOICE"
@@ -103,7 +130,7 @@ if [[ "${AGENTVIBES_TEST_MODE:-false}" == "true" ]]; then
103
130
  fi
104
131
 
105
132
  # Run synthesis — output path printed to stdout
106
- RESULT=$(python3 "$SYNTH_SCRIPT" "$TEXT" "$VOICE" "$TEMP_WAV" "$SPEED" 2>&1) || {
133
+ RESULT=$("$PYTHON_BIN" "$SYNTH_SCRIPT" "$TEXT" "$VOICE" "$TEMP_WAV" "$SPEED" 2>&1) || {
107
134
  echo "❌ Kokoro synthesis failed: $RESULT" >&2
108
135
  exit 3
109
136
  }
@@ -54,6 +54,7 @@ VOICE_OVERRIDE="$2" # Ignored — Soprano has a single voice, kept for provider
54
54
  SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
55
55
  SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
56
56
  source "$SCRIPT_DIR/audio-cache-utils.sh"
57
+ source "$SCRIPT_DIR/python-resolver.sh"
57
58
 
58
59
  SOPRANO_PORT="${SOPRANO_PORT:-7860}"
59
60
  SOPRANO_DEVICE="${SOPRANO_DEVICE:-auto}"
@@ -135,7 +136,7 @@ SYNTH_MODE=""
135
136
  if check_webui_server; then
136
137
  # Gradio WebUI mode — use Python helper for SSE protocol
137
138
  SYNTH_MODE="webui"
138
- python3 "$SCRIPT_DIR/soprano-gradio-synth.py" "$TEXT" "$TEMP_FILE" "$SOPRANO_PORT" 2>/dev/null
139
+ "$PYTHON_BIN" "$SCRIPT_DIR/soprano-gradio-synth.py" "$TEXT" "$TEMP_FILE" "$SOPRANO_PORT" 2>/dev/null
139
140
  elif check_api_server; then
140
141
  # OpenAI-compatible API mode — direct curl
141
142
  SYNTH_MODE="api"
@@ -151,7 +152,7 @@ fi
151
152
 
152
153
  if [[ ! -f "$TEMP_FILE" ]] || [[ ! -s "$TEMP_FILE" ]]; then
153
154
  echo "❌ Failed to synthesize speech with Soprano ($SYNTH_MODE mode)"
154
- [[ "$SYNTH_MODE" == "webui" ]] && echo " Try: python3 $SCRIPT_DIR/soprano-gradio-synth.py \"test\" /tmp/test.wav $SOPRANO_PORT"
155
+ [[ "$SYNTH_MODE" == "webui" ]] && echo " Try: $PYTHON_BIN $SCRIPT_DIR/soprano-gradio-synth.py \"test\" /tmp/test.wav $SOPRANO_PORT"
155
156
  exit 4
156
157
  fi
157
158
 
@@ -47,7 +47,9 @@ if [[ -n "${AGENTVIBES_MUSIC_STOP:-}" ]]; then
47
47
  elif [[ -n "${AGENTVIBES_MUSIC_ONLY:-}" ]]; then
48
48
  # Only a bare .mp3 filename is allowed (no path separators, no leading dash) —
49
49
  # the receiver resolves it against its own tracks dir, so reject path-like input.
50
- if [[ "$AGENTVIBES_MUSIC_ONLY" =~ ^[A-Za-z0-9._][A-Za-z0-9._-]*\.mp3$ ]]; then
50
+ # Spaces ARE allowed (many track names contain them, e.g. "Late Night Hip Hop.mp3");
51
+ # the name travels as a jq --arg-escaped JSON string, so a space is not injectable.
52
+ if [[ "$AGENTVIBES_MUSIC_ONLY" =~ ^[A-Za-z0-9._][A-Za-z0-9._\ -]*\.mp3$ ]]; then
51
53
  PAYLOAD_KIND="music"
52
54
  MUSIC_ONLY_TRACK="$AGENTVIBES_MUSIC_ONLY"
53
55
  else
@@ -66,13 +68,26 @@ fi
66
68
 
67
69
  # Get script directory and project root
68
70
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
71
+ source "$SCRIPT_DIR/python-resolver.sh"
72
+ source "$SCRIPT_DIR/session-id.sh"
69
73
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
70
74
 
71
- # Derive project name from directory
72
- PROJECT_NAME=$(basename "$PROJECT_ROOT")
73
- # Absolute path, forwarded so the receiver-side avatar can show/learn the real
74
- # remote folder (mirrors the local forward-to-avatar.sh's projectPath field).
75
- PROJECT_PATH="$PROJECT_ROOT"
75
+ # Canonical routing session id. The receiver multiplexes forwarded TTS by this
76
+ # id, so it MUST derive from CLAUDE_PROJECT_DIR (the real user project injected
77
+ # via --project-dir), NOT basename(PROJECT_ROOT) for a global install that
78
+ # basename collapses to the HOME dir name and merges every project into one
79
+ # session. Fallback (CLAUDE_PROJECT_DIR unset) is PROJECT_ROOT, preserving the
80
+ # legacy value for the no-project case.
81
+ SESSION_ID="$(av_session_id "$PROJECT_ROOT")"
82
+ # PROJECT_NAME is kept for receiver back-compat (the receiver reads "project");
83
+ # set it to the SAME corrected session id so both fields agree.
84
+ PROJECT_NAME="$SESSION_ID"
85
+ # Absolute path, forwarded so the receiver can show/learn the real remote folder.
86
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
87
+ PROJECT_PATH="$CLAUDE_PROJECT_DIR"
88
+ else
89
+ PROJECT_PATH="$PROJECT_ROOT"
90
+ fi
76
91
 
77
92
  # ---------------------------------------------------------------------------
78
93
  # Get SSH connection details from config
@@ -93,10 +108,10 @@ fi
93
108
  # Priority 2: ~/.agentvibes/transport-config.json (ssh-remote section)
94
109
  if [[ -z "$SSH_HOST" ]]; then
95
110
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
96
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
97
- SSH_HOST=$(python3 -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('host',''))" 2>/dev/null || echo "")
98
- SSH_KEY=$(python3 -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
99
- SSH_PORT=$(python3 -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('port',''))" 2>/dev/null || echo "")
111
+ if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
112
+ SSH_HOST=$("$PYTHON_BIN" -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('host',''))" 2>/dev/null || echo "")
113
+ SSH_KEY=$("$PYTHON_BIN" -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
114
+ SSH_PORT=$("$PYTHON_BIN" -c "import json,sys; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('ssh-remote',{}); print(p.get('port',''))" 2>/dev/null || echo "")
100
115
  fi
101
116
  fi
102
117
 
@@ -104,8 +119,8 @@ fi
104
119
  # (config keyed by LLM name rather than 'ssh-remote', e.g. 'claude-code')
105
120
  if [[ -z "$SSH_HOST" ]]; then
106
121
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
107
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
108
- _remote_data=$(python3 -c "
122
+ if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
123
+ _remote_data=$("$PYTHON_BIN" -c "
109
124
  import json, sys
110
125
  try:
111
126
  d = json.load(open('$_TRANSPORT_CFG'))
@@ -156,10 +171,11 @@ if [[ -n "$SSH_PORT" ]] && [[ ! "$SSH_PORT" =~ ^[0-9]+$ ]]; then
156
171
  fi
157
172
 
158
173
  # SECURITY: Validate VOICE
159
- # Allow letters, digits, underscore, hyphen, period, colon (for :: multi-speaker separator), slash.
174
+ # Allow letters, digits, underscore, hyphen, period, colon (for :: multi-speaker separator),
175
+ # slash, and SPACES (Windows SAPI voice names like "Microsoft David Desktop").
160
176
  # Voice is passed to the remote via base64-encoded JSON (jq --arg safely escapes it),
161
- # so shell metacharacters are the only real risk.
162
- if [[ ! "$VOICE" =~ ^[a-zA-Z0-9_.:\/-]+$ ]]; then
177
+ # so shell metacharacters are the only real risk — a space is not injectable.
178
+ if [[ ! "$VOICE" =~ ^[a-zA-Z0-9_.:\/\ -]+$ ]]; then
163
179
  echo "Invalid voice format: $VOICE" >&2
164
180
  exit 1
165
181
  fi
@@ -343,9 +359,7 @@ fi
343
359
 
344
360
  # ---------------------------------------------------------------------------
345
361
  # Resolve LANGUAGE to forward (map §D 'language | Forward on SSH', R18).
346
- # Precedence mirrors the local chain (learn-manager.sh / language-manager.sh):
347
- # learning-mode target (tts-target-language.txt when tts-learn-mode.txt=="ON")
348
- # > tts-language.txt.
362
+ # Source: tts-language.txt (set by language-manager.sh).
349
363
  # Forwarded as a DEFAULT only — the receiver stays authoritative and its own
350
364
  # language config (if any) wins. language-manager.sh stores tts-language.txt at
351
365
  # the .claude/ root; the resolver contract tests use the .claude/config/ subdir,
@@ -353,14 +367,7 @@ fi
353
367
  # ---------------------------------------------------------------------------
354
368
  LANGUAGE=""
355
369
  for _cdir in "$_RESOLVE_PROJECT_DIR/.claude" "$HOME/.claude"; do
356
- # Priority 1: learning-mode target language (only when learn mode is ON).
357
- if [[ -z "$LANGUAGE" && -f "$_cdir/tts-learn-mode.txt" ]]; then
358
- if [[ "$(cat "$_cdir/tts-learn-mode.txt" 2>/dev/null || true)" == "ON" ]]; then
359
- [[ -f "$_cdir/tts-target-language.txt" ]] && \
360
- LANGUAGE=$(cat "$_cdir/tts-target-language.txt" 2>/dev/null || true)
361
- fi
362
- fi
363
- # Priority 2: tts-language.txt (root or config/ subdir).
370
+ # tts-language.txt (root or config/ subdir).
364
371
  if [[ -z "$LANGUAGE" && -f "$_cdir/tts-language.txt" ]]; then
365
372
  LANGUAGE=$(cat "$_cdir/tts-language.txt" 2>/dev/null || true)
366
373
  fi
@@ -391,6 +398,7 @@ build_json_payload() {
391
398
  --arg effects "$SOX_EFFECTS" \
392
399
  --arg music "$BG_FILE" \
393
400
  --arg volume "$BG_VOLUME" \
401
+ --arg session "$SESSION_ID" \
394
402
  --arg project "$PROJECT_NAME" \
395
403
  --arg projectPath "$PROJECT_PATH" \
396
404
  --arg pretext "$PRETEXT" \
@@ -400,7 +408,7 @@ build_json_payload() {
400
408
  --arg mute "$MUTE" \
401
409
  --arg language "$LANGUAGE" \
402
410
  --arg kind "$PAYLOAD_KIND" \
403
- '{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, project: $project, projectPath: $projectPath, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm, mute: $mute, language: $language, kind: $kind}'
411
+ '{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, session: $session, project: $project, projectPath: $projectPath, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm, mute: $mute, language: $language, kind: $kind}'
404
412
  else
405
413
  # Manual JSON — escape backslashes, quotes, control chars
406
414
  local escaped_text
@@ -409,8 +417,8 @@ build_json_payload() {
409
417
  escaped_pretext=$(printf '%s' "$PRETEXT" | sed 's/\\/\\\\/g; s/"/\\"/g')
410
418
  local escaped_project_path
411
419
  escaped_project_path=$(printf '%s' "$PROJECT_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g')
412
- printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","project":"%s","projectPath":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s","mute":"%s","language":"%s","kind":"%s"}' \
413
- "$escaped_text" "$VOICE" "$SOX_EFFECTS" "$BG_FILE" "$BG_VOLUME" "$PROJECT_NAME" "$escaped_project_path" "$escaped_pretext" "$SPEED" "$PROVIDER" "$LLM_NAME" "$MUTE" "$LANGUAGE" "$PAYLOAD_KIND"
420
+ printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","session":"%s","project":"%s","projectPath":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s","mute":"%s","language":"%s","kind":"%s"}' \
421
+ "$escaped_text" "$VOICE" "$SOX_EFFECTS" "$BG_FILE" "$BG_VOLUME" "$SESSION_ID" "$PROJECT_NAME" "$escaped_project_path" "$escaped_pretext" "$SPEED" "$PROVIDER" "$LLM_NAME" "$MUTE" "$LANGUAGE" "$PAYLOAD_KIND"
414
422
  fi
415
423
  }
416
424
 
@@ -68,6 +68,7 @@ VOICE_OVERRIDE="$2" # Not used for termux-ssh, but kept for interface compatibi
68
68
 
69
69
  # Script directory
70
70
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
71
+ source "$SCRIPT_DIR/python-resolver.sh"
71
72
 
72
73
  # @function get_ssh_host
73
74
  # @intent Determine SSH host alias for Android device
@@ -119,10 +120,10 @@ SSH_KEY=""
119
120
  SSH_PORT=""
120
121
 
121
122
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
122
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
123
- _CFG_HOST=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('host',''))" 2>/dev/null || echo "")
124
- SSH_KEY=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
125
- SSH_PORT=$(python3 -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('port',''))" 2>/dev/null || echo "")
123
+ if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
124
+ _CFG_HOST=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('host',''))" 2>/dev/null || echo "")
125
+ SSH_KEY=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
126
+ SSH_PORT=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('termux-ssh',{}); print(p.get('port',''))" 2>/dev/null || echo "")
126
127
  fi
127
128
 
128
129
  if [[ -n "${_CFG_HOST:-}" ]]; then