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
package/src/installer.js CHANGED
@@ -141,6 +141,33 @@ async function saveManifest(manifestPath, files) {
141
141
  // • dest hash == manifest hash → copy (action: 'updated')
142
142
  // • dest hash != manifest hash → skip (action: 'skipped', .user.bak saved)
143
143
  // • no manifest entry yet → copy (action: 'updated')
144
+ // Preserve destPath's CURRENT content in a sidecar backup before anything
145
+ // overwrites it. Never clobbers an existing .user.bak — that backup may hold the
146
+ // only surviving copy of an earlier customization; a second backup gets a
147
+ // content-stamped name instead. Returns true only when the current content is
148
+ // safely stored somewhere, so callers can refuse to overwrite when it isn't.
149
+ async function backupUserFile(destPath) {
150
+ const bak = `${destPath}.user.bak`;
151
+ try {
152
+ const currentHash = await computeFileHash(destPath);
153
+ if (!currentHash) return false;
154
+
155
+ const existingHash = await computeFileHash(bak);
156
+ if (existingHash === currentHash) return true; // already backed up
157
+
158
+ if (existingHash) {
159
+ // A different backup exists — keep it and stamp this one.
160
+ await fs.copyFile(destPath, `${destPath}.user.bak.${currentHash.slice(0, 8)}`);
161
+ return true;
162
+ }
163
+
164
+ await fs.copyFile(destPath, bak);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
144
171
  async function manifestSafeCopy(srcPath, destPath, manifest) {
145
172
  const srcHash = await computeFileHash(srcPath);
146
173
  if (!srcHash) return { action: 'skipped', hash: null }; // src missing
@@ -157,15 +184,29 @@ async function manifestSafeCopy(srcPath, destPath, manifest) {
157
184
  }
158
185
 
159
186
  const manifestHash = manifest[destPath]?.hash;
160
- if (manifestHash && destHash !== manifestHash) {
161
- // User modified the file since we last installed it — preserve it
162
- try { await fs.copyFile(destPath, destPath + '.user.bak'); } catch { /* best effort */ }
163
- return { action: 'skipped', hash: destHash };
187
+
188
+ if (manifestHash) {
189
+ if (destHash !== manifestHash) {
190
+ // User modified the file since we last installed it — preserve it
191
+ await backupUserFile(destPath);
192
+ return { action: 'skipped', hash: destHash };
193
+ }
194
+ // Byte-identical to what we last installed — untouched, ours to update
195
+ await fs.copyFile(srcPath, destPath);
196
+ return { action: 'updated', hash: srcHash };
164
197
  }
165
198
 
166
- // Stock file (or no manifest entry yet) safe to overwrite
199
+ // No manifest entry, and dest differs from what we ship. This is either a
200
+ // user's customized file or an older stock copy predating the manifest — the
201
+ // two are indistinguishable, so we must assume it could be a customization.
202
+ // Back it up before overwriting, and if the backup can't be secured, refuse
203
+ // to overwrite at all rather than destroy the only copy.
204
+ // (CLAUDE.md: never overwrite existing user .claude/ config.)
205
+ if (!(await backupUserFile(destPath))) {
206
+ return { action: 'skipped', hash: destHash };
207
+ }
167
208
  await fs.copyFile(srcPath, destPath);
168
- return { action: 'updated', hash: srcHash };
209
+ return { action: 'updated', hash: srcHash, backedUp: true };
169
210
  }
170
211
 
171
212
  // Delete only the files listed in manifest that reside under baseDir.
@@ -3593,14 +3634,17 @@ async function copyCommandFiles(targetDir, spinner) {
3593
3634
  */
3594
3635
  function shouldIncludeHookFile(file, stat) {
3595
3636
  if (isNativeWindows()) {
3596
- // Include .ps1 scripts, .py helpers, and hooks.json; exclude dotfiles and prepare-release
3637
+ // Include .ps1 scripts, .py helpers, hooks.json, and the generated
3638
+ // provider-catalog.json; exclude dotfiles and prepare-release
3597
3639
  return stat.isFile() &&
3598
- (file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json') &&
3640
+ (file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json' ||
3641
+ file === 'provider-catalog.json') &&
3599
3642
  !file.includes('prepare-release') &&
3600
3643
  !file.startsWith('.');
3601
3644
  }
3602
3645
  return stat.isFile() &&
3603
- (file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json') &&
3646
+ (file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json' ||
3647
+ file === 'provider-catalog.json') &&
3604
3648
  !file.includes('prepare-release') &&
3605
3649
  !file.startsWith('.');
3606
3650
  }
@@ -5668,6 +5712,16 @@ async function performUpdateOperations(targetDir, spinner) {
5668
5712
  spinner.text = 'Checking for old configuration...';
5669
5713
  await detectAndMigrateOldConfig(targetDir, spinner);
5670
5714
 
5715
+ // Upgrade safety for the new opt-in injection gate (session-start-tts.sh):
5716
+ // a project that was already installed and talking pre-gate has no marker, so
5717
+ // the gate would newly silence it. Backfill the enable marker for a PROJECT
5718
+ // update so existing users keep their audio. A GLOBAL (home-dir) update stays
5719
+ // opt-in — and we say so, otherwise the user's voice just stops with no clue.
5720
+ if ((await configureInjectionScope(targetDir)) === 'global') {
5721
+ console.log('');
5722
+ warnGlobalInjectionScope();
5723
+ }
5724
+
5671
5725
  return {
5672
5726
  commandCount,
5673
5727
  hookCount: hookResult.count,
@@ -5721,6 +5775,61 @@ async function updateAgentVibes(targetDir, options) {
5721
5775
  }
5722
5776
  }
5723
5777
 
5778
+ /**
5779
+ * Configure the session-start injection opt-in marker for this install/update.
5780
+ *
5781
+ * - PROJECT install → drop `.claude/agentvibes-enabled` so session-start-tts.sh
5782
+ * injects the TTS protocol for THIS project (and only this one), preserving the
5783
+ * "install in a project and it just works" experience.
5784
+ * - GLOBAL install (target === home dir) → write nothing; injecting into every
5785
+ * session is exactly the "cacophony of agents" we are preventing.
5786
+ *
5787
+ * Uses `agentvibes-enabled`, NOT `agentvibes-unmuted`, on purpose: the unmuted
5788
+ * marker OVERRIDES a global mute in play-tts.sh, so writing it here would let an
5789
+ * install/update silently defeat a user's `~/.agentvibes-muted` kill-switch.
5790
+ * Non-destructive: never touches an existing mute/unmute/enabled choice.
5791
+ *
5792
+ * @param {string} targetDir - install target
5793
+ * @returns {Promise<'global'|'enabled'|'kept'|'error'>} what happened (for messaging)
5794
+ */
5795
+ async function configureInjectionScope(targetDir) {
5796
+ try {
5797
+ // path.relative is case-insensitive on win32 and normalizes separators, so a
5798
+ // lowercase drive letter or trailing slash no longer misdetects a home
5799
+ // install; realpath (best-effort) also collapses a symlinked $HOME.
5800
+ let resolvedTarget = path.resolve(targetDir);
5801
+ let resolvedHome = path.resolve(os.homedir());
5802
+ try { resolvedTarget = fsSync.realpathSync.native(resolvedTarget); } catch { /* dir may not exist yet */ }
5803
+ try { resolvedHome = fsSync.realpathSync.native(resolvedHome); } catch { /* ignore */ }
5804
+ if (path.relative(resolvedHome, resolvedTarget) === '') return 'global';
5805
+
5806
+ const claudeDir = path.join(targetDir, '.claude');
5807
+ // Respect any prior explicit choice — enabled OR either mute/unmute marker.
5808
+ for (const m of ['agentvibes-enabled', 'agentvibes-unmuted', 'agentvibes-muted']) {
5809
+ if (await fs.access(path.join(claudeDir, m)).then(() => true).catch(() => false)) {
5810
+ return 'kept';
5811
+ }
5812
+ }
5813
+ await fs.mkdir(claudeDir, { recursive: true });
5814
+ await fs.writeFile(path.join(claudeDir, 'agentvibes-enabled'), '', 'utf8');
5815
+ return 'enabled';
5816
+ } catch (err) {
5817
+ // Never fail install/update over the marker, but don't swallow silently —
5818
+ // a read-only FS here would otherwise produce a mysterious "silent" project.
5819
+ console.log(chalk.gray(` (Could not set TTS injection marker: ${err.code || err.message}. Run /agent-vibes:unmute to enable.)`));
5820
+ return 'error';
5821
+ }
5822
+ }
5823
+
5824
+ // Print the "global install stays silent" guidance (shared by install + update).
5825
+ function warnGlobalInjectionScope() {
5826
+ console.log(chalk.yellow.bold(' ⚠ Global install detected (installed at your home directory)'));
5827
+ console.log(chalk.gray(' TTS stays OFF by default so it will NOT talk in every session at once.'));
5828
+ console.log(chalk.gray(' Enable it in a specific project with: ') + chalk.cyan('/agent-vibes:unmute'));
5829
+ console.log(chalk.gray(' (or re-run the installer inside a project folder, not your home dir).'));
5830
+ console.log('');
5831
+ }
5832
+
5724
5833
  // Installation function
5725
5834
  async function install(options = {}) {
5726
5835
  const currentDir = process.env.INIT_CWD || process.cwd();
@@ -6243,6 +6352,11 @@ def _strip_markdown(text: str) -> str:
6243
6352
  console.log(chalk.magenta(' \u2661 Sponsor this Developer github.com/sponsors/paulpreibisch'));
6244
6353
  console.log('');
6245
6354
 
6355
+ // Opt-in injection marker (prevents the global-install cacophony). See
6356
+ // configureInjectionScope: project installs enable THIS project; a home-dir
6357
+ // install stays silent. Uses agentvibes-enabled so it can't override a global mute.
6358
+ if ((await configureInjectionScope(targetDir)) === 'global') warnGlobalInjectionScope();
6359
+
6246
6360
  if (!(options.nonInteractive || process.env.AGENT_VIBES_NON_INTERACTIVE === '1')) {
6247
6361
  // Clean final summary
6248
6362
  console.log('');
@@ -6997,7 +7111,7 @@ export {
6997
7111
  CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
6998
7112
  // Manifest utilities (used by tests and external tooling)
6999
7113
  getProjectManifestPath, getGlobalManifestPath,
7000
- loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
7114
+ loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
7001
7115
  // Pure helper functions exported for testing
7002
7116
  readJsonConfigSafe, backupConfigFile,
7003
7117
  isPiperProvider, supportsEmoji, getPersonalityIcon,
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Provider Catalog — Layer 2 of the SSOT program (companion to the Utterance
3
+ * Resolver, src/services/utterance-resolver.js). See
4
+ * docs/architecture/provider-catalog-design.md.
5
+ *
6
+ * ONE record per provider answers the inventory question — "WHAT exists to speak
7
+ * with?": voice lists, defaults, membership validation, per-platform runtime,
8
+ * and display names. The resolver answers the orthogonal question ("HOW is this
9
+ * utterance spoken?"). The two layers NEVER import each other — the seam is
10
+ * welded by conformance assertions only (see
11
+ * test/unit/provider-catalog-conformance.test.js). That keeps the resolver's
12
+ * 433-test contract untouched.
13
+ *
14
+ * DISCIPLINE: this module is PURE data + PURE functions. No `fs`, no `env`, no
15
+ * clock — exactly like the resolver. Discovered providers (piper, macos, SAPI)
16
+ * receive their installed-voice list via injection (`{ installed }`); the
17
+ * catalog owns the RULE, never the I/O.
18
+ *
19
+ * SECURITY NOTE: the receiver `ALLOWED_VOICES` allowlists
20
+ * (clawdbot-receiver-SECURE.sh) are a SECURITY control, NOT inventory — they are
21
+ * deliberately narrower than this catalog and must never be unified with it.
22
+ *
23
+ * @module services/provider-catalog
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ /* ------------------------------------------------------------------ *
29
+ * Static voice data (the canonical copies; provider-voice-catalog.js
30
+ * re-exports these for back-compat).
31
+ * ------------------------------------------------------------------ */
32
+
33
+ /** ElevenLabs DEFAULT-LIBRARY voices (voice_id + display metadata), available to
34
+ * every API key. Community/library voices that must be added to an account first
35
+ * are intentionally excluded so a switch can't fail at synth time. Mirrored on
36
+ * the shell side by .claude/hooks/elevenlabs-voices.sh (parity-guarded). */
37
+ const ELEVENLABS_VOICES = [
38
+ { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
39
+ { id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
40
+ { id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
41
+ { id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
42
+ { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
43
+ { id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
44
+ { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
45
+ { id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
46
+ { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
47
+ { id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
48
+ { id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
49
+ { id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
50
+ { id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
51
+ { id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
52
+ { id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
53
+ { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
54
+ { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
55
+ { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
56
+ { id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
57
+ { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
58
+ { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
59
+ ];
60
+
61
+ /** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
62
+ const KOKORO_VOICE_IDS = [
63
+ // American English
64
+ 'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
65
+ 'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
66
+ // British English
67
+ 'bf_alice','bf_emma','bf_isabella','bf_lily',
68
+ 'bm_daniel','bm_fable','bm_george','bm_lewis',
69
+ // Japanese
70
+ 'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
71
+ // Mandarin Chinese
72
+ 'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
73
+ // Spanish
74
+ 'ef_dora','em_alex','em_santa',
75
+ // French
76
+ 'ff_siwis',
77
+ // Hindi
78
+ 'hf_alpha','hm_omega',
79
+ // Italian
80
+ 'if_sara','im_nicola',
81
+ // Brazilian Portuguese
82
+ 'pf_dora','pm_alex','pm_santa',
83
+ // Korean
84
+ 'kf_alpha','km_hyunsu',
85
+ ];
86
+
87
+ /**
88
+ * Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
89
+ * where the 2nd character is `f` (female) or `m` (male).
90
+ * @param {string} id
91
+ * @returns {'Female'|'Male'|''}
92
+ */
93
+ function kokoroGender(id) {
94
+ const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
95
+ if (c === 'f') return 'Female';
96
+ if (c === 'm') return 'Male';
97
+ return '';
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ *
101
+ * Voice-model validation rules (pure). Each returns { ok, canonical, reason }.
102
+ * ------------------------------------------------------------------ */
103
+
104
+ /** Piper-family voice shape: `en_US-lessac-medium` or a `model::Speaker` form. */
105
+ const PIPER_VOICE_SHAPE = /^[a-z]{2,3}_[A-Z]{2}-/;
106
+ /** Raw ElevenLabs voice_id shape (preserves elevenlabs-voices.sh:66 semantics). */
107
+ const ELEVENLABS_ID_SHAPE = /^[A-Za-z0-9]{20}$/;
108
+
109
+ /* ------------------------------------------------------------------ *
110
+ * The seven canonical ProviderRecords.
111
+ *
112
+ * `aliases` are spellings that resolve to THIS record via getProvider()
113
+ * (e.g. windows-sapi ← 'sapi'). `engineId` is the resolver ENGINES value
114
+ * ('sapi' for windows-sapi, 'piper' for windows-piper); the derived
115
+ * engineAliasTable() mirrors the resolver's ENGINE_ALIASES.
116
+ * ------------------------------------------------------------------ */
117
+
118
+ const RECORDS = [
119
+ {
120
+ id: 'soprano',
121
+ engineId: 'soprano',
122
+ aliases: [],
123
+ displayName: 'Soprano TTS',
124
+ voiceModel: 'single',
125
+ voices: ['soprano-default'],
126
+ defaultVoice: 'soprano-default',
127
+ runtime: { unix: 'play-tts-soprano.sh', windows: 'play-tts-soprano.ps1', darwinOnly: false },
128
+ requires: 'pip',
129
+ },
130
+ {
131
+ id: 'piper',
132
+ engineId: 'piper',
133
+ aliases: [],
134
+ displayName: 'Piper TTS',
135
+ voiceModel: 'discovered',
136
+ voices: null, // discovered on disk (*.onnx glob)
137
+ defaultVoice: 'en_US-lessac-medium', // preferred fallback, NOT a guaranteed install
138
+ runtime: { unix: 'play-tts-piper.sh', windows: null, darwinOnly: false }, // Windows uses windows-piper
139
+ requires: 'pip',
140
+ },
141
+ {
142
+ id: 'kokoro',
143
+ engineId: 'kokoro',
144
+ aliases: [],
145
+ displayName: 'Kokoro TTS',
146
+ voiceModel: 'static',
147
+ voices: KOKORO_VOICE_IDS,
148
+ defaultVoice: 'af_heart',
149
+ runtime: { unix: 'play-tts-kokoro.sh', windows: 'play-tts-kokoro.ps1', darwinOnly: false },
150
+ requires: 'pip',
151
+ },
152
+ {
153
+ id: 'elevenlabs',
154
+ engineId: 'elevenlabs',
155
+ aliases: [],
156
+ displayName: 'ElevenLabs',
157
+ voiceModel: 'name-to-id',
158
+ voices: ELEVENLABS_VOICES,
159
+ defaultVoice: 'Sarah',
160
+ runtime: { unix: 'play-tts-elevenlabs.sh', windows: null, darwinOnly: false }, // ← no ps1 runtime; the contradiction, now data
161
+ requires: 'api-key',
162
+ },
163
+ {
164
+ id: 'macos',
165
+ engineId: 'macos',
166
+ aliases: ['macos-say', 'say'],
167
+ displayName: 'macOS Say',
168
+ voiceModel: 'discovered',
169
+ voices: null, // `say -v ?`
170
+ defaultVoice: 'Samantha',
171
+ runtime: { unix: 'play-tts-macos.sh', windows: null, darwinOnly: true },
172
+ requires: 'builtin',
173
+ },
174
+ {
175
+ id: 'windows-sapi',
176
+ engineId: 'sapi',
177
+ aliases: ['sapi'],
178
+ displayName: 'Windows SAPI',
179
+ voiceModel: 'discovered',
180
+ voices: null, // System.Speech
181
+ defaultVoice: '', // system default
182
+ runtime: { unix: null, windows: 'play-tts-sapi.ps1', darwinOnly: false },
183
+ requires: 'builtin',
184
+ },
185
+ {
186
+ id: 'windows-piper',
187
+ engineId: 'piper',
188
+ aliases: ['piper'], // on Windows, bare 'piper' forwards to windows-piper
189
+ displayName: 'Piper TTS',
190
+ voiceModel: 'discovered',
191
+ voices: null, // same *.onnx models as piper
192
+ defaultVoice: 'en_US-lessac-medium',
193
+ runtime: { unix: null, windows: 'play-tts-windows-piper.ps1', darwinOnly: false },
194
+ requires: 'exe',
195
+ },
196
+ ];
197
+
198
+ // Lookup indexes (built once).
199
+ const _BY_ID = new Map();
200
+ const _BY_ALIAS = new Map();
201
+ for (const r of RECORDS) {
202
+ _BY_ID.set(r.id, r);
203
+ }
204
+ for (const r of RECORDS) {
205
+ for (const a of r.aliases) {
206
+ // Exact-id spellings always win, so never let an alias shadow a real id.
207
+ if (!_BY_ID.has(a) && !_BY_ALIAS.has(a)) _BY_ALIAS.set(a, r);
208
+ }
209
+ }
210
+
211
+ /** Normalize an id-or-alias to a lowercase key. */
212
+ function _norm(idOrAlias) {
213
+ return typeof idOrAlias === 'string' ? idOrAlias.trim().toLowerCase() : '';
214
+ }
215
+
216
+ /* ------------------------------------------------------------------ *
217
+ * Public API (design §3.2). All entries are alias-normalizing.
218
+ * ------------------------------------------------------------------ */
219
+
220
+ /**
221
+ * @param {string} idOrAlias
222
+ * @returns {object|null} the ProviderRecord, or null if unknown.
223
+ */
224
+ function getProvider(idOrAlias) {
225
+ const key = _norm(idOrAlias);
226
+ if (!key) return null;
227
+ return _BY_ID.get(key) || _BY_ALIAS.get(key) || null;
228
+ }
229
+
230
+ /** @returns {object[]} all seven records (fresh array; records are shared). */
231
+ function listProviders() {
232
+ return RECORDS.slice();
233
+ }
234
+
235
+ /**
236
+ * Is a provider playable on `platform`?
237
+ * - 'windows' → has a Windows runtime script.
238
+ * - 'darwin' → has a Unix runtime script (darwin runs the .sh players).
239
+ * - 'unix' → has a Unix runtime AND is not darwin-only (i.e. Linux).
240
+ * @param {string} idOrAlias
241
+ * @param {'unix'|'windows'|'darwin'} platform
242
+ * @returns {boolean}
243
+ */
244
+ function isAvailable(idOrAlias, platform) {
245
+ const r = getProvider(idOrAlias);
246
+ if (!r) return false;
247
+ if (platform === 'windows') return r.runtime.windows !== null;
248
+ if (platform === 'darwin') return r.runtime.unix !== null;
249
+ return r.runtime.unix !== null && !r.runtime.darwinOnly; // 'unix' == generic Linux
250
+ }
251
+
252
+ /**
253
+ * @param {'unix'|'windows'|'darwin'} platform
254
+ * @returns {object[]} records available on that platform.
255
+ */
256
+ function providersFor(platform) {
257
+ return RECORDS.filter((r) => isAvailable(r.id, platform));
258
+ }
259
+
260
+ /**
261
+ * List a provider's voices as `{ id, name?, gender, lang? }` entries. Discovered
262
+ * providers require an injected `installed` list (else an empty array).
263
+ * @param {string} idOrAlias
264
+ * @param {{installed?: string[]}} [opts]
265
+ * @returns {Array<{id: string, name?: string, gender: string, lang?: string}>}
266
+ */
267
+ function listVoices(idOrAlias, { installed } = {}) {
268
+ const r = getProvider(idOrAlias);
269
+ if (!r) return [];
270
+ switch (r.voiceModel) {
271
+ case 'static': // kokoro
272
+ return r.voices.map((id) => ({ id, gender: kokoroGender(id) }));
273
+ case 'name-to-id': // elevenlabs
274
+ return r.voices.map((v) => ({ id: v.id, name: v.name, gender: v.gender || '', lang: v.lang }));
275
+ case 'single': // soprano
276
+ return r.voices.map((id) => ({ id, gender: '' }));
277
+ case 'discovered': // piper / macos / windows-sapi / windows-piper
278
+ default:
279
+ if (Array.isArray(installed)) return installed.map((id) => ({ id, gender: '' }));
280
+ return [];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * @param {string} idOrAlias
286
+ * @returns {string} the provider's default voice ('' for windows-sapi = system default).
287
+ */
288
+ function defaultVoice(idOrAlias) {
289
+ const r = getProvider(idOrAlias);
290
+ return r ? r.defaultVoice : '';
291
+ }
292
+
293
+ /**
294
+ * Validate a voice for a provider. STRICT membership at config-write time
295
+ * (typos never reach voice.txt). Returns `{ ok, canonical, reason }` — never a
296
+ * bare boolean — so every rejection carries a message and the accepted spelling
297
+ * is normalized ONCE.
298
+ *
299
+ * @param {string} idOrAlias
300
+ * @param {string} voice
301
+ * @param {{installed?: string[], allowUnlisted?: boolean}} [opts]
302
+ * `allowUnlisted` expresses the AGENTVIBES_ALLOW_UNLISTED_VOICE escape hatch —
303
+ * the catalog never reads env; consumers pass the flag (design §3.3). It relaxes
304
+ * MEMBERSHIP, not shape.
305
+ * @returns {{ok: boolean, canonical: string|null, reason: string}}
306
+ */
307
+ function validateVoice(idOrAlias, voice, { installed, allowUnlisted = false } = {}) {
308
+ const r = getProvider(idOrAlias);
309
+ if (!r) return { ok: false, canonical: null, reason: `Unknown provider: ${idOrAlias}` };
310
+ const raw = typeof voice === 'string' ? voice.trim() : '';
311
+
312
+ switch (r.voiceModel) {
313
+ case 'static': { // kokoro — case-fold, exact membership
314
+ const folded = raw.toLowerCase();
315
+ if (r.voices.includes(folded)) return { ok: true, canonical: folded, reason: '' };
316
+ if (allowUnlisted && folded) return { ok: true, canonical: folded, reason: '' };
317
+ return { ok: false, canonical: null, reason: `"${voice}" is not a known ${r.displayName} voice` };
318
+ }
319
+ case 'name-to-id': { // elevenlabs — case-insensitive name → id, OR raw 20-char id
320
+ const lc = raw.toLowerCase();
321
+ const byName = r.voices.find((v) => v.name.toLowerCase() === lc);
322
+ if (byName) return { ok: true, canonical: byName.id, reason: '' };
323
+ if (ELEVENLABS_ID_SHAPE.test(raw)) return { ok: true, canonical: raw, reason: '' };
324
+ return { ok: false, canonical: null, reason: `"${voice}" is not a known ElevenLabs voice name or id` };
325
+ }
326
+ case 'single': { // soprano — '', 'soprano', 'soprano-default' → canonical 'soprano-default'
327
+ const lc = raw.toLowerCase();
328
+ if (lc === '' || lc === r.id || lc === r.defaultVoice) {
329
+ return { ok: true, canonical: r.defaultVoice, reason: '' };
330
+ }
331
+ return { ok: false, canonical: null, reason: `${r.displayName} has a single voice (${r.defaultVoice})` };
332
+ }
333
+ case 'discovered': // piper / macos / windows-sapi / windows-piper
334
+ default: {
335
+ const isPiperFamily = r.engineId === 'piper';
336
+ if (Array.isArray(installed)) {
337
+ if (installed.includes(raw)) return { ok: true, canonical: raw, reason: '' };
338
+ if (!allowUnlisted) {
339
+ return { ok: false, canonical: null, reason: `"${voice}" is not an installed ${r.displayName} voice` };
340
+ }
341
+ // allowUnlisted: fall through to shape/permissive check
342
+ }
343
+ if (isPiperFamily) {
344
+ if (PIPER_VOICE_SHAPE.test(raw) || raw.includes('::')) return { ok: true, canonical: raw, reason: '' };
345
+ return { ok: false, canonical: null, reason: `"${voice}" is not a valid Piper voice id` };
346
+ }
347
+ // macos / windows-sapi: permissive when no list is available; '' = system default.
348
+ if (raw === '' && r.defaultVoice === '') return { ok: true, canonical: '', reason: '' };
349
+ if (raw) return { ok: true, canonical: raw, reason: '' };
350
+ return { ok: false, canonical: null, reason: `Empty voice for ${r.displayName}` };
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * @param {string} idOrAlias
357
+ * @returns {string} a human display name, or the input unchanged if unknown
358
+ * (mirrors provider-validator.getProviderDisplayName's passthrough).
359
+ */
360
+ function displayName(idOrAlias) {
361
+ const r = getProvider(idOrAlias);
362
+ if (r) return r.displayName;
363
+ return typeof idOrAlias === 'string' ? idOrAlias : '';
364
+ }
365
+
366
+ /**
367
+ * Derived alias→engine table, built to MIRROR the resolver's ENGINE_ALIASES
368
+ * (utterance-resolver.js:52-57). A record whose id differs from its engineId
369
+ * contributes id→engineId; each non-identity alias contributes alias→engineId.
370
+ * Conformance group 6 asserts this deep-equals the resolver's table.
371
+ * @returns {Record<string,string>}
372
+ */
373
+ function engineAliasTable() {
374
+ const table = {};
375
+ for (const r of RECORDS) {
376
+ if (r.id !== r.engineId) table[r.id] = r.engineId;
377
+ for (const a of r.aliases) {
378
+ if (a !== r.engineId) table[a] = r.engineId;
379
+ }
380
+ }
381
+ return table;
382
+ }
383
+
384
+ export {
385
+ getProvider,
386
+ listProviders,
387
+ providersFor,
388
+ isAvailable,
389
+ listVoices,
390
+ defaultVoice,
391
+ validateVoice,
392
+ displayName,
393
+ engineAliasTable,
394
+ kokoroGender,
395
+ ELEVENLABS_VOICES,
396
+ KOKORO_VOICE_IDS,
397
+ };
398
+
399
+ export default {
400
+ getProvider,
401
+ listProviders,
402
+ providersFor,
403
+ isAvailable,
404
+ listVoices,
405
+ defaultVoice,
406
+ validateVoice,
407
+ displayName,
408
+ engineAliasTable,
409
+ kokoroGender,
410
+ ELEVENLABS_VOICES,
411
+ KOKORO_VOICE_IDS,
412
+ };