agentvibes 5.12.0 → 5.13.1

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 (73) hide show
  1. package/.agentvibes/install-manifest.json +111 -91
  2. package/.claude/commands/agent-vibes/commands.json +0 -20
  3. package/.claude/commands/agent-vibes/unmute.md +6 -2
  4. package/.claude/config/audio-effects.cfg +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  7. package/.claude/hooks/bmad-party-speak.sh +20 -4
  8. package/.claude/hooks/bmad-speak.sh +60 -2
  9. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  10. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  11. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  12. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  13. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  14. package/.claude/hooks/kokoro-installer.sh +20 -10
  15. package/.claude/hooks/language-manager.sh +10 -3
  16. package/.claude/hooks/party-set-room.sh +71 -0
  17. package/.claude/hooks/party-stage-roster.py +328 -0
  18. package/.claude/hooks/personality-manager.sh +19 -2
  19. package/.claude/hooks/piper-voice-manager.sh +3 -2
  20. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  21. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  22. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  23. package/.claude/hooks/play-tts-soprano.sh +3 -2
  24. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  25. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  26. package/.claude/hooks/play-tts.sh +66 -61
  27. package/.claude/hooks/provider-catalog.json +352 -0
  28. package/.claude/hooks/provider-catalog.sh +161 -0
  29. package/.claude/hooks/provider-commands.sh +2 -1
  30. package/.claude/hooks/provider-manager.sh +47 -9
  31. package/.claude/hooks/python-resolver.sh +117 -0
  32. package/.claude/hooks/session-id.sh +56 -0
  33. package/.claude/hooks/session-start-tts.sh +39 -0
  34. package/.claude/hooks/speed-manager.sh +1 -1
  35. package/.claude/hooks/translate-manager.sh +3 -2
  36. package/.claude/hooks/translator.py +1 -1
  37. package/.claude/hooks/voice-manager.sh +242 -10
  38. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  39. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  40. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  41. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  42. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  43. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  44. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  45. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  46. package/.mcp.json +0 -7
  47. package/README.md +12 -3
  48. package/RELEASE_NOTES.md +61 -0
  49. package/mcp-server/server.py +146 -49
  50. package/mcp-server/test_mcp_correctness.py +20 -2
  51. package/mcp-server/test_windows_script_parity.py +0 -2
  52. package/package.json +2 -2
  53. package/src/cli/list-voices.js +218 -114
  54. package/src/console/bling.js +71 -0
  55. package/src/console/music-preview.js +79 -0
  56. package/src/console/tabs/music-tab.js +16 -39
  57. package/src/console/tabs/settings-tab.js +195 -13
  58. package/src/console/tabs/setup-tab.js +9 -34
  59. package/src/console/tabs/voices-tab.js +83 -14
  60. package/src/console/widgets/track-picker.js +82 -0
  61. package/src/installer.js +136 -18
  62. package/src/services/provider-catalog.js +412 -0
  63. package/src/services/provider-voice-catalog.js +52 -73
  64. package/src/services/tts-engine-service.js +29 -0
  65. package/src/utils/provider-validator.js +62 -12
  66. package/.claude/commands/agent-vibes/language.md +0 -23
  67. package/.claude/commands/agent-vibes/learn.md +0 -67
  68. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  69. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  70. package/.claude/commands/agent-vibes/target.md +0 -30
  71. package/.claude/hooks/learn-manager.sh +0 -492
  72. package/.claude/hooks/replay-target-audio.sh +0 -95
  73. 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
 
@@ -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
@@ -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
@@ -332,7 +385,7 @@ case "$ACTIVE_PROVIDER" in
332
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
333
386
  # Kokoro-shaped voice override (e.g. af_heart) must win over the LLM row's
334
387
  # engine column, else it is synthesized as a Piper voice, 404s downloading
335
- # a model that doesn't exist, and stays silent. (Grafted from master WIP.)
388
+ # a model that doesn't exist, and stays silent.
336
389
  ACTIVE_PROVIDER="kokoro"
337
390
  elif [[ -n "$_LLM_ENGINE" ]]; then
338
391
  ACTIVE_PROVIDER="$_LLM_ENGINE"
@@ -356,8 +409,8 @@ fi
356
409
  _TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
357
410
  if [[ "$ACTIVE_PROVIDER" != "ssh-remote" && "$ACTIVE_PROVIDER" != "agentvibes-receiver" && "$ACTIVE_PROVIDER" != "termux-ssh" ]] \
358
411
  && [[ -n "$LLM_PROVIDER" && "$LLM_PROVIDER" != "default" ]] \
359
- && [[ -f "$_TRANSPORT_CFG" ]] && command -v python3 &>/dev/null; then
360
- _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'
361
414
  import json, os, sys
362
415
  try:
363
416
  d = json.load(open(os.environ['AGENTVIBES_CFG'], encoding='utf-8'))
@@ -368,7 +421,7 @@ PYEOF
368
421
  )
369
422
  if [[ "$_LLM_SSH_MODE" == "remote" ]]; then
370
423
  # Redirect this LLM's audio through ssh-remote using its own SSH config
371
- _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'
372
425
  import json, os, sys
373
426
  try:
374
427
  d = json.load(open(os.environ['AGENTVIBES_CFG'], encoding='utf-8'))
@@ -536,50 +589,8 @@ speak_text() {
536
589
  esac
537
590
  }
538
591
 
539
- # Note: learn-manager.sh and translate-manager.sh are sourced inside their
540
- # respective handler functions to avoid triggering their main handlers
541
-
542
- # @function handle_learning_mode
543
- # @intent Speak in both main language and target language for learning
544
- # @why Issue #51 - Auto-translate and speak twice for immersive language learning
545
- # @returns 0 if learning mode handled, 1 if not in learning mode
546
- handle_learning_mode() {
547
- # Source learn-manager for learning mode functions
548
- source "$SCRIPT_DIR/learn-manager.sh" 2>/dev/null || return 1
549
-
550
- # Check if learning mode is enabled
551
- if ! is_learn_mode_enabled 2>/dev/null; then
552
- return 1
553
- fi
554
-
555
- local target_lang
556
- target_lang=$(get_target_language 2>/dev/null || echo "")
557
- local target_voice
558
- target_voice=$(get_target_voice 2>/dev/null || echo "")
559
-
560
- # Need both target language and voice for learning mode
561
- if [[ -z "$target_lang" ]] || [[ -z "$target_voice" ]]; then
562
- return 1
563
- fi
564
-
565
- # 1. Speak in main language (current voice)
566
- speak_text "$TEXT" "$VOICE_OVERRIDE" "$ACTIVE_PROVIDER"
567
-
568
- # 2. Auto-translate to target language
569
- local translated
570
- # SECURITY: Add timeout to prevent hanging (#134)
571
- translated=$(timeout 5 python3 "$SCRIPT_DIR/translator.py" "$TEXT" "$target_lang" 2>/dev/null) || translated="$TEXT"
572
-
573
- # Small pause between languages
574
- sleep 0.5
575
-
576
- # 3. Speak translated text with target voice
577
- local target_provider
578
- target_provider=$(detect_voice_provider "$target_voice")
579
- speak_text "$translated" "$target_voice" "$target_provider"
580
-
581
- return 0
582
- }
592
+ # Note: translate-manager.sh is sourced inside its handler function to avoid
593
+ # triggering its main handler
583
594
 
584
595
  # @function handle_translation_mode
585
596
  # @intent Translate and speak in target language (non-learning mode)
@@ -604,7 +615,7 @@ handle_translation_mode() {
604
615
  # Translate text
605
616
  local translated
606
617
  # SECURITY: Add timeout to prevent hanging (#134)
607
- 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"
608
619
 
609
620
  # Get voice for target language if no override specified
610
621
  local voice_to_use="$VOICE_OVERRIDE"
@@ -625,14 +636,8 @@ handle_translation_mode() {
625
636
  }
626
637
 
627
638
  # Mode priority:
628
- # 1. Learning mode (speaks twice: main + translated)
629
- # 2. Translation mode (speaks translated only)
630
- # 3. Normal mode (speaks as-is)
631
-
632
- # Try learning mode first (Issue #51)
633
- if handle_learning_mode; then
634
- exit 0
635
- fi
639
+ # 1. Translation mode (speaks translated only)
640
+ # 2. Normal mode (speaks as-is)
636
641
 
637
642
  # Try translation mode (Issue #50)
638
643
  if handle_translation_mode; then