mcp-voice-hooks 1.0.8 → 1.0.13

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.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Real-time voice interaction for Claude Code. Speak naturally while Claude works - interrupt, redirect, or provide continuous feedback without stopping.
4
4
 
5
+ Optionally enable text-to-speech to have Claude speak back to you.
6
+
7
+ Mac only for now.
8
+
5
9
  ## Demo
6
10
 
7
11
  [![Voice Hooks Demo](https://img.youtube.com/vi/KpkxvJ65gbM/0.jpg)](https://youtu.be/KpkxvJ65gbM)
@@ -15,11 +19,11 @@ mcp-voice-hooks enables continuous voice conversations with AI assistants by:
15
19
  - Using hooks to ensure Claude checks for voice input before tool use and before stopping
16
20
  - Allowing natural interruptions like "No, stop that" or "Wait, try something else"
17
21
 
18
- ## Features
22
+ ## Browser Compatibility
19
23
 
20
- - šŸŽ¤ **Real-time Voice Capture**: Browser-based speech recognition with automatic segmentation
21
- - šŸ”„ **Continuous Interaction**: Keep talking while Claude works - no need to stop between commands
22
- - šŸŖ **Smart Hook System**: Pre-tool and stop hooks ensure Claude always checks for your input
24
+ - āœ… **Chrome**: Full support for speech recognition, browser text-to-speech, and system text-to-speech
25
+ - āš ļø **Safari**: Full support for speech recognition, but only system text-to-speech is supported
26
+ - āŒ **Edge**: Speech recognition not working on Apple Silicon (language-not-supported error)
23
27
 
24
28
  ## Installation in Your Own Project
25
29
 
@@ -52,21 +56,42 @@ mcp-voice-hooks enables continuous voice conversations with AI assistants by:
52
56
  claude
53
57
  ```
54
58
 
55
- 3. **Open the voice interface** at <http://localhost:5111> and start speaking!
59
+ **Important**: After the first-time installation, you will need to restart Claude for the hooks to take effect. This is because the hooks are automatically installed when the MCP server starts for the first time.
56
60
 
57
- The hooks are automatically installed when the MCP server starts. You need to send one text message to Claude to trigger the voice hooks.
61
+ 3. **Open the voice interface** at <http://localhost:5111> and start speaking!
58
62
 
59
- **Note**: After the first-time installation, you may need to restart Claude for the hooks to take effect.
63
+ You need to send one text message to Claude to trigger the voice hooks.
64
+
65
+ ## Voice responses
66
+
67
+ There are two options for voice responses:
68
+
69
+ 1. Browser Text-to-Speech
70
+ 2. System Text-to-Speech
71
+
72
+ ### Selecting and downloading high quality System Voices (Mac only)
73
+
74
+ Mac has built-in text to speech, but high quality voices are not available by default.
75
+
76
+ You can download high quality voices from the system voice menu: `System Settings > Accessibility > Spoken Content > System Voice`
77
+
78
+ Click the info icon next to the system voice dropdown. Search for "Siri" to find the highest quality voices. You'll have to trigger a download of the voice.
79
+
80
+ Once it's downloaded, you can select it in the system voice menu.
81
+
82
+ Test it with the bash command:
83
+
84
+ ```bash
85
+ say "Hi, this is your mac system voice"
86
+ ```
87
+
88
+ For Siri voices you need to set your system voice and select "Mac System Voice" in the voice-hooks browser interface.
89
+
90
+ Other downloaded voices will show up in the voice dropdown in the voice-hooks browser interface.
91
+
92
+ ### Selecting and downloading high quality Browser Voices
60
93
 
61
- The default port is 5111. To use a different port, add to your project's `.claude/settings.json`:
62
94
 
63
- ```json
64
- {
65
- "env": {
66
- "MCP_VOICE_HOOKS_PORT": "8080"
67
- }
68
- }
69
- ```
70
95
 
71
96
  ## Manual Hook Installation
72
97
 
@@ -76,30 +101,31 @@ The hooks are automatically installed/updated when the MCP server starts. Howeve
76
101
  npx mcp-voice-hooks install-hooks
77
102
  ```
78
103
 
79
- This will:
80
-
81
- - Clean up any existing `~/.mcp-voice-hooks` directory contents
82
- - Install/update hook scripts to `~/.mcp-voice-hooks/hooks/`
83
- - Configure your project's `.claude/settings.json`
104
+ This will configure your project's `.claude/settings.json` with the necessary hook commands.
84
105
 
85
106
  ## Uninstallation
86
107
 
87
108
  To completely remove MCP Voice Hooks:
88
109
 
89
110
  ```bash
90
- # Remove hooks and settings
91
- npx mcp-voice-hooks uninstall
92
-
93
- # Also remove from Claude MCP servers
111
+ # Remove from Claude MCP servers
94
112
  claude mcp remove voice-hooks
95
113
  ```
96
114
 
115
+ ```bash
116
+ # Also remove hooks and settings
117
+ npx mcp-voice-hooks uninstall
118
+ ```
119
+
97
120
  This will:
98
121
 
99
- - Remove the `~/.mcp-voice-hooks` directory
100
122
  - Clean up voice hooks from your project's `.claude/settings.json`
101
123
  - Preserve any custom hooks you've added
102
124
 
125
+ ## Known Limitations
126
+
127
+ - **Intermittent Stop Hook Execution**: Claude Code's Stop hooks are not triggered if the agent stops immediately after a tool call. This results in the assistant occasionally stopping without checking for voice input. This will be fixed in 1.0.45. [github issue](https://github.com/anthropics/claude-code/issues/3113#issuecomment-3047324928)
128
+
103
129
  ## Development Mode
104
130
 
105
131
  If you're developing mcp-voice-hooks itself:
@@ -152,65 +178,14 @@ and then configure claude to use the mcp proxy like so:
152
178
  }
153
179
  ```
154
180
 
155
- ## Voice responses (Mac only)
181
+ ### Port Configuration
156
182
 
157
- Add the post tool hook to your claude settings:
183
+ The default port is 5111. To use a different port, add to your project's `.claude/settings.json`:
158
184
 
159
- ```json
160
- {
185
+ ```json
161
186
  {
162
- "hooks": {
163
- "PostToolUse": [
164
- {
165
- "matcher": "^mcp__voice-hooks__",
166
- "hooks": [
167
- {
168
- "type": "command",
169
- "command": "./.claude/hooks/post-tool-voice-hook.sh"
170
- }
171
- ]
172
- }
173
- ]
174
- },
175
187
  "env": {
176
- "VOICE_RESPONSES_ENABLED": "true"
188
+ "MCP_VOICE_HOOKS_PORT": "8080"
177
189
  }
178
190
  }
179
- }
180
- ```
181
-
182
- ### Configuration
183
-
184
- Voice responses are disabled by default. To enable them:
185
-
186
- Add to your Claude Code settings JSON:
187
-
188
- ```json
189
- {
190
- "env": {
191
- "VOICE_RESPONSES_ENABLED": "true"
192
- }
193
- }
194
- ```
195
-
196
- To disable voice responses, set the value to `false` or remove the setting entirely.
197
-
198
- ### High quality voice responses
199
-
200
- These voice responses are spoken by your Mac's system voice.
201
-
202
- Configure in `System Settings > Accessibility > Spoken Content > System Voice`
203
-
204
- I recommend using a Siri voice, as they are much higher quality.
205
-
206
- Click the info icon next to the system voice dropdown. Search for "Siri" to find the highest quality voices. You'll have to trigger a download of the voice.
207
-
208
- It may take a while to download.
209
-
210
- Once it's downloaded, you can select it in the system voice dropdown.
211
-
212
- Test it with the bash command:
213
-
214
- ```bash
215
- say "Hi, this is your mac system voice"
216
- ```
191
+ ```
package/bin/cli.js CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
- import os from 'os';
6
5
  import { spawn } from 'child_process';
7
6
  import { fileURLToPath } from 'url';
8
- import { replaceVoiceHooks, areHooksEqual } from '../dist/hook-merger.js';
7
+ import { replaceVoiceHooks, areHooksEqual, removeVoiceHooks } from '../dist/hook-merger.js';
9
8
 
10
9
  const __filename = fileURLToPath(import.meta.url);
11
10
  const __dirname = path.dirname(__filename);
@@ -19,10 +18,7 @@ async function main() {
19
18
  if (command === 'install-hooks') {
20
19
  console.log('šŸ”§ Installing MCP Voice Hooks...');
21
20
 
22
- // Step 1: Ensure user directory exists and install/update hooks
23
- await ensureUserDirectorySetup();
24
-
25
- // Step 2: Configure Claude Code settings automatically
21
+ // Configure Claude Code settings automatically
26
22
  await configureClaudeCodeSettings();
27
23
 
28
24
  console.log('\nāœ… Installation complete!');
@@ -46,74 +42,6 @@ async function main() {
46
42
  }
47
43
  }
48
44
 
49
- // Ensure ~/.mcp-voice-hooks/hooks/ directory exists and contains latest hook files
50
- async function ensureUserDirectorySetup() {
51
- const userDir = path.join(os.homedir(), '.mcp-voice-hooks');
52
- const hooksDir = path.join(userDir, 'hooks');
53
-
54
- console.log('šŸ“ Setting up user directory:', userDir);
55
-
56
- // Clean up existing directory contents (except README.md if it exists)
57
- if (fs.existsSync(userDir)) {
58
- console.log('🧹 Cleaning up existing directory...');
59
-
60
- // Remove hooks directory if it exists
61
- if (fs.existsSync(hooksDir)) {
62
- fs.rmSync(hooksDir, { recursive: true, force: true });
63
- console.log('āœ… Cleaned up old hooks');
64
- }
65
-
66
- // Remove any other files
67
- const files = fs.readdirSync(userDir);
68
- for (const file of files) {
69
- const filePath = path.join(userDir, file);
70
- const stat = fs.statSync(filePath);
71
- if (stat.isDirectory()) {
72
- fs.rmSync(filePath, { recursive: true, force: true });
73
- } else {
74
- fs.unlinkSync(filePath);
75
- }
76
- }
77
- }
78
-
79
- // Create directories
80
- if (!fs.existsSync(userDir)) {
81
- fs.mkdirSync(userDir, { recursive: true });
82
- console.log('āœ… Created user directory');
83
- }
84
-
85
- if (!fs.existsSync(hooksDir)) {
86
- fs.mkdirSync(hooksDir, { recursive: true });
87
- console.log('āœ… Created hooks directory');
88
- }
89
-
90
- // Copy/update hook files from the package's .claude/hooks/ to user directory
91
- const packageHooksDir = path.join(__dirname, '..', '.claude', 'hooks');
92
-
93
- if (fs.existsSync(packageHooksDir)) {
94
- const hookFiles = fs.readdirSync(packageHooksDir).filter(file => file.endsWith('.sh'));
95
-
96
- for (const hookFile of hookFiles) {
97
- const sourcePath = path.join(packageHooksDir, hookFile);
98
- const destPath = path.join(hooksDir, hookFile);
99
-
100
- // Copy hook file
101
- fs.copyFileSync(sourcePath, destPath);
102
- console.log(`āœ… Updated hook: ${hookFile}`);
103
- }
104
- } else {
105
- console.log('āš ļø Package hooks directory not found, skipping hook installation');
106
- }
107
-
108
- // Copy README.md from project root to user directory
109
- const projectReadmePath = path.join(__dirname, '..', 'README.md');
110
- const userReadmePath = path.join(userDir, 'README.md');
111
-
112
- if (fs.existsSync(projectReadmePath)) {
113
- fs.copyFileSync(projectReadmePath, userReadmePath);
114
- console.log('āœ… Copied README.md');
115
- }
116
- }
117
45
 
118
46
  // Automatically configure Claude Code settings
119
47
  async function configureClaudeCodeSettings() {
@@ -141,7 +69,7 @@ async function configureClaudeCodeSettings() {
141
69
  }
142
70
  }
143
71
 
144
- // Add hook configuration
72
+ // Add hook configuration with inline commands
145
73
  const hookConfig = {
146
74
  "Stop": [
147
75
  {
@@ -149,7 +77,7 @@ async function configureClaudeCodeSettings() {
149
77
  "hooks": [
150
78
  {
151
79
  "type": "command",
152
- "command": "sh ~/.mcp-voice-hooks/hooks/stop-hook.sh"
80
+ "command": "curl -s -X POST \"http://localhost:${MCP_VOICE_HOOKS_PORT:-5111}/api/hooks/stop\" || echo '{\"decision\": \"approve\", \"reason\": \"voice-hooks unavailable\"}'"
153
81
  }
154
82
  ]
155
83
  }
@@ -160,7 +88,7 @@ async function configureClaudeCodeSettings() {
160
88
  "hooks": [
161
89
  {
162
90
  "type": "command",
163
- "command": "sh ~/.mcp-voice-hooks/hooks/pre-tool-hook.sh"
91
+ "command": "curl -s -X POST \"http://localhost:${MCP_VOICE_HOOKS_PORT:-5111}/api/hooks/pre-tool\" || echo '{\"decision\": \"approve\", \"reason\": \"voice-hooks unavailable\"}'"
164
92
  }
165
93
  ]
166
94
  },
@@ -169,7 +97,7 @@ async function configureClaudeCodeSettings() {
169
97
  "hooks": [
170
98
  {
171
99
  "type": "command",
172
- "command": "sh ~/.mcp-voice-hooks/hooks/pre-speak-hook.sh"
100
+ "command": "curl -s -X POST \"http://localhost:${MCP_VOICE_HOOKS_PORT:-5111}/api/hooks/pre-speak\" || echo '{\"decision\": \"approve\", \"reason\": \"voice-hooks unavailable\"}'"
173
101
  }
174
102
  ]
175
103
  },
@@ -178,7 +106,7 @@ async function configureClaudeCodeSettings() {
178
106
  "hooks": [
179
107
  {
180
108
  "type": "command",
181
- "command": "sh ~/.mcp-voice-hooks/hooks/pre-wait-hook.sh"
109
+ "command": "curl -s -X POST \"http://localhost:${MCP_VOICE_HOOKS_PORT:-5111}/api/hooks/pre-wait\" || echo '{\"decision\": \"approve\", \"reason\": \"voice-hooks unavailable\"}'"
182
110
  }
183
111
  ]
184
112
  }
@@ -204,19 +132,10 @@ async function configureClaudeCodeSettings() {
204
132
 
205
133
  // Silent hook installation check - runs on every startup
206
134
  async function ensureHooksInstalled() {
207
- const userDir = path.join(os.homedir(), '.mcp-voice-hooks');
208
- const hooksDir = path.join(userDir, 'hooks');
209
-
210
135
  try {
211
136
  console.log('šŸ”„ Updating hooks to latest version...');
212
137
 
213
- // Always remove and recreate the hooks directory to ensure clean state
214
- if (fs.existsSync(hooksDir)) {
215
- fs.rmSync(hooksDir, { recursive: true, force: true });
216
- }
217
-
218
- // Recreate with latest hooks
219
- await ensureUserDirectorySetup();
138
+ // Update hooks configuration in settings.json
220
139
  await configureClaudeCodeSettings();
221
140
  console.log('āœ… Hooks and settings updated');
222
141
  } catch (error) {
@@ -259,19 +178,9 @@ async function runMCPServer() {
259
178
 
260
179
  // Uninstall MCP Voice Hooks
261
180
  async function uninstall() {
262
- const userDir = path.join(os.homedir(), '.mcp-voice-hooks');
263
181
  const claudeSettingsPath = path.join(process.cwd(), '.claude', 'settings.json');
264
182
 
265
- // Step 1: Remove ~/.mcp-voice-hooks directory
266
- if (fs.existsSync(userDir)) {
267
- console.log('šŸ“ Removing user directory:', userDir);
268
- fs.rmSync(userDir, { recursive: true, force: true });
269
- console.log('āœ… Removed ~/.mcp-voice-hooks');
270
- } else {
271
- console.log('ā„¹ļø ~/.mcp-voice-hooks directory not found');
272
- }
273
-
274
- // Step 2: Remove voice hooks from Claude settings
183
+ // Remove voice hooks from Claude settings
275
184
  if (fs.existsSync(claudeSettingsPath)) {
276
185
  try {
277
186
  console.log('āš™ļø Removing voice hooks from Claude settings...');
@@ -303,6 +212,7 @@ async function uninstall() {
303
212
  console.log('ā„¹ļø No Claude settings file found in current project');
304
213
  }
305
214
 
215
+
306
216
  console.log('\nāœ… Uninstallation complete!');
307
217
  console.log('šŸ‘‹ MCP Voice Hooks has been removed.');
308
218
  }
@@ -10,7 +10,7 @@ interface HookSettings {
10
10
  [hookType: string]: HookConfig[];
11
11
  }
12
12
  /**
13
- * Removes any existing voice hooks that reference our directory
13
+ * Removes any existing voice hooks that use MCP_VOICE_HOOKS_PORT
14
14
  * @param hooks - The current hooks configuration
15
15
  * @returns The hooks with voice hooks removed
16
16
  */
@@ -1,7 +1,7 @@
1
1
  // src/hook-merger.ts
2
2
  function removeVoiceHooks(hooks = {}) {
3
3
  const cleaned = {};
4
- const voiceHookPattern = /\.mcp-voice-hooks/;
4
+ const voiceHookPattern = /MCP_VOICE_HOOKS_PORT/;
5
5
  for (const [hookType, hookArray] of Object.entries(hooks)) {
6
6
  cleaned[hookType] = hookArray.filter((hookConfig) => {
7
7
  return !hookConfig.hooks.some((hook) => voiceHookPattern.test(hook.command));
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/hook-merger.ts"],"sourcesContent":["interface Hook {\n type: string;\n command: string;\n}\n\ninterface HookConfig {\n matcher: string;\n hooks: Hook[];\n}\n\nexport interface HookSettings {\n [hookType: string]: HookConfig[];\n}\n\n/**\n * Removes any existing voice hooks that reference our directory\n * @param hooks - The current hooks configuration\n * @returns The hooks with voice hooks removed\n */\nexport function removeVoiceHooks(hooks: HookSettings = {}): HookSettings {\n const cleaned: HookSettings = {};\n const voiceHookPattern = /\\.mcp-voice-hooks/;\n \n for (const [hookType, hookArray] of Object.entries(hooks)) {\n cleaned[hookType] = hookArray.filter(hookConfig => {\n // Keep this hook config only if none of its commands reference our directory\n return !hookConfig.hooks.some(hook => voiceHookPattern.test(hook.command));\n });\n \n // Remove empty arrays\n if (cleaned[hookType].length === 0) {\n delete cleaned[hookType];\n }\n }\n \n return cleaned;\n}\n\n/**\n * Replaces voice hooks - removes any existing ones and adds new ones\n * @param existingHooks - The current hooks configuration\n * @param voiceHooks - The voice hooks to add\n * @returns The updated hooks configuration\n */\nexport function replaceVoiceHooks(existingHooks: HookSettings = {}, voiceHooks: HookSettings): HookSettings {\n // First, remove any existing voice hooks\n const cleaned = removeVoiceHooks(existingHooks);\n \n // Then merge in the new voice hooks\n const result: HookSettings = JSON.parse(JSON.stringify(cleaned)); // Deep clone\n \n for (const [hookType, hookArray] of Object.entries(voiceHooks)) {\n if (!result[hookType]) {\n result[hookType] = hookArray;\n } else {\n result[hookType].push(...hookArray);\n }\n }\n \n return result;\n}\n\n/**\n * Checks if two hook settings are semantically equal (ignoring order)\n * @param hooks1 - First hooks configuration\n * @param hooks2 - Second hooks configuration\n * @returns True if they contain the same hooks regardless of order\n */\nexport function areHooksEqual(hooks1: HookSettings = {}, hooks2: HookSettings = {}): boolean {\n const types1 = Object.keys(hooks1).sort();\n const types2 = Object.keys(hooks2).sort();\n \n // Different hook types\n if (types1.join(',') !== types2.join(',')) {\n return false;\n }\n \n // Check each hook type\n for (const hookType of types1) {\n const configs1 = hooks1[hookType];\n const configs2 = hooks2[hookType];\n \n if (configs1.length !== configs2.length) {\n return false;\n }\n \n // Create normalized strings for comparison\n const normalized1 = configs1\n .map(config => JSON.stringify(config))\n .sort();\n const normalized2 = configs2\n .map(config => JSON.stringify(config))\n .sort();\n \n // Compare sorted arrays\n for (let i = 0; i < normalized1.length; i++) {\n if (normalized1[i] !== normalized2[i]) {\n return false;\n }\n }\n }\n \n return true;\n}"],"mappings":";AAmBO,SAAS,iBAAiB,QAAsB,CAAC,GAAiB;AACvE,QAAM,UAAwB,CAAC;AAC/B,QAAM,mBAAmB;AAEzB,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,KAAK,GAAG;AACzD,YAAQ,QAAQ,IAAI,UAAU,OAAO,gBAAc;AAEjD,aAAO,CAAC,WAAW,MAAM,KAAK,UAAQ,iBAAiB,KAAK,KAAK,OAAO,CAAC;AAAA,IAC3E,CAAC;AAGD,QAAI,QAAQ,QAAQ,EAAE,WAAW,GAAG;AAClC,aAAO,QAAQ,QAAQ;AAAA,IACzB;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,kBAAkB,gBAA8B,CAAC,GAAG,YAAwC;AAE1G,QAAM,UAAU,iBAAiB,aAAa;AAG9C,QAAM,SAAuB,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAE/D,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,UAAU,GAAG;AAC9D,QAAI,CAAC,OAAO,QAAQ,GAAG;AACrB,aAAO,QAAQ,IAAI;AAAA,IACrB,OAAO;AACL,aAAO,QAAQ,EAAE,KAAK,GAAG,SAAS;AAAA,IACpC;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,cAAc,SAAuB,CAAC,GAAG,SAAuB,CAAC,GAAY;AAC3F,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AACxC,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AAGxC,MAAI,OAAO,KAAK,GAAG,MAAM,OAAO,KAAK,GAAG,GAAG;AACzC,WAAO;AAAA,EACT;AAGA,aAAW,YAAY,QAAQ;AAC7B,UAAM,WAAW,OAAO,QAAQ;AAChC,UAAM,WAAW,OAAO,QAAQ;AAEhC,QAAI,SAAS,WAAW,SAAS,QAAQ;AACvC,aAAO;AAAA,IACT;AAGA,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AACR,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AAGR,aAAS,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;AAC3C,UAAI,YAAY,CAAC,MAAM,YAAY,CAAC,GAAG;AACrC,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/hook-merger.ts"],"sourcesContent":["interface Hook {\n type: string;\n command: string;\n}\n\ninterface HookConfig {\n matcher: string;\n hooks: Hook[];\n}\n\nexport interface HookSettings {\n [hookType: string]: HookConfig[];\n}\n\n/**\n * Removes any existing voice hooks that use MCP_VOICE_HOOKS_PORT\n * @param hooks - The current hooks configuration\n * @returns The hooks with voice hooks removed\n */\nexport function removeVoiceHooks(hooks: HookSettings = {}): HookSettings {\n const cleaned: HookSettings = {};\n // Pattern to match voice hooks by the unique environment variable\n const voiceHookPattern = /MCP_VOICE_HOOKS_PORT/;\n \n for (const [hookType, hookArray] of Object.entries(hooks)) {\n cleaned[hookType] = hookArray.filter(hookConfig => {\n // Keep this hook config only if none of its commands match our pattern\n return !hookConfig.hooks.some(hook => voiceHookPattern.test(hook.command));\n });\n \n // Remove empty arrays\n if (cleaned[hookType].length === 0) {\n delete cleaned[hookType];\n }\n }\n \n return cleaned;\n}\n\n/**\n * Replaces voice hooks - removes any existing ones and adds new ones\n * @param existingHooks - The current hooks configuration\n * @param voiceHooks - The voice hooks to add\n * @returns The updated hooks configuration\n */\nexport function replaceVoiceHooks(existingHooks: HookSettings = {}, voiceHooks: HookSettings): HookSettings {\n // First, remove any existing voice hooks\n const cleaned = removeVoiceHooks(existingHooks);\n \n // Then merge in the new voice hooks\n const result: HookSettings = JSON.parse(JSON.stringify(cleaned)); // Deep clone\n \n for (const [hookType, hookArray] of Object.entries(voiceHooks)) {\n if (!result[hookType]) {\n result[hookType] = hookArray;\n } else {\n result[hookType].push(...hookArray);\n }\n }\n \n return result;\n}\n\n/**\n * Checks if two hook settings are semantically equal (ignoring order)\n * @param hooks1 - First hooks configuration\n * @param hooks2 - Second hooks configuration\n * @returns True if they contain the same hooks regardless of order\n */\nexport function areHooksEqual(hooks1: HookSettings = {}, hooks2: HookSettings = {}): boolean {\n const types1 = Object.keys(hooks1).sort();\n const types2 = Object.keys(hooks2).sort();\n \n // Different hook types\n if (types1.join(',') !== types2.join(',')) {\n return false;\n }\n \n // Check each hook type\n for (const hookType of types1) {\n const configs1 = hooks1[hookType];\n const configs2 = hooks2[hookType];\n \n if (configs1.length !== configs2.length) {\n return false;\n }\n \n // Create normalized strings for comparison\n const normalized1 = configs1\n .map(config => JSON.stringify(config))\n .sort();\n const normalized2 = configs2\n .map(config => JSON.stringify(config))\n .sort();\n \n // Compare sorted arrays\n for (let i = 0; i < normalized1.length; i++) {\n if (normalized1[i] !== normalized2[i]) {\n return false;\n }\n }\n }\n \n return true;\n}"],"mappings":";AAmBO,SAAS,iBAAiB,QAAsB,CAAC,GAAiB;AACvE,QAAM,UAAwB,CAAC;AAE/B,QAAM,mBAAmB;AAEzB,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,KAAK,GAAG;AACzD,YAAQ,QAAQ,IAAI,UAAU,OAAO,gBAAc;AAEjD,aAAO,CAAC,WAAW,MAAM,KAAK,UAAQ,iBAAiB,KAAK,KAAK,OAAO,CAAC;AAAA,IAC3E,CAAC;AAGD,QAAI,QAAQ,QAAQ,EAAE,WAAW,GAAG;AAClC,aAAO,QAAQ,QAAQ;AAAA,IACzB;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,kBAAkB,gBAA8B,CAAC,GAAG,YAAwC;AAE1G,QAAM,UAAU,iBAAiB,aAAa;AAG9C,QAAM,SAAuB,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAE/D,aAAW,CAAC,UAAU,SAAS,KAAK,OAAO,QAAQ,UAAU,GAAG;AAC9D,QAAI,CAAC,OAAO,QAAQ,GAAG;AACrB,aAAO,QAAQ,IAAI;AAAA,IACrB,OAAO;AACL,aAAO,QAAQ,EAAE,KAAK,GAAG,SAAS;AAAA,IACpC;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,cAAc,SAAuB,CAAC,GAAG,SAAuB,CAAC,GAAY;AAC3F,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AACxC,QAAM,SAAS,OAAO,KAAK,MAAM,EAAE,KAAK;AAGxC,MAAI,OAAO,KAAK,GAAG,MAAM,OAAO,KAAK,GAAG,GAAG;AACzC,WAAO;AAAA,EACT;AAGA,aAAW,YAAY,QAAQ;AAC7B,UAAM,WAAW,OAAO,QAAQ;AAChC,UAAM,WAAW,OAAO,QAAQ;AAEhC,QAAI,SAAS,WAAW,SAAS,QAAQ;AACvC,aAAO;AAAA,IACT;AAGA,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AACR,UAAM,cAAc,SACjB,IAAI,YAAU,KAAK,UAAU,MAAM,CAAC,EACpC,KAAK;AAGR,aAAS,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;AAC3C,UAAI,YAAY,CAAC,MAAM,YAAY,CAAC,GAAG;AACrC,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}