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
@@ -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
 
@@ -34,18 +34,60 @@ fi
34
34
  # Default to "default" so remote always has an LLM key to look up
35
35
  LLM_NAME="${LLM_NAME:-default}"
36
36
 
37
- # Validate required input
38
- if [[ -z "$TEXT" ]]; then
37
+ # Music-only preview mode: AGENTVIBES_MUSIC_ONLY=<track.mp3> tells the receiver to
38
+ # play a standalone background-music track (no speech). Used by the Music tab's
39
+ # remote preview so a track can be auditioned on the receiver. In this mode empty
40
+ # TEXT is allowed and the track name is forwarded as the "music" field with
41
+ # kind=music; the receiver resolves the file from its own ~/.claude/audio/tracks/.
42
+ PAYLOAD_KIND="speak"
43
+ MUSIC_ONLY_TRACK=""
44
+ if [[ -n "${AGENTVIBES_MUSIC_STOP:-}" ]]; then
45
+ # Stop whatever music preview is currently playing on the receiver.
46
+ PAYLOAD_KIND="music-stop"
47
+ elif [[ -n "${AGENTVIBES_MUSIC_ONLY:-}" ]]; then
48
+ # Only a bare .mp3 filename is allowed (no path separators, no leading dash) —
49
+ # the receiver resolves it against its own tracks dir, so reject path-like input.
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
53
+ PAYLOAD_KIND="music"
54
+ MUSIC_ONLY_TRACK="$AGENTVIBES_MUSIC_ONLY"
55
+ else
56
+ echo "Invalid music track name: $AGENTVIBES_MUSIC_ONLY" >&2
57
+ exit 1
58
+ fi
59
+ fi
60
+
61
+ # Validate required input (music / music-stop modes carry no speech text)
62
+ if [[ "$PAYLOAD_KIND" == "music" || "$PAYLOAD_KIND" == "music-stop" ]]; then
63
+ TEXT="" # no speech in a music preview / stop
64
+ elif [[ -z "$TEXT" ]]; then
39
65
  echo "Usage: $0 <text> [voice] [agent_name]" >&2
40
66
  exit 1
41
67
  fi
42
68
 
43
69
  # Get script directory and project root
44
70
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
71
+ source "$SCRIPT_DIR/python-resolver.sh"
72
+ source "$SCRIPT_DIR/session-id.sh"
45
73
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
46
74
 
47
- # Derive project name from directory
48
- PROJECT_NAME=$(basename "$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
49
91
 
50
92
  # ---------------------------------------------------------------------------
51
93
  # Get SSH connection details from config
@@ -66,10 +108,10 @@ fi
66
108
  # Priority 2: ~/.agentvibes/transport-config.json (ssh-remote section)
67
109
  if [[ -z "$SSH_HOST" ]]; then
68
110
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
69
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
70
- 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 "")
71
- 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 "")
72
- 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 "")
73
115
  fi
74
116
  fi
75
117
 
@@ -77,8 +119,8 @@ fi
77
119
  # (config keyed by LLM name rather than 'ssh-remote', e.g. 'claude-code')
78
120
  if [[ -z "$SSH_HOST" ]]; then
79
121
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
80
- if [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
81
- _remote_data=$(python3 -c "
122
+ if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
123
+ _remote_data=$("$PYTHON_BIN" -c "
82
124
  import json, sys
83
125
  try:
84
126
  d = json.load(open('$_TRANSPORT_CFG'))
@@ -129,10 +171,11 @@ if [[ -n "$SSH_PORT" ]] && [[ ! "$SSH_PORT" =~ ^[0-9]+$ ]]; then
129
171
  fi
130
172
 
131
173
  # SECURITY: Validate VOICE
132
- # 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").
133
176
  # Voice is passed to the remote via base64-encoded JSON (jq --arg safely escapes it),
134
- # so shell metacharacters are the only real risk.
135
- 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
136
179
  echo "Invalid voice format: $VOICE" >&2
137
180
  exit 1
138
181
  fi
@@ -316,9 +359,7 @@ fi
316
359
 
317
360
  # ---------------------------------------------------------------------------
318
361
  # Resolve LANGUAGE to forward (map §D 'language | Forward on SSH', R18).
319
- # Precedence mirrors the local chain (learn-manager.sh / language-manager.sh):
320
- # learning-mode target (tts-target-language.txt when tts-learn-mode.txt=="ON")
321
- # > tts-language.txt.
362
+ # Source: tts-language.txt (set by language-manager.sh).
322
363
  # Forwarded as a DEFAULT only — the receiver stays authoritative and its own
323
364
  # language config (if any) wins. language-manager.sh stores tts-language.txt at
324
365
  # the .claude/ root; the resolver contract tests use the .claude/config/ subdir,
@@ -326,14 +367,7 @@ fi
326
367
  # ---------------------------------------------------------------------------
327
368
  LANGUAGE=""
328
369
  for _cdir in "$_RESOLVE_PROJECT_DIR/.claude" "$HOME/.claude"; do
329
- # Priority 1: learning-mode target language (only when learn mode is ON).
330
- if [[ -z "$LANGUAGE" && -f "$_cdir/tts-learn-mode.txt" ]]; then
331
- if [[ "$(cat "$_cdir/tts-learn-mode.txt" 2>/dev/null || true)" == "ON" ]]; then
332
- [[ -f "$_cdir/tts-target-language.txt" ]] && \
333
- LANGUAGE=$(cat "$_cdir/tts-target-language.txt" 2>/dev/null || true)
334
- fi
335
- fi
336
- # Priority 2: tts-language.txt (root or config/ subdir).
370
+ # tts-language.txt (root or config/ subdir).
337
371
  if [[ -z "$LANGUAGE" && -f "$_cdir/tts-language.txt" ]]; then
338
372
  LANGUAGE=$(cat "$_cdir/tts-language.txt" 2>/dev/null || true)
339
373
  fi
@@ -364,25 +398,36 @@ build_json_payload() {
364
398
  --arg effects "$SOX_EFFECTS" \
365
399
  --arg music "$BG_FILE" \
366
400
  --arg volume "$BG_VOLUME" \
401
+ --arg session "$SESSION_ID" \
367
402
  --arg project "$PROJECT_NAME" \
403
+ --arg projectPath "$PROJECT_PATH" \
368
404
  --arg pretext "$PRETEXT" \
369
405
  --arg speed "$SPEED" \
370
406
  --arg provider "$PROVIDER" \
371
407
  --arg llm "$LLM_NAME" \
372
408
  --arg mute "$MUTE" \
373
409
  --arg language "$LANGUAGE" \
374
- '{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, project: $project, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm, mute: $mute, language: $language}'
410
+ --arg kind "$PAYLOAD_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}'
375
412
  else
376
413
  # Manual JSON — escape backslashes, quotes, control chars
377
414
  local escaped_text
378
415
  escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ' | sed 's/\r//g')
379
416
  local escaped_pretext
380
417
  escaped_pretext=$(printf '%s' "$PRETEXT" | sed 's/\\/\\\\/g; s/"/\\"/g')
381
- printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","project":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s","mute":"%s","language":"%s"}' \
382
- "$escaped_text" "$VOICE" "$SOX_EFFECTS" "$BG_FILE" "$BG_VOLUME" "$PROJECT_NAME" "$escaped_pretext" "$SPEED" "$PROVIDER" "$LLM_NAME" "$MUTE" "$LANGUAGE"
418
+ local escaped_project_path
419
+ escaped_project_path=$(printf '%s' "$PROJECT_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g')
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"
383
422
  fi
384
423
  }
385
424
 
425
+ # Music-only preview: force the track as the "music" field (overriding any
426
+ # per-LLM/agent background-music config) so the receiver plays exactly this track.
427
+ if [[ "$PAYLOAD_KIND" == "music" ]]; then
428
+ BG_FILE="$MUSIC_ONLY_TRACK"
429
+ fi
430
+
386
431
  JSON_PAYLOAD=$(build_json_payload)
387
432
 
388
433
  # SECURITY: Base64-encode entire payload — safe for SSH transport
@@ -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
@@ -31,13 +31,13 @@
31
31
  #
32
32
  # ---
33
33
  #
34
- # @fileoverview TTS Provider Router with Translation and Language Learning Support
34
+ # @fileoverview TTS Provider Router with Translation Support
35
35
  # @context Routes TTS requests to active provider (Piper or macOS) with optional translation
36
- # @architecture Provider abstraction layer - single entry point for all TTS, handles translation and learning mode
37
- # @dependencies provider-manager.sh, play-tts-piper.sh, translator.py, translate-manager.sh, learn-manager.sh
36
+ # @architecture Provider abstraction layer - single entry point for all TTS, handles translation
37
+ # @dependencies provider-manager.sh, play-tts-piper.sh, translator.py, translate-manager.sh
38
38
  # @entrypoints Called by hooks, slash commands, personality-manager.sh, and all TTS features
39
39
  # @patterns Provider pattern - delegates to provider-specific implementations, auto-detects provider from voice name
40
- # @related provider-manager.sh, play-tts-piper.sh, learn-manager.sh, translate-manager.sh
40
+ # @related provider-manager.sh, play-tts-piper.sh, translate-manager.sh
41
41
  #
42
42
  # **IMPORTANT: This script should be called inline (NOT in background) in Bash tool**
43
43
  # Wait for TTS playback to complete before continuing.
@@ -113,6 +113,12 @@ elif [[ -f "$GLOBAL_MUTE_FILE" ]]; then
113
113
  exit 0
114
114
  fi
115
115
 
116
+ # Resolve a working Python interpreter once (translator and transport config
117
+ # both need it). Sourced AFTER the mute exits so muted calls
118
+ # pay nothing. Windows git-bash frequently has no python3 on PATH — see
119
+ # python-resolver.sh; $PYTHON_BIN is empty when none is usable.
120
+ source "$SCRIPT_DIR/python-resolver.sh"
121
+
116
122
  # Parse named flags (e.g. --llm) before positional arguments.
117
123
  # This allows callers to pass: play-tts.sh --llm claude-code "text to speak"
118
124
  # Named args are extracted; remaining positional args are shifted into $1/$2/$3.
@@ -241,6 +247,53 @@ if [[ -z "$_PRETEXT" ]]; then
241
247
  fi
242
248
  done
243
249
  fi
250
+ # Dynamic session self-ID: a per-LLM PRETEXT containing the {{session}} token
251
+ # expands to "<LLM> on <Project> in <Terminal>" so multi-session users can tell
252
+ # which tab just spoke. Announced once per session (best-effort — see the key
253
+ # note below); on later utterances the token is dropped so it doesn't prefix
254
+ # every line. Opt-in — only fires when the pretext actually contains the token.
255
+ if [[ "$_PRETEXT" == *"{{session}}"* ]]; then
256
+ # Session key. There is no true Claude-session id available to a plain Bash
257
+ # command, so this is best-effort: a terminal-session id if the emulator
258
+ # exports one (stable for the tab's life), else the parent PID. Worst case the
259
+ # self-ID repeats or is skipped — cosmetic, and only when a pretext opts in.
260
+ _SID_RAW="${WT_SESSION:-${TERM_SESSION_ID:-${TMUX:-$PPID}}}"
261
+ _SID_KEY="$(printf '%s' "${LLM_PROVIDER:-}|$(basename "${CLAUDE_PROJECT_DIR:-$PROJECT_ROOT}")|$_SID_RAW" | tr -c 'A-Za-z0-9' '_')"
262
+ # Announce-marker dir: an OWNED subdir, per the project's secure-temp rule.
263
+ # If the dir exists but isn't ours (pre-created symlink attack on a shared
264
+ # host), skip the marker entirely and just strip the token — never chmod or
265
+ # write through someone else's path.
266
+ _ANN_BASE="${XDG_RUNTIME_DIR:-/tmp/agentvibes-$(id -u 2>/dev/null || echo 0)}"
267
+ _ANN_DIR="$_ANN_BASE/agentvibes-session"
268
+ _ANN_OK=0
269
+ if mkdir -p "$_ANN_DIR" 2>/dev/null && chmod 700 "$_ANN_DIR" 2>/dev/null; then
270
+ _ANN_OWNER="$(stat -c '%u' "$_ANN_DIR" 2>/dev/null || stat -f '%u' "$_ANN_DIR" 2>/dev/null || echo '')"
271
+ [[ -z "$_ANN_OWNER" || "$_ANN_OWNER" == "$(id -u 2>/dev/null || echo 0)" ]] && _ANN_OK=1
272
+ fi
273
+ _ANN_FILE="$_ANN_DIR/announced-$_SID_KEY"
274
+ # First utterance → replacement is the self-ID; later utterances → empty (drop).
275
+ if [[ "$_ANN_OK" == "1" && -f "$_ANN_FILE" ]]; then
276
+ _SESSION_ID=""
277
+ else
278
+ _SESSION_ID="$(bash "$SCRIPT_DIR/agentvibes-session-id.sh" "${LLM_PROVIDER:-claude-code}" "${CLAUDE_PROJECT_DIR:-$PROJECT_ROOT}" 2>/dev/null || true)"
279
+ if [[ "$_ANN_OK" == "1" ]]; then
280
+ touch "$_ANN_FILE" 2>/dev/null || true
281
+ # Bound growth: drop announce-markers older than a day (find is optional).
282
+ find "$_ANN_DIR" -maxdepth 1 -name 'announced-*' -mtime +1 -delete 2>/dev/null || true
283
+ fi
284
+ fi
285
+ if [[ -n "$_SESSION_ID" ]]; then
286
+ _PRETEXT="${_PRETEXT//"{{session}}"/$_SESSION_ID}"
287
+ else
288
+ # Empty replacement: also swallow one adjacent ", " so "Hey {{session}}, ready"
289
+ # becomes "Hey ready", not "Hey , ready"; then drop any bare token.
290
+ _PRETEXT="${_PRETEXT//"{{session}}, "/}"
291
+ _PRETEXT="${_PRETEXT//", {{session}}"/}"
292
+ _PRETEXT="${_PRETEXT//"{{session}}"/}"
293
+ fi
294
+ # Final tidy: collapse doubled/edge commas and runs of whitespace.
295
+ _PRETEXT="$(printf '%s' "$_PRETEXT" | sed -E 's/,[[:space:]]*,/,/g; s/^[[:space:]]*,[[:space:]]*//; s/[[:space:]]*,[[:space:]]*$//; s/[[:space:]]{2,}/ /g; s/^[[:space:]]+//; s/[[:space:]]+$//')"
296
+ fi
244
297
  if [[ -n "$_PRETEXT" ]]; then
245
298
  TEXT="${_PRETEXT}, ${TEXT}"
246
299
  fi
@@ -329,7 +382,12 @@ case "$ACTIVE_PROVIDER" in
329
382
  # Transport — keep it. The receiver's audio-effects.cfg picks the engine.
330
383
  ;;
331
384
  *)
332
- if [[ -n "$_LLM_ENGINE" ]]; then
385
+ if [[ "$VOICE_OVERRIDE" =~ ^(af|am|bf|bm|jf|jm|kf|km|zf|zm|ff|fm|hf|hm|if|im|pf|pm|ef|em|nf|nm)_[a-zA-Z0-9]+$ ]]; then
386
+ # Kokoro-shaped voice override (e.g. af_heart) must win over the LLM row's
387
+ # engine column, else it is synthesized as a Piper voice, 404s downloading
388
+ # a model that doesn't exist, and stays silent.
389
+ ACTIVE_PROVIDER="kokoro"
390
+ elif [[ -n "$_LLM_ENGINE" ]]; then
333
391
  ACTIVE_PROVIDER="$_LLM_ENGINE"
334
392
  fi
335
393
  ;;
@@ -351,8 +409,8 @@ fi
351
409
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
352
410
  if [[ "$ACTIVE_PROVIDER" != "ssh-remote" && "$ACTIVE_PROVIDER" != "agentvibes-receiver" && "$ACTIVE_PROVIDER" != "termux-ssh" ]] \
353
411
  && [[ -n "$LLM_PROVIDER" && "$LLM_PROVIDER" != "default" ]] \
354
- && [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
355
- _LLM_SSH_MODE=$(AGENTVIBES_CFG="$_TRANSPORT_CFG" AGENTVIBES_KEY="$LLM_PROVIDER" python3 - <<'PYEOF'
412
+ && [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
413
+ _LLM_SSH_MODE=$(AGENTVIBES_CFG="$_TRANSPORT_CFG" AGENTVIBES_KEY="$LLM_PROVIDER" "$PYTHON_BIN" - <<'PYEOF'
356
414
  import json, os, sys
357
415
  try:
358
416
  d = json.load(open(os.environ['AGENTVIBES_CFG'], encoding='utf-8'))
@@ -363,7 +421,7 @@ PYEOF
363
421
  )
364
422
  if [[ "$_LLM_SSH_MODE" == "remote" ]]; then
365
423
  # Redirect this LLM's audio through ssh-remote using its own SSH config
366
- _llm_remote_data=$(AGENTVIBES_CFG="$_TRANSPORT_CFG" AGENTVIBES_KEY="$LLM_PROVIDER" python3 - <<'PYEOF'
424
+ _llm_remote_data=$(AGENTVIBES_CFG="$_TRANSPORT_CFG" AGENTVIBES_KEY="$LLM_PROVIDER" "$PYTHON_BIN" - <<'PYEOF'
367
425
  import json, os, sys
368
426
  try:
369
427
  d = json.load(open(os.environ['AGENTVIBES_CFG'], encoding='utf-8'))
@@ -513,12 +571,17 @@ speak_text() {
513
571
  termux-ssh)
514
572
  bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$text" "$voice"
515
573
  ;;
516
- ssh-remote)
574
+ ssh-remote|agentvibes-receiver)
575
+ # Both route through the base64-JSON sender that the modern receiver
576
+ # (templates/agentvibes-receiver.ps1 / .sh, installed as play-remote.*)
577
+ # understands. The old voiceless-connections sender spoke a 3-positional-arg
578
+ # protocol for a legacy ~/.agentvibes/play-remote.sh receiver that no current
579
+ # receiver install produces, so it delivered a non-base64 command the modern
580
+ # receiver rejects ("Payload must be base64-encoded"). play-tts-ssh-remote.sh
581
+ # resolves the host from any transport-config mode=remote entry (Priority 2b),
582
+ # so the agentvibes-receiver section is found without an ssh-remote section.
517
583
  bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$text" "$voice" "" "${profile_file:-}"
518
584
  ;;
519
- agentvibes-receiver)
520
- bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$text" "$voice"
521
- ;;
522
585
  *)
523
586
  echo "❌ Unknown provider: $provider" >&2
524
587
  return 1
@@ -526,50 +589,8 @@ speak_text() {
526
589
  esac
527
590
  }
528
591
 
529
- # Note: learn-manager.sh and translate-manager.sh are sourced inside their
530
- # respective handler functions to avoid triggering their main handlers
531
-
532
- # @function handle_learning_mode
533
- # @intent Speak in both main language and target language for learning
534
- # @why Issue #51 - Auto-translate and speak twice for immersive language learning
535
- # @returns 0 if learning mode handled, 1 if not in learning mode
536
- handle_learning_mode() {
537
- # Source learn-manager for learning mode functions
538
- source "$SCRIPT_DIR/learn-manager.sh" 2>/dev/null || return 1
539
-
540
- # Check if learning mode is enabled
541
- if ! is_learn_mode_enabled 2>/dev/null; then
542
- return 1
543
- fi
544
-
545
- local target_lang
546
- target_lang=$(get_target_language 2>/dev/null || echo "")
547
- local target_voice
548
- target_voice=$(get_target_voice 2>/dev/null || echo "")
549
-
550
- # Need both target language and voice for learning mode
551
- if [[ -z "$target_lang" ]] || [[ -z "$target_voice" ]]; then
552
- return 1
553
- fi
554
-
555
- # 1. Speak in main language (current voice)
556
- speak_text "$TEXT" "$VOICE_OVERRIDE" "$ACTIVE_PROVIDER"
557
-
558
- # 2. Auto-translate to target language
559
- local translated
560
- # SECURITY: Add timeout to prevent hanging (#134)
561
- translated=$(timeout 5 python3 "$SCRIPT_DIR/translator.py" "$TEXT" "$target_lang" 2>/dev/null) || translated="$TEXT"
562
-
563
- # Small pause between languages
564
- sleep 0.5
565
-
566
- # 3. Speak translated text with target voice
567
- local target_provider
568
- target_provider=$(detect_voice_provider "$target_voice")
569
- speak_text "$translated" "$target_voice" "$target_provider"
570
-
571
- return 0
572
- }
592
+ # Note: translate-manager.sh is sourced inside its handler function to avoid
593
+ # triggering its main handler
573
594
 
574
595
  # @function handle_translation_mode
575
596
  # @intent Translate and speak in target language (non-learning mode)
@@ -594,7 +615,7 @@ handle_translation_mode() {
594
615
  # Translate text
595
616
  local translated
596
617
  # SECURITY: Add timeout to prevent hanging (#134)
597
- translated=$(timeout 5 python3 "$SCRIPT_DIR/translator.py" "$TEXT" "$translate_to" 2>/dev/null) || translated="$TEXT"
618
+ translated=$(timeout 5 "${PYTHON_BIN:-python3}" "$SCRIPT_DIR/translator.py" "$TEXT" "$translate_to" 2>/dev/null) || translated="$TEXT"
598
619
 
599
620
  # Get voice for target language if no override specified
600
621
  local voice_to_use="$VOICE_OVERRIDE"
@@ -615,14 +636,8 @@ handle_translation_mode() {
615
636
  }
616
637
 
617
638
  # Mode priority:
618
- # 1. Learning mode (speaks twice: main + translated)
619
- # 2. Translation mode (speaks translated only)
620
- # 3. Normal mode (speaks as-is)
621
-
622
- # Try learning mode first (Issue #51)
623
- if handle_learning_mode; then
624
- exit 0
625
- fi
639
+ # 1. Translation mode (speaks translated only)
640
+ # 2. Normal mode (speaks as-is)
626
641
 
627
642
  # Try translation mode (Issue #50)
628
643
  if handle_translation_mode; then
@@ -653,12 +668,14 @@ case "$ACTIVE_PROVIDER" in
653
668
  termux-ssh)
654
669
  exec bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$TEXT" "$VOICE_OVERRIDE"
655
670
  ;;
656
- ssh-remote)
671
+ ssh-remote|agentvibes-receiver)
672
+ # Both route through the base64-JSON sender the modern receiver understands.
673
+ # The legacy voiceless-connections sender spoke a 3-arg protocol for an old
674
+ # ~/.agentvibes/play-remote.sh receiver that no current install produces, so it
675
+ # delivered a non-base64 command the modern receiver rejects with
676
+ # "Payload must be base64-encoded".
657
677
  exec bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$TEXT" "$VOICE_OVERRIDE" "" "${AGENT_PROFILE_FILE:-}"
658
678
  ;;
659
- agentvibes-receiver)
660
- exec bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$TEXT" "$VOICE_OVERRIDE"
661
- ;;
662
679
  *)
663
680
  echo "❌ Unknown provider: $ACTIVE_PROVIDER" >&2
664
681
  echo " Run: /agent-vibes:provider list" >&2