claude-voice 1.0.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +395 -0
  3. package/bin/claude-voice +29 -0
  4. package/config/default.json +109 -0
  5. package/config/voice-prompt.md +27 -0
  6. package/dist/cli.d.ts +8 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +1103 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +140 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +179 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/env.d.ts +40 -0
  15. package/dist/env.d.ts.map +1 -0
  16. package/dist/env.js +175 -0
  17. package/dist/env.js.map +1 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +140 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/platform/index.d.ts +35 -0
  23. package/dist/platform/index.d.ts.map +1 -0
  24. package/dist/platform/index.js +170 -0
  25. package/dist/platform/index.js.map +1 -0
  26. package/dist/server.d.ts +5 -0
  27. package/dist/server.d.ts.map +1 -0
  28. package/dist/server.js +185 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/stt/index.d.ts +15 -0
  31. package/dist/stt/index.d.ts.map +1 -0
  32. package/dist/stt/index.js +54 -0
  33. package/dist/stt/index.js.map +1 -0
  34. package/dist/stt/providers/openai.d.ts +15 -0
  35. package/dist/stt/providers/openai.d.ts.map +1 -0
  36. package/dist/stt/providers/openai.js +74 -0
  37. package/dist/stt/providers/openai.js.map +1 -0
  38. package/dist/stt/providers/sherpa-onnx.d.ts +50 -0
  39. package/dist/stt/providers/sherpa-onnx.d.ts.map +1 -0
  40. package/dist/stt/providers/sherpa-onnx.js +237 -0
  41. package/dist/stt/providers/sherpa-onnx.js.map +1 -0
  42. package/dist/stt/providers/whisper-local.d.ts +19 -0
  43. package/dist/stt/providers/whisper-local.d.ts.map +1 -0
  44. package/dist/stt/providers/whisper-local.js +141 -0
  45. package/dist/stt/providers/whisper-local.js.map +1 -0
  46. package/dist/terminal/input-injector.d.ts +55 -0
  47. package/dist/terminal/input-injector.d.ts.map +1 -0
  48. package/dist/terminal/input-injector.js +189 -0
  49. package/dist/terminal/input-injector.js.map +1 -0
  50. package/dist/tts/index.d.ts +20 -0
  51. package/dist/tts/index.d.ts.map +1 -0
  52. package/dist/tts/index.js +72 -0
  53. package/dist/tts/index.js.map +1 -0
  54. package/dist/tts/providers/elevenlabs.d.ts +23 -0
  55. package/dist/tts/providers/elevenlabs.d.ts.map +1 -0
  56. package/dist/tts/providers/elevenlabs.js +142 -0
  57. package/dist/tts/providers/elevenlabs.js.map +1 -0
  58. package/dist/tts/providers/macos-say.d.ts +17 -0
  59. package/dist/tts/providers/macos-say.d.ts.map +1 -0
  60. package/dist/tts/providers/macos-say.js +72 -0
  61. package/dist/tts/providers/macos-say.js.map +1 -0
  62. package/dist/tts/providers/openai.d.ts +19 -0
  63. package/dist/tts/providers/openai.d.ts.map +1 -0
  64. package/dist/tts/providers/openai.js +118 -0
  65. package/dist/tts/providers/openai.js.map +1 -0
  66. package/dist/tts/providers/piper.d.ts +48 -0
  67. package/dist/tts/providers/piper.d.ts.map +1 -0
  68. package/dist/tts/providers/piper.js +417 -0
  69. package/dist/tts/providers/piper.js.map +1 -0
  70. package/dist/voice-input.d.ts +9 -0
  71. package/dist/voice-input.d.ts.map +1 -0
  72. package/dist/voice-input.js +137 -0
  73. package/dist/voice-input.js.map +1 -0
  74. package/dist/wake-word/index.d.ts +19 -0
  75. package/dist/wake-word/index.d.ts.map +1 -0
  76. package/dist/wake-word/index.js +200 -0
  77. package/dist/wake-word/index.js.map +1 -0
  78. package/dist/wake-word/recorder.d.ts +19 -0
  79. package/dist/wake-word/recorder.d.ts.map +1 -0
  80. package/dist/wake-word/recorder.js +145 -0
  81. package/dist/wake-word/recorder.js.map +1 -0
  82. package/hooks/notification.js +125 -0
  83. package/hooks/post-tool-use.js +374 -0
  84. package/hooks/session-start.js +212 -0
  85. package/hooks/stop.js +254 -0
  86. package/models/.gitkeep +0 -0
  87. package/package.json +80 -0
  88. package/python/stt_service.py +59 -0
  89. package/python/voice_input.py +154 -0
  90. package/scripts/install.sh +147 -0
  91. package/scripts/listen.py +161 -0
  92. package/scripts/postinstall.js +57 -0
  93. package/scripts/record.sh +79 -0
  94. package/scripts/setup-hooks.sh +22 -0
  95. package/scripts/voice-input.sh +66 -0
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code Hook: Notification
4
+ *
5
+ * This hook runs when Claude Code sends notifications.
6
+ * It provides voice alerts for important events like permission prompts.
7
+ * Respects user configuration for notifications.
8
+ */
9
+
10
+ const http = require('http');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ const API_URL = 'http://127.0.0.1:3456';
16
+ const CONFIG_FILE = path.join(os.homedir(), '.claude-voice', 'config.json');
17
+
18
+ // Load configuration
19
+ function loadConfig() {
20
+ try {
21
+ if (fs.existsSync(CONFIG_FILE)) {
22
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
23
+ }
24
+ } catch {
25
+ // Use defaults
26
+ }
27
+ return {
28
+ notifications: {
29
+ enabled: true,
30
+ permissionPrompt: true,
31
+ idlePrompt: true,
32
+ customMessages: {
33
+ permissionPrompt: 'Claude needs your permission.',
34
+ idlePrompt: 'Claude is waiting for your input.'
35
+ }
36
+ }
37
+ };
38
+ }
39
+
40
+ async function sendToTTS(text, priority = false) {
41
+ return new Promise((resolve, reject) => {
42
+ const data = JSON.stringify({ text, priority });
43
+
44
+ const req = http.request(`${API_URL}/tts`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'Content-Length': Buffer.byteLength(data, 'utf8')
49
+ },
50
+ timeout: 3000
51
+ }, (res) => {
52
+ let body = '';
53
+ res.on('data', chunk => body += chunk);
54
+ res.on('end', () => {
55
+ try {
56
+ const response = JSON.parse(body);
57
+ resolve(response);
58
+ } catch {
59
+ resolve({ success: false });
60
+ }
61
+ });
62
+ });
63
+
64
+ req.on('error', () => resolve({ success: false }));
65
+ req.on('timeout', () => {
66
+ req.destroy();
67
+ resolve({ success: false });
68
+ });
69
+
70
+ req.write(data);
71
+ req.end();
72
+ });
73
+ }
74
+
75
+ async function main() {
76
+ const config = loadConfig();
77
+
78
+ // Check if notifications are enabled
79
+ if (!config.notifications || !config.notifications.enabled) {
80
+ console.log(JSON.stringify({}));
81
+ return;
82
+ }
83
+
84
+ // Read hook input from stdin
85
+ let input = '';
86
+ for await (const chunk of process.stdin) {
87
+ input += chunk;
88
+ }
89
+
90
+ const hookData = JSON.parse(input);
91
+ const { notification_type } = hookData;
92
+
93
+ // Handle permission prompts
94
+ if (notification_type === 'permission_prompt') {
95
+ if (config.notifications.permissionPrompt !== false) {
96
+ const message = config.notifications.customMessages?.permissionPrompt ||
97
+ 'Claude needs your permission.';
98
+ try {
99
+ await sendToTTS(message, true); // priority = true (interrupt)
100
+ } catch {
101
+ // Silently fail
102
+ }
103
+ }
104
+ }
105
+ // Handle idle prompts
106
+ else if (notification_type === 'idle_prompt') {
107
+ if (config.notifications.idlePrompt !== false) {
108
+ const message = config.notifications.customMessages?.idlePrompt ||
109
+ 'Claude is waiting for your input.';
110
+ try {
111
+ await sendToTTS(message, false);
112
+ } catch {
113
+ // Silently fail
114
+ }
115
+ }
116
+ }
117
+
118
+ // Output empty response
119
+ console.log(JSON.stringify({}));
120
+ }
121
+
122
+ main().catch(() => {
123
+ console.log(JSON.stringify({}));
124
+ process.exit(0); // Don't fail the hook
125
+ });
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code Hook: PostToolUse
4
+ *
5
+ * This hook runs after Claude Code executes a tool.
6
+ * It provides voice announcements for tool completion/results.
7
+ * Supports both "completion only" and "summarize results" modes.
8
+ */
9
+
10
+ const http = require('http');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ const API_URL = 'http://127.0.0.1:3456';
16
+ const CONFIG_FILE = path.join(os.homedir(), '.claude-voice', 'config.json');
17
+
18
+ // Default configuration
19
+ const DEFAULT_CONFIG = {
20
+ toolTTS: {
21
+ enabled: true,
22
+ mode: 'summarize',
23
+ tools: {
24
+ Read: true,
25
+ Grep: true,
26
+ Glob: false,
27
+ Bash: true,
28
+ Write: true,
29
+ Edit: true,
30
+ MultiEdit: true,
31
+ WebFetch: false,
32
+ WebSearch: false,
33
+ Task: false,
34
+ default: false
35
+ },
36
+ customMessages: {
37
+ completion: 'Done.',
38
+ error: 'Operation failed.'
39
+ },
40
+ announceErrors: true,
41
+ maxSummaryLength: 100
42
+ }
43
+ };
44
+
45
+ // Load configuration
46
+ function loadConfig() {
47
+ try {
48
+ if (fs.existsSync(CONFIG_FILE)) {
49
+ const userConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
50
+ return deepMerge(DEFAULT_CONFIG, userConfig);
51
+ }
52
+ } catch {
53
+ // Use defaults on error
54
+ }
55
+ return DEFAULT_CONFIG;
56
+ }
57
+
58
+ // Deep merge helper
59
+ function deepMerge(target, source) {
60
+ const result = { ...target };
61
+ for (const key in source) {
62
+ if (source[key] !== undefined) {
63
+ if (
64
+ typeof source[key] === 'object' &&
65
+ source[key] !== null &&
66
+ !Array.isArray(source[key]) &&
67
+ typeof target[key] === 'object' &&
68
+ target[key] !== null
69
+ ) {
70
+ result[key] = deepMerge(target[key], source[key]);
71
+ } else {
72
+ result[key] = source[key];
73
+ }
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+
79
+ // Send text to TTS service
80
+ async function sendToTTS(text, priority = false) {
81
+ return new Promise((resolve) => {
82
+ const data = JSON.stringify({ text, priority });
83
+
84
+ const req = http.request(`${API_URL}/tts`, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ 'Content-Length': Buffer.byteLength(data, 'utf8')
89
+ },
90
+ timeout: 3000
91
+ }, (res) => {
92
+ let body = '';
93
+ res.on('data', chunk => body += chunk);
94
+ res.on('end', () => {
95
+ try {
96
+ resolve(JSON.parse(body));
97
+ } catch {
98
+ resolve({ success: false });
99
+ }
100
+ });
101
+ });
102
+
103
+ req.on('error', () => resolve({ success: false }));
104
+ req.on('timeout', () => {
105
+ req.destroy();
106
+ resolve({ success: false });
107
+ });
108
+
109
+ req.write(data);
110
+ req.end();
111
+ });
112
+ }
113
+
114
+ // Check if tool TTS is enabled for a specific tool
115
+ function isToolEnabled(config, toolName) {
116
+ const toolConfig = config.toolTTS?.tools || {};
117
+
118
+ // Check specific tool setting first
119
+ if (Object.prototype.hasOwnProperty.call(toolConfig, toolName)) {
120
+ return toolConfig[toolName];
121
+ }
122
+
123
+ // Fall back to default setting
124
+ return toolConfig.default ?? false;
125
+ }
126
+
127
+ // Detect if tool execution was an error
128
+ function isToolError(toolResult) {
129
+ if (!toolResult) return false;
130
+
131
+ const resultStr = typeof toolResult === 'string'
132
+ ? toolResult
133
+ : JSON.stringify(toolResult);
134
+
135
+ // Common error indicators
136
+ const errorPatterns = [
137
+ /error:/i,
138
+ /failed/i,
139
+ /exception/i,
140
+ /not found/i,
141
+ /permission denied/i,
142
+ /exit code [1-9]/i,
143
+ /command not found/i,
144
+ /no such file/i
145
+ ];
146
+
147
+ return errorPatterns.some(pattern => pattern.test(resultStr));
148
+ }
149
+
150
+ // Summarizers for each tool type
151
+ const toolSummarizers = {
152
+ Read: (input, result) => {
153
+ const fileName = input.file_path ? path.basename(input.file_path) : 'file';
154
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
155
+ const lineCount = (resultStr.match(/\n/g) || []).length + 1;
156
+ return `Read ${lineCount} lines from ${fileName}.`;
157
+ },
158
+
159
+ Grep: (input, result) => {
160
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
161
+ const lines = resultStr.trim().split('\n').filter(l => l.trim());
162
+ const matchCount = lines.length;
163
+
164
+ if (matchCount === 0) {
165
+ return 'No matches found.';
166
+ }
167
+
168
+ // Try to count unique files
169
+ const files = new Set();
170
+ lines.forEach(line => {
171
+ const match = line.match(/^([^:]+):/);
172
+ if (match) files.add(match[1]);
173
+ });
174
+
175
+ if (files.size > 1) {
176
+ return `Found ${matchCount} matches in ${files.size} files.`;
177
+ } else if (files.size === 1) {
178
+ return `Found ${matchCount} matches in 1 file.`;
179
+ }
180
+ return `Found ${matchCount} matches.`;
181
+ },
182
+
183
+ Glob: (input, result) => {
184
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
185
+ const files = resultStr.trim().split('\n').filter(l => l.trim());
186
+ if (files.length === 0) {
187
+ return 'No files found.';
188
+ }
189
+ return `Found ${files.length} ${files.length === 1 ? 'file' : 'files'}.`;
190
+ },
191
+
192
+ Bash: (input, result) => {
193
+ const command = input.command || '';
194
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
195
+
196
+ // Check for exit code in result
197
+ const exitCodeMatch = resultStr.match(/exit code[:\s]+(\d+)/i);
198
+ if (exitCodeMatch && exitCodeMatch[1] !== '0') {
199
+ return `Command failed with exit code ${exitCodeMatch[1]}.`;
200
+ }
201
+
202
+ // Common command patterns
203
+ if (/^git\s+status/i.test(command)) {
204
+ return 'Git status complete.';
205
+ }
206
+ if (/^git\s+commit/i.test(command)) {
207
+ return 'Git commit complete.';
208
+ }
209
+ if (/^git\s+push/i.test(command)) {
210
+ return 'Git push complete.';
211
+ }
212
+ if (/^git\s+pull/i.test(command)) {
213
+ return 'Git pull complete.';
214
+ }
215
+ if (/^npm\s+install/i.test(command) || /^yarn\s+install/i.test(command) || /^pnpm\s+install/i.test(command)) {
216
+ return 'Package installation complete.';
217
+ }
218
+ if (/^npm\s+test/i.test(command) || /^yarn\s+test/i.test(command) || /^pnpm\s+test/i.test(command)) {
219
+ if (resultStr.includes('passed') || !resultStr.includes('failed')) {
220
+ return 'Tests passed.';
221
+ }
222
+ return 'Tests completed with failures.';
223
+ }
224
+ if (/^npm\s+run\s+build/i.test(command) || /^yarn\s+build/i.test(command) || /^pnpm\s+build/i.test(command)) {
225
+ return 'Build complete.';
226
+ }
227
+
228
+ return 'Command completed.';
229
+ },
230
+
231
+ Write: (input, result) => {
232
+ const fileName = input.file_path ? path.basename(input.file_path) : 'file';
233
+ return `Created ${fileName}.`;
234
+ },
235
+
236
+ Edit: (input, result) => {
237
+ const fileName = input.file_path ? path.basename(input.file_path) : 'file';
238
+ return `Updated ${fileName}.`;
239
+ },
240
+
241
+ MultiEdit: (input, result) => {
242
+ const edits = input.edits || [];
243
+ const fileCount = new Set(edits.map(e => e.file_path)).size;
244
+ if (fileCount === 1) {
245
+ const fileName = path.basename(edits[0]?.file_path || 'file');
246
+ return `Made ${edits.length} edits to ${fileName}.`;
247
+ }
248
+ return `Made ${edits.length} edits across ${fileCount} files.`;
249
+ },
250
+
251
+ WebFetch: (input, result) => {
252
+ try {
253
+ const url = new URL(input.url);
254
+ return `Fetched content from ${url.hostname}.`;
255
+ } catch {
256
+ return 'Web fetch complete.';
257
+ }
258
+ },
259
+
260
+ WebSearch: (input, result) => {
261
+ const query = input.query || '';
262
+ const shortQuery = query.length > 30 ? query.substring(0, 30) + '...' : query;
263
+ return `Search complete for "${shortQuery}".`;
264
+ },
265
+
266
+ TodoRead: (input, result) => {
267
+ return 'Read todo list.';
268
+ },
269
+
270
+ TodoWrite: (input, result) => {
271
+ const todos = input.todos || [];
272
+ return `Updated ${todos.length} todo items.`;
273
+ },
274
+
275
+ Task: (input, result) => {
276
+ return 'Task completed.';
277
+ },
278
+
279
+ // Default summarizer for unknown tools
280
+ default: (input, result, toolName) => {
281
+ return `${toolName} completed.`;
282
+ }
283
+ };
284
+
285
+ // Generate summary based on tool type
286
+ function summarizeTool(toolName, toolInput, toolResult, config) {
287
+ const summarizer = toolSummarizers[toolName] || toolSummarizers.default;
288
+
289
+ try {
290
+ let summary = summarizer(toolInput, toolResult, toolName);
291
+
292
+ // Truncate if too long
293
+ const maxLength = config.toolTTS?.maxSummaryLength || 100;
294
+ if (summary.length > maxLength) {
295
+ summary = summary.substring(0, maxLength - 3) + '...';
296
+ }
297
+
298
+ return summary;
299
+ } catch {
300
+ return config.toolTTS?.customMessages?.completion || 'Done.';
301
+ }
302
+ }
303
+
304
+ // Main hook logic
305
+ async function main() {
306
+ const config = loadConfig();
307
+
308
+ // Check if tool TTS is enabled globally
309
+ if (!config.toolTTS?.enabled) {
310
+ console.log(JSON.stringify({}));
311
+ return;
312
+ }
313
+
314
+ // Read hook input from stdin
315
+ let input = '';
316
+ for await (const chunk of process.stdin) {
317
+ input += chunk;
318
+ }
319
+
320
+ let hookData;
321
+ try {
322
+ hookData = JSON.parse(input);
323
+ } catch {
324
+ console.log(JSON.stringify({}));
325
+ return;
326
+ }
327
+
328
+ const { tool_name, tool_input, tool_result } = hookData;
329
+
330
+ // Check if TTS is enabled for this specific tool
331
+ if (!isToolEnabled(config, tool_name)) {
332
+ console.log(JSON.stringify({}));
333
+ return;
334
+ }
335
+
336
+ // Determine what to speak
337
+ let speechText;
338
+ const isError = isToolError(tool_result);
339
+
340
+ if (isError && config.toolTTS?.announceErrors) {
341
+ // Handle error case
342
+ if (config.toolTTS?.mode === 'summarize') {
343
+ speechText = summarizeTool(tool_name, tool_input, tool_result, config);
344
+ } else {
345
+ speechText = config.toolTTS?.customMessages?.error || 'Operation failed.';
346
+ }
347
+ } else if (!isError) {
348
+ // Handle success case
349
+ if (config.toolTTS?.mode === 'summarize') {
350
+ speechText = summarizeTool(tool_name, tool_input, tool_result, config);
351
+ } else {
352
+ // Completion mode - say tool name + "done"
353
+ const completionWord = config.toolTTS?.customMessages?.completion || 'done.';
354
+ speechText = `${tool_name} ${completionWord}`;
355
+ }
356
+ }
357
+
358
+ // Send to TTS if we have something to say
359
+ if (speechText) {
360
+ try {
361
+ await sendToTTS(speechText, false); // priority = false (normal queue)
362
+ } catch {
363
+ // Silently fail - don't interrupt Claude Code
364
+ }
365
+ }
366
+
367
+ // Output empty response (hook doesn't modify behavior)
368
+ console.log(JSON.stringify({}));
369
+ }
370
+
371
+ main().catch(() => {
372
+ console.log(JSON.stringify({}));
373
+ process.exit(0); // Don't fail the hook
374
+ });
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code Hook: SessionStart
4
+ *
5
+ * This hook runs when a Claude Code session starts.
6
+ * It verifies the voice extension daemon is running and starts it if needed.
7
+ * Respects user configuration.
8
+ */
9
+
10
+ const http = require('http');
11
+ const { spawn } = require('child_process');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ const API_URL = 'http://127.0.0.1:3456';
17
+ const CONFIG_FILE = path.join(os.homedir(), '.claude-voice', 'config.json');
18
+ const ENV_FILE = path.join(os.homedir(), '.claude-voice', '.env');
19
+ const LOG_FILE = path.join(os.homedir(), '.claude-voice', 'daemon.log');
20
+ const USER_VOICE_PROMPT = path.join(os.homedir(), '.claude-voice', 'voice-prompt.md');
21
+ const DEFAULT_VOICE_PROMPT = path.join(__dirname, '..', 'config', 'voice-prompt.md');
22
+
23
+ // Load environment variables from .env file
24
+ function loadEnvVars() {
25
+ try {
26
+ if (fs.existsSync(ENV_FILE)) {
27
+ const content = fs.readFileSync(ENV_FILE, 'utf-8');
28
+ for (const line of content.split('\n')) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith('#')) continue;
31
+ const match = trimmed.match(/^([A-Z_]+)=(.*)$/);
32
+ if (match) {
33
+ const [, key, value] = match;
34
+ if (!process.env[key]) {
35
+ process.env[key] = value.replace(/^["']|["']$/g, '');
36
+ }
37
+ }
38
+ }
39
+ }
40
+ } catch {
41
+ // Ignore errors
42
+ }
43
+ }
44
+
45
+ // Load configuration
46
+ function loadConfig() {
47
+ try {
48
+ if (fs.existsSync(CONFIG_FILE)) {
49
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
50
+ }
51
+ } catch {
52
+ // Use defaults
53
+ }
54
+ return {
55
+ wakeWord: {
56
+ enabled: true,
57
+ keyword: 'jarvis'
58
+ },
59
+ voiceOutput: {
60
+ enabled: true,
61
+ abstractMarker: '<!-- TTS -->',
62
+ maxAbstractLength: 200,
63
+ promptTemplate: null
64
+ }
65
+ };
66
+ }
67
+
68
+ // Load voice prompt template for TTS-friendly output
69
+ function loadVoicePrompt(config) {
70
+ try {
71
+ let template;
72
+
73
+ // Priority: config.promptTemplate > user file > default file
74
+ if (config.voiceOutput?.promptTemplate) {
75
+ template = config.voiceOutput.promptTemplate;
76
+ } else if (fs.existsSync(USER_VOICE_PROMPT)) {
77
+ template = fs.readFileSync(USER_VOICE_PROMPT, 'utf-8');
78
+ } else if (fs.existsSync(DEFAULT_VOICE_PROMPT)) {
79
+ template = fs.readFileSync(DEFAULT_VOICE_PROMPT, 'utf-8');
80
+ } else {
81
+ return null;
82
+ }
83
+
84
+ // Replace template variables
85
+ const marker = config.voiceOutput?.abstractMarker || '<!-- TTS -->';
86
+ const maxLength = config.voiceOutput?.maxAbstractLength || 200;
87
+
88
+ return template
89
+ .replace(/\{\{MARKER\}\}/g, marker)
90
+ .replace(/\{\{MAX_LENGTH\}\}/g, String(maxLength));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ async function checkDaemon() {
97
+ return new Promise((resolve) => {
98
+ const req = http.get(`${API_URL}/status`, (res) => {
99
+ let data = '';
100
+ res.on('data', (chunk) => data += chunk);
101
+ res.on('end', () => {
102
+ try {
103
+ const status = JSON.parse(data);
104
+ resolve(status.status === 'running');
105
+ } catch {
106
+ resolve(false);
107
+ }
108
+ });
109
+ });
110
+
111
+ req.on('error', () => resolve(false));
112
+ req.setTimeout(2000, () => {
113
+ req.destroy();
114
+ resolve(false);
115
+ });
116
+ });
117
+ }
118
+
119
+ async function startDaemon() {
120
+ const daemonPath = path.join(__dirname, '..', 'dist', 'index.js');
121
+
122
+ // Ensure log directory exists
123
+ const logDir = path.dirname(LOG_FILE);
124
+ if (!fs.existsSync(logDir)) {
125
+ fs.mkdirSync(logDir, { recursive: true });
126
+ }
127
+
128
+ const logStream = fs.openSync(LOG_FILE, 'a');
129
+
130
+ const child = spawn('node', [daemonPath], {
131
+ detached: true,
132
+ stdio: ['ignore', logStream, logStream],
133
+ env: process.env
134
+ });
135
+
136
+ child.unref();
137
+
138
+ // Wait for daemon to start
139
+ for (let i = 0; i < 10; i++) {
140
+ await new Promise(r => setTimeout(r, 500));
141
+ if (await checkDaemon()) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ async function main() {
150
+ // Load environment variables
151
+ loadEnvVars();
152
+
153
+ // Load configuration
154
+ const config = loadConfig();
155
+
156
+ // Read hook input from stdin
157
+ let input = '';
158
+ for await (const chunk of process.stdin) {
159
+ input += chunk;
160
+ }
161
+
162
+ // Check if daemon is running
163
+ const isRunning = await checkDaemon();
164
+
165
+ let additionalContext = '';
166
+ const wakeWordEnabled = config.wakeWord?.enabled !== false;
167
+ const keyword = config.wakeWord?.keyword || 'jarvis';
168
+ const capitalizedKeyword = keyword.charAt(0).toUpperCase() + keyword.slice(1);
169
+
170
+ if (isRunning) {
171
+ if (wakeWordEnabled) {
172
+ additionalContext = `[Voice Extension] Voice interface active. Say "${capitalizedKeyword}" to start speaking.`;
173
+ } else {
174
+ additionalContext = '[Voice Extension] Voice interface active.';
175
+ }
176
+ } else {
177
+ // Try to start the daemon
178
+ const started = await startDaemon();
179
+ if (started) {
180
+ if (wakeWordEnabled) {
181
+ additionalContext = `[Voice Extension] Voice interface started. Say "${capitalizedKeyword}" to start speaking.`;
182
+ } else {
183
+ additionalContext = '[Voice Extension] Voice interface started.';
184
+ }
185
+ } else {
186
+ additionalContext = '[Voice Extension] Voice interface not available. Run: claude-voice start';
187
+ }
188
+ }
189
+
190
+ // Inject voice output formatting instructions if enabled
191
+ if (config.voiceOutput?.enabled !== false) {
192
+ const voicePrompt = loadVoicePrompt(config);
193
+ if (voicePrompt) {
194
+ additionalContext += '\n\n' + voicePrompt;
195
+ }
196
+ }
197
+
198
+ // Output hook response
199
+ const response = {
200
+ hookSpecificOutput: {
201
+ hookEventName: 'SessionStart',
202
+ additionalContext
203
+ }
204
+ };
205
+
206
+ console.log(JSON.stringify(response));
207
+ }
208
+
209
+ main().catch(() => {
210
+ console.log(JSON.stringify({}));
211
+ process.exit(0); // Don't fail the hook
212
+ });