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.
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/bin/claude-voice +29 -0
- package/config/default.json +109 -0
- package/config/voice-prompt.md +27 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1103 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +140 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +179 -0
- package/dist/config.js.map +1 -0
- package/dist/env.d.ts +40 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +175 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/index.d.ts +35 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +170 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +185 -0
- package/dist/server.js.map +1 -0
- package/dist/stt/index.d.ts +15 -0
- package/dist/stt/index.d.ts.map +1 -0
- package/dist/stt/index.js +54 -0
- package/dist/stt/index.js.map +1 -0
- package/dist/stt/providers/openai.d.ts +15 -0
- package/dist/stt/providers/openai.d.ts.map +1 -0
- package/dist/stt/providers/openai.js +74 -0
- package/dist/stt/providers/openai.js.map +1 -0
- package/dist/stt/providers/sherpa-onnx.d.ts +50 -0
- package/dist/stt/providers/sherpa-onnx.d.ts.map +1 -0
- package/dist/stt/providers/sherpa-onnx.js +237 -0
- package/dist/stt/providers/sherpa-onnx.js.map +1 -0
- package/dist/stt/providers/whisper-local.d.ts +19 -0
- package/dist/stt/providers/whisper-local.d.ts.map +1 -0
- package/dist/stt/providers/whisper-local.js +141 -0
- package/dist/stt/providers/whisper-local.js.map +1 -0
- package/dist/terminal/input-injector.d.ts +55 -0
- package/dist/terminal/input-injector.d.ts.map +1 -0
- package/dist/terminal/input-injector.js +189 -0
- package/dist/terminal/input-injector.js.map +1 -0
- package/dist/tts/index.d.ts +20 -0
- package/dist/tts/index.d.ts.map +1 -0
- package/dist/tts/index.js +72 -0
- package/dist/tts/index.js.map +1 -0
- package/dist/tts/providers/elevenlabs.d.ts +23 -0
- package/dist/tts/providers/elevenlabs.d.ts.map +1 -0
- package/dist/tts/providers/elevenlabs.js +142 -0
- package/dist/tts/providers/elevenlabs.js.map +1 -0
- package/dist/tts/providers/macos-say.d.ts +17 -0
- package/dist/tts/providers/macos-say.d.ts.map +1 -0
- package/dist/tts/providers/macos-say.js +72 -0
- package/dist/tts/providers/macos-say.js.map +1 -0
- package/dist/tts/providers/openai.d.ts +19 -0
- package/dist/tts/providers/openai.d.ts.map +1 -0
- package/dist/tts/providers/openai.js +118 -0
- package/dist/tts/providers/openai.js.map +1 -0
- package/dist/tts/providers/piper.d.ts +48 -0
- package/dist/tts/providers/piper.d.ts.map +1 -0
- package/dist/tts/providers/piper.js +417 -0
- package/dist/tts/providers/piper.js.map +1 -0
- package/dist/voice-input.d.ts +9 -0
- package/dist/voice-input.d.ts.map +1 -0
- package/dist/voice-input.js +137 -0
- package/dist/voice-input.js.map +1 -0
- package/dist/wake-word/index.d.ts +19 -0
- package/dist/wake-word/index.d.ts.map +1 -0
- package/dist/wake-word/index.js +200 -0
- package/dist/wake-word/index.js.map +1 -0
- package/dist/wake-word/recorder.d.ts +19 -0
- package/dist/wake-word/recorder.d.ts.map +1 -0
- package/dist/wake-word/recorder.js +145 -0
- package/dist/wake-word/recorder.js.map +1 -0
- package/hooks/notification.js +125 -0
- package/hooks/post-tool-use.js +374 -0
- package/hooks/session-start.js +212 -0
- package/hooks/stop.js +254 -0
- package/models/.gitkeep +0 -0
- package/package.json +80 -0
- package/python/stt_service.py +59 -0
- package/python/voice_input.py +154 -0
- package/scripts/install.sh +147 -0
- package/scripts/listen.py +161 -0
- package/scripts/postinstall.js +57 -0
- package/scripts/record.sh +79 -0
- package/scripts/setup-hooks.sh +22 -0
- 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
|
+
});
|