agentvibes 4.0.0 → 4.2.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 (42) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +174 -67
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +23 -5
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +8 -0
  25. package/src/console/navigation.js +3 -1
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/install-tab.js +2 -1
  28. package/src/console/tabs/placeholder-tab.js +9 -1
  29. package/src/console/tabs/receiver-tab.js +1212 -0
  30. package/src/console/tabs/settings-tab.js +33 -323
  31. package/src/console/widgets/destroy-list.js +25 -0
  32. package/src/console/widgets/format-utils.js +89 -0
  33. package/src/console/widgets/notice.js +55 -0
  34. package/src/console/widgets/personality-picker.js +185 -0
  35. package/src/console/widgets/reverb-picker.js +94 -0
  36. package/src/console/widgets/track-picker.js +285 -0
  37. package/src/installer.js +54 -2
  38. package/src/services/agent-voice-store.js +282 -22
  39. package/src/services/config-service.js +24 -0
  40. package/src/services/navigation-service.js +1 -1
  41. package/src/utils/music-file-validator.js +41 -31
  42. package/templates/agentvibes-receiver.sh +431 -111
@@ -242,12 +242,15 @@ class AgentVibesVoiceBrowser {
242
242
  for (const line of lines) {
243
243
  try {
244
244
  const voice = JSON.parse(line);
245
- voices.push({
246
- name: voice.Name,
247
- gender: voice.Gender?.toLowerCase() || 'unknown',
248
- language: voice.Culture || 'en-US',
249
- provider: 'windows-sapi'
250
- });
245
+ // SECURITY: Validate expected schema from PowerShell output (#133)
246
+ if (voice && typeof voice.Name === 'string' && voice.Name.length > 0) {
247
+ voices.push({
248
+ name: voice.Name,
249
+ gender: typeof voice.Gender === 'string' ? voice.Gender.toLowerCase() : 'unknown',
250
+ language: typeof voice.Culture === 'string' ? voice.Culture : 'en-US',
251
+ provider: 'windows-sapi'
252
+ });
253
+ }
251
254
  } catch {}
252
255
  }
253
256
  return voices;
@@ -1309,7 +1312,8 @@ class AgentVibesVoiceBrowser {
1309
1312
 
1310
1313
  for (const player of players) {
1311
1314
  try {
1312
- await execAsync(`which ${player.cmd} 2>/dev/null`);
1315
+ // SECURITY: Use spawnSync instead of shell string (#126)
1316
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1313
1317
 
1314
1318
  const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1315
1319
  this.currentAudioProcess = audioProcess;
@@ -1508,7 +1512,10 @@ class AgentVibesVoiceBrowser {
1508
1512
 
1509
1513
  async playWindowsSAPIVoice(row, sampleText) {
1510
1514
  try {
1511
- const psScript = `Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synth.SelectVoice('${row.name}'); $synth.Speak('${sampleText.replace(/'/g, "''")}')`;
1515
+ // SECURITY: Escape row.name and sampleText for PowerShell single-quote context (#124)
1516
+ const safeName = row.name.replace(/'/g, "''");
1517
+ const safeText = sampleText.replace(/'/g, "''");
1518
+ const psScript = `Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synth.SelectVoice('${safeName}'); $synth.Speak('${safeText}')`;
1512
1519
  const process = spawn('powershell', ['-Command', psScript], { stdio: 'ignore' });
1513
1520
  this.currentAudioProcess = process;
1514
1521
 
@@ -1547,9 +1554,18 @@ class AgentVibesVoiceBrowser {
1547
1554
  };
1548
1555
  await fs.writeFile(payloadFile, JSON.stringify(payload));
1549
1556
 
1550
- // Call Soprano API to generate audio (OpenAI format)
1551
- const curlCmd = `curl -s -m 10 -X POST http://127.0.0.1:7860/v1/audio/speech -H "Content-Type: application/json" -d @${payloadFile} -o ${outputFile}`;
1552
- await execAsync(curlCmd);
1557
+ // SECURITY: Use spawn with argument array instead of shell string (#125)
1558
+ await new Promise((resolve, reject) => {
1559
+ const curlProc = spawn('curl', [
1560
+ '-s', '-m', '10', '-X', 'POST',
1561
+ 'http://127.0.0.1:7860/v1/audio/speech',
1562
+ '-H', 'Content-Type: application/json',
1563
+ '-d', `@${payloadFile}`,
1564
+ '-o', outputFile
1565
+ ], { stdio: 'ignore' });
1566
+ curlProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`curl exited ${code}`)));
1567
+ curlProc.on('error', reject);
1568
+ });
1553
1569
 
1554
1570
  // Clean up payload file
1555
1571
  try {
@@ -1565,7 +1581,8 @@ class AgentVibesVoiceBrowser {
1565
1581
 
1566
1582
  for (const player of players) {
1567
1583
  try {
1568
- await execAsync(`which ${player.cmd} 2>/dev/null`);
1584
+ // SECURITY: Use spawnSync instead of shell string (#126)
1585
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1569
1586
 
1570
1587
  const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
1571
1588
  this.currentAudioProcess = audioProcess;
@@ -1630,10 +1647,8 @@ class AgentVibesVoiceBrowser {
1630
1647
  }
1631
1648
 
1632
1649
  const modelPath = path.join(CONFIG.PIPER_VOICES_DIR, `${safeModel}.onnx`);
1633
- try {
1634
- await fs.access(outputFile);
1635
- } catch {
1636
- // Use spawn instead of execAsync to prevent command injection
1650
+ // SECURITY: Always regenerate instead of TOCTOU check (#132)
1651
+ {
1637
1652
  const piperProcess = spawn(CONFIG.PIPER_PATH, [
1638
1653
  '--model', modelPath,
1639
1654
  '--output_file', outputFile
@@ -1666,10 +1681,8 @@ class AgentVibesVoiceBrowser {
1666
1681
  return;
1667
1682
  }
1668
1683
 
1669
- try {
1670
- await fs.access(outputFile);
1671
- } catch {
1672
- // Use spawn instead of execAsync to prevent command injection
1684
+ // SECURITY: Always regenerate instead of TOCTOU check (#132)
1685
+ {
1673
1686
  const piperProcess = spawn(CONFIG.PIPER_PATH, [
1674
1687
  '--model', CONFIG.MODEL_PATH,
1675
1688
  '--speaker', row.id.toString(),
@@ -1694,7 +1707,8 @@ class AgentVibesVoiceBrowser {
1694
1707
 
1695
1708
  for (const player of players) {
1696
1709
  try {
1697
- await execAsync(`which ${player.cmd} 2>/dev/null`);
1710
+ // SECURITY: Use spawnSync instead of shell string (#126)
1711
+ if (spawnSync('which', [player.cmd], { stdio: 'ignore' }).status !== 0) throw new Error('not found');
1698
1712
 
1699
1713
  // SECURITY: Store process immediately to prevent leak
1700
1714
  const audioProcess = spawn(player.cmd, player.args, { stdio: 'ignore' });
@@ -821,6 +821,42 @@ class AgentVibesServer:
821
821
  result = await self._run_script("clean-audio-cache.sh", [])
822
822
  return result if result else "❌ Failed to clean audio cache"
823
823
 
824
+ async def set_banner(self, enabled: bool) -> str:
825
+ """
826
+ Enable or disable the TTS output banner (voice info, file path, cache size).
827
+
828
+ Args:
829
+ enabled: True to show banner, False to hide it
830
+
831
+ Returns:
832
+ Confirmation message
833
+ """
834
+ banner_file = Path.home() / ".agentvibes" / "banner-disabled"
835
+ if enabled:
836
+ # Remove the disable flag
837
+ try:
838
+ banner_file.unlink(missing_ok=True)
839
+ except Exception:
840
+ pass
841
+ return "✅ TTS banner enabled — voice info will show after each speech"
842
+ else:
843
+ # Create the disable flag
844
+ banner_file.parent.mkdir(parents=True, exist_ok=True)
845
+ banner_file.touch()
846
+ return "🔇 TTS banner disabled — speech will play without output info"
847
+
848
+ async def get_banner(self) -> str:
849
+ """
850
+ Check if the TTS output banner is enabled or disabled.
851
+
852
+ Returns:
853
+ Current banner status
854
+ """
855
+ banner_file = Path.home() / ".agentvibes" / "banner-disabled"
856
+ if banner_file.exists():
857
+ return "🔇 TTS banner: **disabled**\n\nSay: \"Turn on banner\" to re-enable"
858
+ return "✅ TTS banner: **enabled**\n\nSay: \"Turn off banner\" to disable"
859
+
824
860
  # Helper methods
825
861
  def _build_script_env(self) -> dict:
826
862
  """Build environment dict for script execution (shared by all script runners)"""
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "4.0.0",
4
+ "version": "4.2.0",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -89,7 +89,6 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@inquirer/search": "^3.1.3",
92
- "agentvibes": "^3.5.9",
93
92
  "blessed": "^0.1.81",
94
93
  "boxen": "^7.0.0",
95
94
  "chalk": "^5.0.0",
@@ -106,7 +105,6 @@
106
105
  "access": "public"
107
106
  },
108
107
  "devDependencies": {
109
- "bats": "^1.13.0",
110
108
  "c8": "^10.1.3"
111
109
  }
112
110
  }
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
15
  import { NavigationService, TAB_ORDER } from '../services/navigation-service.js';
16
16
  import { setupNavigation } from './navigation.js';
17
- import { createPlaceholderTab, TAB_DISPLAY_LABELS } from './tabs/placeholder-tab.js';
17
+ import { createPlaceholderTab, TAB_DISPLAY_LABELS, TAB_SHORTCUT_KEYS } from './tabs/placeholder-tab.js';
18
18
  import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
19
19
  import { createModalOverlay } from './modals/modal-overlay.js';
20
20
  import { BRAND_PINK } from './brand-colors.js';
@@ -24,6 +24,8 @@ import { createMusicTab } from './tabs/music-tab.js';
24
24
  import { createInstallTab } from './tabs/install-tab.js';
25
25
  import { createHelpTab } from './tabs/help-tab.js';
26
26
  import { createReadmeTab } from './tabs/readme-tab.js';
27
+ import { createReceiverTab } from './tabs/receiver-tab.js';
28
+ import { createAgentsTab } from './tabs/agents-tab.js';
27
29
  import { ConfigService } from '../services/config-service.js';
28
30
  import { ProviderService } from '../services/provider-service.js';
29
31
 
@@ -112,7 +114,7 @@ export class AgentVibesConsole {
112
114
  // Screen options stored as property so tests can verify correct configuration
113
115
  // without needing to intercept the blessed.screen() call (ESM mock limitation).
114
116
  this._screenOptions = {
115
- smartCSR: false,
117
+ smartCSR: true,
116
118
  mouse: true,
117
119
  fullUnicode: true,
118
120
  title: `AgentVibes v${APP_VERSION} TUI Console`,
@@ -292,7 +294,8 @@ export class AgentVibesConsole {
292
294
  let xOffset = 1;
293
295
  for (const id of TAB_ORDER) {
294
296
  const label = TAB_DISPLAY_LABELS[id];
295
- const text = ` [${label[0]}] ${label} `;
297
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
298
+ const text = ` [${shortcutKey}] ${label} `;
296
299
  const el = blessed.box({
297
300
  parent: this.screen,
298
301
  top: 3,
@@ -478,10 +481,11 @@ export class AgentVibesConsole {
478
481
  _renderTabBarContent(activeTabId) {
479
482
  return TAB_ORDER.map(id => {
480
483
  const label = TAB_DISPLAY_LABELS[id];
484
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
481
485
  if (id === activeTabId) {
482
- return `{bold}{white-fg}[${label[0]}] ${label}{/white-fg}{/bold}`;
486
+ return `{bold}{white-fg}[${shortcutKey}] ${label}{/white-fg}{/bold}`;
483
487
  }
484
- return `{#82b1ff-fg}[${label[0]}] ${label}{/#82b1ff-fg}`;
488
+ return `{#82b1ff-fg}[${shortcutKey}] ${label}{/#82b1ff-fg}`;
485
489
  }).join(' ');
486
490
  }
487
491
 
@@ -657,6 +661,20 @@ export class AgentVibesConsole {
657
661
  }
658
662
  this.tabs['help'] = createHelpTab(this.screen, services);
659
663
 
664
+ // Destroy agents placeholder and mount real agents tab
665
+ const agentsPlaceholder = this.tabs['agents'];
666
+ if (agentsPlaceholder && typeof agentsPlaceholder.destroy === 'function') {
667
+ agentsPlaceholder.destroy();
668
+ }
669
+ this.tabs['agents'] = createAgentsTab(this.screen, services);
670
+
671
+ // Destroy receiver placeholder and mount real receiver tab
672
+ const receiverPlaceholder = this.tabs['receiver'];
673
+ if (receiverPlaceholder && typeof receiverPlaceholder.destroy === 'function') {
674
+ receiverPlaceholder.destroy();
675
+ }
676
+ this.tabs['receiver'] = createReceiverTab(this.screen, services);
677
+
660
678
  const readmePlaceholder = this.tabs['readme'];
661
679
  if (readmePlaceholder && typeof readmePlaceholder.destroy === 'function') {
662
680
  readmePlaceholder.destroy();
@@ -0,0 +1,44 @@
1
+ /**
2
+ * AgentVibes — Canonical personality constants.
3
+ *
4
+ * Single source of truth for personality names and their associated emoji
5
+ * glyphs. All TUI modules (settings-tab, agents-tab, personality-picker …)
6
+ * import from here; src/installer.js maintains its own copy because it
7
+ * predates the TUI and uses a different module-load path.
8
+ *
9
+ * Exported:
10
+ * PERSONALITY_EMOJIS — Map of personality name → emoji string
11
+ * PERSONALITIES — Ordered array of personality names (canonical order
12
+ * used for picker lists)
13
+ */
14
+
15
+ export const PERSONALITY_EMOJIS = Object.freeze({
16
+ angry: '😠',
17
+ annoying: '😤',
18
+ crass: '🤬',
19
+ dramatic: '🎭',
20
+ 'dry-humor': '😐',
21
+ flirty: '😘',
22
+ funny: '😂',
23
+ grandpa: '👴',
24
+ millennial: '🙄',
25
+ moody: '😒',
26
+ none: '😊',
27
+ normal: '😊',
28
+ pirate: '⚓',
29
+ poetic: '📜',
30
+ professional: '👔',
31
+ rapper: '🎤',
32
+ robot: '🤖',
33
+ sarcastic: '😏',
34
+ sassy: '💁',
35
+ 'surfer-dude':'🏄',
36
+ zen: '🧘',
37
+ });
38
+
39
+ export const PERSONALITIES = Object.freeze([
40
+ 'none', 'angry', 'annoying', 'crass', 'dramatic', 'dry-humor',
41
+ 'flirty', 'funny', 'grandpa', 'millennial', 'moody', 'normal',
42
+ 'pirate', 'poetic', 'professional', 'rapper', 'robot', 'sarcastic',
43
+ 'sassy', 'surfer-dude', 'zen',
44
+ ]);
@@ -35,6 +35,14 @@ export const FOOTER_CONFIG = {
35
35
  color: '#607d8b',
36
36
  text: ` ${key('↑↓')} Scroll ${key('Q')} Quit`,
37
37
  },
38
+ agents: {
39
+ color: '#9c27b0',
40
+ text: ` ${key('↑↓')} Navigate ${key('Enter')} Edit Agent ${key('Space')} Sample ${key('R')} Reset`,
41
+ },
42
+ receiver: {
43
+ color: '#00897b',
44
+ text: ` ${key('E')} Enable ${key('D')} Details ${key('C')} Clear Log`,
45
+ },
38
46
  install: {
39
47
  color: '#1a237e',
40
48
  text: ` ${key('↑↓')} Navigate ${key('Enter')} Select ${key('Esc')} Back`,
@@ -3,7 +3,7 @@
3
3
  * Story 6.2: Tab Bar & Global Keyboard Navigation
4
4
  *
5
5
  * Registers all global key bindings on the Blessed screen.
6
- * Tab shortcuts (S/V/M/A/R/H/I) are blocked when a modal is open.
6
+ * Tab shortcuts (S/V/M/X/R/H/I) are blocked when a modal is open.
7
7
  */
8
8
 
9
9
  /** Map of key → tab ID for global tab shortcut keys */
@@ -11,6 +11,8 @@ const KEY_TO_TAB = {
11
11
  's': 'settings', 'S': 'settings',
12
12
  'v': 'voices', 'V': 'voices',
13
13
  'm': 'music', 'M': 'music',
14
+ 'b': 'agents', 'B': 'agents',
15
+ 'x': 'receiver', 'X': 'receiver',
14
16
  'r': 'readme', 'R': 'readme',
15
17
  'h': 'help', 'H': 'help',
16
18
  'i': 'install', 'I': 'install',