@eldrforge/kodrdriv 0.0.13 → 0.0.15

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 (51) hide show
  1. package/.kodrdriv/context/content.md +7 -1
  2. package/RELEASE_NOTES.md +14 -0
  3. package/dist/arguments.js +332 -9
  4. package/dist/arguments.js.map +1 -1
  5. package/dist/commands/audio-commit.js +666 -0
  6. package/dist/commands/audio-commit.js.map +1 -0
  7. package/dist/commands/audio-review.js +677 -0
  8. package/dist/commands/audio-review.js.map +1 -0
  9. package/dist/commands/clean.js +36 -0
  10. package/dist/commands/clean.js.map +1 -0
  11. package/dist/commands/commit.js +32 -7
  12. package/dist/commands/commit.js.map +1 -1
  13. package/dist/commands/publish.js +15 -7
  14. package/dist/commands/publish.js.map +1 -1
  15. package/dist/commands/release.js +31 -3
  16. package/dist/commands/release.js.map +1 -1
  17. package/dist/commands/review.js +152 -0
  18. package/dist/commands/review.js.map +1 -0
  19. package/dist/constants.js +90 -59
  20. package/dist/constants.js.map +1 -1
  21. package/dist/content/diff.js +155 -1
  22. package/dist/content/diff.js.map +1 -1
  23. package/dist/content/issues.js +240 -0
  24. package/dist/content/issues.js.map +1 -0
  25. package/dist/content/releaseNotes.js +90 -0
  26. package/dist/content/releaseNotes.js.map +1 -0
  27. package/dist/main.js +23 -10
  28. package/dist/main.js.map +1 -1
  29. package/dist/prompt/instructions/commit.md +18 -15
  30. package/dist/prompt/instructions/release.md +6 -5
  31. package/dist/prompt/instructions/review.md +108 -0
  32. package/dist/prompt/personas/reviewer.md +29 -0
  33. package/dist/prompt/prompts.js +110 -13
  34. package/dist/prompt/prompts.js.map +1 -1
  35. package/dist/types.js +36 -1
  36. package/dist/types.js.map +1 -1
  37. package/dist/util/general.js +35 -2
  38. package/dist/util/general.js.map +1 -1
  39. package/dist/util/github.js +54 -1
  40. package/dist/util/github.js.map +1 -1
  41. package/dist/util/openai.js +68 -4
  42. package/dist/util/openai.js.map +1 -1
  43. package/dist/util/stdin.js +61 -0
  44. package/dist/util/stdin.js.map +1 -0
  45. package/dist/util/storage.js +20 -1
  46. package/dist/util/storage.js.map +1 -1
  47. package/docs/public/commands.md +20 -0
  48. package/output/kodrdriv/250702-0552-release-notes.md +3 -0
  49. package/package.json +7 -6
  50. package/pnpm-workspace.yaml +2 -0
  51. package/vitest.config.ts +4 -4
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env node
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { getLogger } from '../logging.js';
5
+ import { transcribeAudio } from '../util/openai.js';
6
+ import { run } from '../util/child.js';
7
+ import { DEFAULT_OUTPUT_DIRECTORY } from '../constants.js';
8
+ import { getTimestampedAudioFilename, getOutputPath, getTimestampedTranscriptFilename } from '../util/general.js';
9
+ import { create } from '../util/storage.js';
10
+ import { execute as execute$1 } from './review.js';
11
+
12
+ const detectBestAudioDevice = async ()=>{
13
+ try {
14
+ // Get list of audio devices - this command always "fails" but gives us the device list
15
+ try {
16
+ await run('ffmpeg -f avfoundation -list_devices true -i ""');
17
+ } catch (result) {
18
+ // ffmpeg returns error code but we get the device list in stderr
19
+ const output = result.stderr || result.stdout || '';
20
+ // Parse audio devices from output
21
+ const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
22
+ if (!audioDevicesSection) return '1'; // Default fallback
23
+ const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
24
+ // Prefer AirPods, then built-in microphone over virtual/external devices
25
+ const preferredDevices = [
26
+ 'AirPods',
27
+ 'MacBook Pro Microphone',
28
+ 'MacBook Air Microphone',
29
+ 'Built-in Microphone',
30
+ 'Internal Microphone'
31
+ ];
32
+ for (const deviceLine of deviceLines){
33
+ for (const preferred of preferredDevices){
34
+ if (deviceLine.toLowerCase().includes(preferred.toLowerCase())) {
35
+ // Extract device index
36
+ const match = deviceLine.match(/\[(\d+)\]/);
37
+ if (match) {
38
+ return match[1];
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ // If no preferred device found, use device 1 as default (usually better than 0)
45
+ return '1';
46
+ } catch (error) {
47
+ // Fallback to device 1
48
+ return '1';
49
+ }
50
+ };
51
+ const parseAudioDevices = async ()=>{
52
+ try {
53
+ try {
54
+ await run('ffmpeg -f avfoundation -list_devices true -i ""');
55
+ } catch (result) {
56
+ const output = result.stderr || result.stdout || '';
57
+ const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
58
+ if (audioDevicesSection) {
59
+ const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
60
+ return deviceLines.map((line)=>{
61
+ const match = line.match(/\[(\d+)\]\s+(.+)/);
62
+ if (match) {
63
+ return {
64
+ index: match[1],
65
+ name: match[2]
66
+ };
67
+ }
68
+ return null;
69
+ }).filter(Boolean);
70
+ }
71
+ }
72
+ return [];
73
+ } catch (error) {
74
+ return [];
75
+ }
76
+ };
77
+ const selectAudioDeviceInteractively = async (runConfig)=>{
78
+ const logger = getLogger();
79
+ logger.info('🎙️ Available audio devices:');
80
+ const devices = await parseAudioDevices();
81
+ if (devices.length === 0) {
82
+ logger.error('❌ No audio devices found. Make sure ffmpeg is installed and audio devices are available.');
83
+ return null;
84
+ }
85
+ // Display devices
86
+ devices.forEach((device, i)=>{
87
+ logger.info(` ${i + 1}. [${device.index}] ${device.name}`);
88
+ });
89
+ logger.info('');
90
+ logger.info('📋 Select an audio device by entering its number (1-' + devices.length + '):');
91
+ return new Promise((resolve)=>{
92
+ // Set up keyboard input
93
+ process.stdin.setRawMode(true);
94
+ process.stdin.resume();
95
+ process.stdin.setEncoding('utf8');
96
+ let inputBuffer = '';
97
+ const keyHandler = (key)=>{
98
+ const keyCode = key.charCodeAt(0);
99
+ if (keyCode === 13) {
100
+ const selectedIndex = parseInt(inputBuffer) - 1;
101
+ if (selectedIndex >= 0 && selectedIndex < devices.length) {
102
+ const selectedDevice = devices[selectedIndex];
103
+ logger.info(`✅ Selected: [${selectedDevice.index}] ${selectedDevice.name}`);
104
+ // Save to configuration
105
+ saveAudioDeviceToConfig(runConfig, selectedDevice.index, selectedDevice.name).then(()=>{
106
+ logger.info('💾 Audio device saved to configuration');
107
+ }).catch((error)=>{
108
+ logger.warn('⚠️ Failed to save audio device to configuration: %s', error.message);
109
+ });
110
+ // Cleanup and resolve
111
+ process.stdin.setRawMode(false);
112
+ process.stdin.pause();
113
+ process.stdin.removeListener('data', keyHandler);
114
+ resolve(selectedDevice.index);
115
+ } else {
116
+ logger.error('❌ Invalid selection. Please enter a number between 1 and ' + devices.length);
117
+ inputBuffer = '';
118
+ process.stdout.write('📋 Select an audio device: ');
119
+ }
120
+ } else if (keyCode === 3) {
121
+ logger.info('\n❌ Selection cancelled');
122
+ process.stdin.setRawMode(false);
123
+ process.stdin.pause();
124
+ process.stdin.removeListener('data', keyHandler);
125
+ resolve(null);
126
+ } else if (keyCode >= 48 && keyCode <= 57) {
127
+ inputBuffer += key;
128
+ process.stdout.write(key);
129
+ } else if (keyCode === 127) {
130
+ if (inputBuffer.length > 0) {
131
+ inputBuffer = inputBuffer.slice(0, -1);
132
+ process.stdout.write('\b \b');
133
+ }
134
+ }
135
+ };
136
+ process.stdin.on('data', keyHandler);
137
+ process.stdout.write('📋 Select an audio device: ');
138
+ });
139
+ };
140
+ const saveAudioDeviceToConfig = async (runConfig, deviceIndex, deviceName)=>{
141
+ const logger = getLogger();
142
+ const storage = create({
143
+ log: logger.info
144
+ });
145
+ try {
146
+ const configDir = runConfig.configDirectory || DEFAULT_OUTPUT_DIRECTORY;
147
+ await storage.ensureDirectory(configDir);
148
+ const configPath = getOutputPath(configDir, 'audio-config.json');
149
+ // Read existing config or create new one
150
+ let audioConfig = {};
151
+ try {
152
+ const existingConfig = await storage.readFile(configPath, 'utf-8');
153
+ audioConfig = JSON.parse(existingConfig);
154
+ } catch (error) {
155
+ // File doesn't exist or is invalid, start with empty config
156
+ audioConfig = {};
157
+ }
158
+ // Update audio device
159
+ audioConfig.audioDevice = deviceIndex;
160
+ audioConfig.audioDeviceName = deviceName;
161
+ audioConfig.lastUpdated = new Date().toISOString();
162
+ // Save updated config
163
+ await storage.writeFile(configPath, JSON.stringify(audioConfig, null, 2), 'utf-8');
164
+ logger.debug('Saved audio configuration to: %s', configPath);
165
+ } catch (error) {
166
+ logger.error('Failed to save audio configuration: %s', error.message);
167
+ throw error;
168
+ }
169
+ };
170
+ const loadAudioDeviceFromConfig = async (runConfig)=>{
171
+ const logger = getLogger();
172
+ const storage = create({
173
+ log: logger.info
174
+ });
175
+ try {
176
+ const configDir = runConfig.configDirectory || DEFAULT_OUTPUT_DIRECTORY;
177
+ const configPath = getOutputPath(configDir, 'audio-config.json');
178
+ const configContent = await storage.readFile(configPath, 'utf-8');
179
+ const audioConfig = JSON.parse(configContent);
180
+ if (audioConfig.audioDevice) {
181
+ logger.debug('Loaded audio device from config: [%s] %s', audioConfig.audioDevice, audioConfig.audioDeviceName || 'Unknown');
182
+ return audioConfig.audioDevice;
183
+ }
184
+ return null;
185
+ } catch (error) {
186
+ // Config file doesn't exist or is invalid
187
+ logger.debug('No saved audio device configuration found');
188
+ return null;
189
+ }
190
+ };
191
+ const listAudioDevices = async ()=>{
192
+ const logger = getLogger();
193
+ try {
194
+ try {
195
+ await run('ffmpeg -f avfoundation -list_devices true -i ""');
196
+ } catch (result) {
197
+ const output = result.stderr || result.stdout || '';
198
+ const audioDevicesSection = output.split('AVFoundation audio devices:')[1];
199
+ if (audioDevicesSection) {
200
+ logger.info('🎙️ Available audio devices:');
201
+ const deviceLines = audioDevicesSection.split('\n').filter((line)=>line.includes('[') && line.includes(']')).map((line)=>line.trim());
202
+ deviceLines.forEach((line)=>{
203
+ const match = line.match(/\[(\d+)\]\s+(.+)/);
204
+ if (match) {
205
+ logger.info(` [${match[1]}] ${match[2]}`);
206
+ }
207
+ });
208
+ }
209
+ }
210
+ } catch (error) {
211
+ logger.debug('Could not list audio devices');
212
+ }
213
+ };
214
+ const execute = async (runConfig)=>{
215
+ var _runConfig_audioReview, _runConfig_audioReview1, _runConfig_audioReview2, _runConfig_audioReview3, _runConfig_audioReview4, _runConfig_audioReview5, _runConfig_audioReview6, _runConfig_audioReview7, _runConfig_audioReview8, _runConfig_audioReview9, _runConfig_audioReview10, _runConfig_review;
216
+ const logger = getLogger();
217
+ const isDryRun = runConfig.dryRun || false;
218
+ // Handle audio device selection if requested
219
+ if ((_runConfig_audioReview = runConfig.audioReview) === null || _runConfig_audioReview === void 0 ? void 0 : _runConfig_audioReview.selectAudioDevice) {
220
+ logger.info('🎛️ Starting audio device selection...');
221
+ const selectedDevice = await selectAudioDeviceInteractively(runConfig);
222
+ if (selectedDevice === null) {
223
+ logger.error('❌ Audio device selection cancelled or failed');
224
+ process.exit(1);
225
+ }
226
+ logger.info('✅ Audio device selection complete');
227
+ logger.info('');
228
+ logger.info('You can now run the audio-review command without --select-audio-device to use your saved device');
229
+ return 'Audio device configured successfully';
230
+ }
231
+ if (isDryRun) {
232
+ var _runConfig_review1;
233
+ logger.info('DRY RUN: Would start audio recording for review context');
234
+ logger.info('DRY RUN: Would transcribe audio and use as context for review analysis');
235
+ logger.info('DRY RUN: Would then delegate to regular review command');
236
+ // In dry run, just call the regular review command with empty note
237
+ return execute$1({
238
+ ...runConfig,
239
+ review: {
240
+ ...runConfig.review,
241
+ note: ((_runConfig_review1 = runConfig.review) === null || _runConfig_review1 === void 0 ? void 0 : _runConfig_review1.note) || ''
242
+ }
243
+ });
244
+ }
245
+ // Start audio recording and transcription
246
+ logger.info('Starting audio recording for review context...');
247
+ logger.info('This command will use your system\'s default audio recording tool');
248
+ logger.info('💡 Tip: Use --select-audio-device to choose a specific microphone');
249
+ logger.info('Press Ctrl+C after you finish speaking to generate your review analysis');
250
+ const audioContext = await recordAndTranscribeAudio(runConfig);
251
+ // Now delegate to the regular review command with the audio context
252
+ logger.info('🤖 Analyzing review using audio context...');
253
+ const result = await execute$1({
254
+ ...runConfig,
255
+ review: {
256
+ // Map audioReview configuration to review configuration
257
+ includeCommitHistory: (_runConfig_audioReview1 = runConfig.audioReview) === null || _runConfig_audioReview1 === void 0 ? void 0 : _runConfig_audioReview1.includeCommitHistory,
258
+ includeRecentDiffs: (_runConfig_audioReview2 = runConfig.audioReview) === null || _runConfig_audioReview2 === void 0 ? void 0 : _runConfig_audioReview2.includeRecentDiffs,
259
+ includeReleaseNotes: (_runConfig_audioReview3 = runConfig.audioReview) === null || _runConfig_audioReview3 === void 0 ? void 0 : _runConfig_audioReview3.includeReleaseNotes,
260
+ includeGithubIssues: (_runConfig_audioReview4 = runConfig.audioReview) === null || _runConfig_audioReview4 === void 0 ? void 0 : _runConfig_audioReview4.includeGithubIssues,
261
+ commitHistoryLimit: (_runConfig_audioReview5 = runConfig.audioReview) === null || _runConfig_audioReview5 === void 0 ? void 0 : _runConfig_audioReview5.commitHistoryLimit,
262
+ diffHistoryLimit: (_runConfig_audioReview6 = runConfig.audioReview) === null || _runConfig_audioReview6 === void 0 ? void 0 : _runConfig_audioReview6.diffHistoryLimit,
263
+ releaseNotesLimit: (_runConfig_audioReview7 = runConfig.audioReview) === null || _runConfig_audioReview7 === void 0 ? void 0 : _runConfig_audioReview7.releaseNotesLimit,
264
+ githubIssuesLimit: (_runConfig_audioReview8 = runConfig.audioReview) === null || _runConfig_audioReview8 === void 0 ? void 0 : _runConfig_audioReview8.githubIssuesLimit,
265
+ sendit: (_runConfig_audioReview9 = runConfig.audioReview) === null || _runConfig_audioReview9 === void 0 ? void 0 : _runConfig_audioReview9.sendit,
266
+ context: (_runConfig_audioReview10 = runConfig.audioReview) === null || _runConfig_audioReview10 === void 0 ? void 0 : _runConfig_audioReview10.context,
267
+ // Use the transcribed audio as content
268
+ note: audioContext.trim() || ((_runConfig_review = runConfig.review) === null || _runConfig_review === void 0 ? void 0 : _runConfig_review.note) || ''
269
+ }
270
+ });
271
+ // Final cleanup to ensure process can exit
272
+ try {
273
+ if (process.stdin.setRawMode) {
274
+ process.stdin.setRawMode(false);
275
+ process.stdin.pause();
276
+ process.stdin.removeAllListeners();
277
+ }
278
+ process.removeAllListeners('SIGINT');
279
+ process.removeAllListeners('SIGTERM');
280
+ } catch (error) {
281
+ // Ignore cleanup errors
282
+ }
283
+ return result;
284
+ };
285
+ const recordAndTranscribeAudio = async (runConfig)=>{
286
+ var _runConfig_audioReview;
287
+ const logger = getLogger();
288
+ const outputDirectory = runConfig.outputDirectory || DEFAULT_OUTPUT_DIRECTORY;
289
+ const storage = create({
290
+ log: logger.info
291
+ });
292
+ await storage.ensureDirectory(outputDirectory);
293
+ const tempDir = await fs.mkdtemp(path.join(outputDirectory, '.temp-audio-'));
294
+ const audioFilePath = path.join(tempDir, 'recording.wav');
295
+ // Declare variables at function scope for cleanup access
296
+ let recordingProcess = null;
297
+ let recordingFinished = false;
298
+ let recordingCancelled = false;
299
+ let countdownInterval = null;
300
+ let remainingSeconds = 30;
301
+ let intendedRecordingTime = 30;
302
+ const maxRecordingTime = ((_runConfig_audioReview = runConfig.audioReview) === null || _runConfig_audioReview === void 0 ? void 0 : _runConfig_audioReview.maxRecordingTime) || 300; // 5 minutes default
303
+ const extensionTime = 30; // 30 seconds per extension
304
+ try {
305
+ // Use system recording tool - cross-platform approach
306
+ logger.info('🎤 Starting recording... Speak now!');
307
+ logger.info('📋 Controls: ENTER=done, E=extend+30s, C/Ctrl+C=cancel');
308
+ // List available audio devices in debug mode
309
+ if (runConfig.debug) {
310
+ await listAudioDevices();
311
+ }
312
+ // Start countdown display
313
+ const startCountdown = ()=>{
314
+ // Show initial countdown
315
+ updateCountdownDisplay();
316
+ countdownInterval = setInterval(()=>{
317
+ remainingSeconds--;
318
+ if (remainingSeconds > 0) {
319
+ updateCountdownDisplay();
320
+ } else {
321
+ process.stdout.write('\r⏱️ Recording: Time\'s up! \n');
322
+ if (countdownInterval) {
323
+ clearInterval(countdownInterval);
324
+ countdownInterval = null;
325
+ }
326
+ // Auto-stop when intended time is reached
327
+ stopRecording();
328
+ }
329
+ }, 1000);
330
+ };
331
+ const updateCountdownDisplay = ()=>{
332
+ const maxMinutes = Math.floor(maxRecordingTime / 60);
333
+ const intendedMinutes = Math.floor(intendedRecordingTime / 60);
334
+ const intendedSeconds = intendedRecordingTime % 60;
335
+ process.stdout.write(`\r⏱️ Recording: ${remainingSeconds}s left (${intendedMinutes}:${intendedSeconds.toString().padStart(2, '0')}/${maxMinutes}:00 max) [ENTER=done, E=+30s, C=cancel]`);
336
+ };
337
+ const extendRecording = ()=>{
338
+ const newTotal = intendedRecordingTime + extensionTime;
339
+ if (newTotal <= maxRecordingTime) {
340
+ intendedRecordingTime = newTotal;
341
+ remainingSeconds += extensionTime;
342
+ logger.info(`🔄 Extended recording by ${extensionTime}s (total: ${Math.floor(intendedRecordingTime / 60)}:${(intendedRecordingTime % 60).toString().padStart(2, '0')})`);
343
+ updateCountdownDisplay();
344
+ } else {
345
+ const canExtend = maxRecordingTime - intendedRecordingTime;
346
+ if (canExtend > 0) {
347
+ intendedRecordingTime = maxRecordingTime;
348
+ remainingSeconds += canExtend;
349
+ logger.info(`🔄 Extended recording by ${canExtend}s (maximum reached: ${Math.floor(maxRecordingTime / 60)}:${(maxRecordingTime % 60).toString().padStart(2, '0')})`);
350
+ updateCountdownDisplay();
351
+ } else {
352
+ logger.warn(`⚠️ Cannot extend: maximum recording time (${Math.floor(maxRecordingTime / 60)}:${(maxRecordingTime % 60).toString().padStart(2, '0')}) reached`);
353
+ }
354
+ }
355
+ };
356
+ // Set up keyboard input handling
357
+ const setupKeyboardHandling = ()=>{
358
+ process.stdin.setRawMode(true);
359
+ process.stdin.resume();
360
+ process.stdin.setEncoding('utf8');
361
+ const keyHandler = (key)=>{
362
+ const keyCode = key.charCodeAt(0);
363
+ if (keyCode === 13) {
364
+ // Immediate feedback
365
+ process.stdout.write('\r✅ ENTER pressed - stopping recording... \n');
366
+ process.stdin.setRawMode(false);
367
+ process.stdin.pause();
368
+ process.stdin.removeListener('data', keyHandler);
369
+ stopRecording();
370
+ } else if (key.toLowerCase() === 'e') {
371
+ extendRecording();
372
+ } else if (key.toLowerCase() === 'c' || keyCode === 3) {
373
+ // Immediate feedback
374
+ process.stdout.write('\r❌ Cancelling recording... \n');
375
+ process.stdin.setRawMode(false);
376
+ process.stdin.pause();
377
+ process.stdin.removeListener('data', keyHandler);
378
+ cancelRecording();
379
+ }
380
+ };
381
+ process.stdin.on('data', keyHandler);
382
+ };
383
+ // Determine which recording command to use based on platform (using max time)
384
+ let recordCommand;
385
+ if (process.platform === 'darwin') {
386
+ // macOS - try ffmpeg first, then fall back to manual recording
387
+ try {
388
+ var _runConfig_audioReview1, _runConfig_audioReview2;
389
+ // Check if ffmpeg is available
390
+ await run('which ffmpeg');
391
+ // Get the best audio device (from saved config, CLI config, or auto-detected)
392
+ const savedDevice = await loadAudioDeviceFromConfig(runConfig);
393
+ const audioDevice = ((_runConfig_audioReview1 = runConfig.audioReview) === null || _runConfig_audioReview1 === void 0 ? void 0 : _runConfig_audioReview1.audioDevice) || savedDevice || await detectBestAudioDevice();
394
+ recordCommand = `ffmpeg -f avfoundation -i ":${audioDevice}" -t ${maxRecordingTime} -y "${audioFilePath}"`;
395
+ if ((_runConfig_audioReview2 = runConfig.audioReview) === null || _runConfig_audioReview2 === void 0 ? void 0 : _runConfig_audioReview2.audioDevice) {
396
+ logger.info(`🎙️ Using audio device ${audioDevice} (from CLI configuration)`);
397
+ } else if (savedDevice) {
398
+ logger.info(`🎙️ Using audio device ${audioDevice} (from saved configuration)`);
399
+ } else {
400
+ logger.info(`🎙️ Using audio device ${audioDevice} (auto-detected)`);
401
+ }
402
+ } catch {
403
+ // ffmpeg not available, try sox/rec
404
+ try {
405
+ await run('which rec');
406
+ recordCommand = `rec -r 44100 -c 1 -t wav "${audioFilePath}" trim 0 ${maxRecordingTime}`;
407
+ } catch {
408
+ // Neither available, use manual fallback
409
+ throw new Error('MANUAL_RECORDING_NEEDED');
410
+ }
411
+ }
412
+ } else if (process.platform === 'win32') {
413
+ // Windows - use ffmpeg if available, otherwise fallback
414
+ try {
415
+ await run('where ffmpeg');
416
+ recordCommand = `ffmpeg -f dshow -i audio="Microphone" -t ${maxRecordingTime} -y "${audioFilePath}"`;
417
+ } catch {
418
+ throw new Error('MANUAL_RECORDING_NEEDED');
419
+ }
420
+ } else {
421
+ // Linux - use arecord (ALSA) or ffmpeg
422
+ try {
423
+ await run('which arecord');
424
+ recordCommand = `arecord -f cd -t wav -d ${maxRecordingTime} "${audioFilePath}"`;
425
+ } catch {
426
+ try {
427
+ await run('which ffmpeg');
428
+ recordCommand = `ffmpeg -f alsa -i default -t ${maxRecordingTime} -y "${audioFilePath}"`;
429
+ } catch {
430
+ throw new Error('MANUAL_RECORDING_NEEDED');
431
+ }
432
+ }
433
+ }
434
+ // Start recording as a background process (with max time, we'll stop it early if needed)
435
+ try {
436
+ recordingProcess = run(recordCommand);
437
+ } catch (error) {
438
+ if (error.message === 'MANUAL_RECORDING_NEEDED') {
439
+ // Provide helpful instructions for manual recording
440
+ logger.warn('⚠️ Automatic recording not available on this system.');
441
+ logger.warn('📱 Please record audio manually using your system\'s built-in tools:');
442
+ logger.warn('');
443
+ if (process.platform === 'darwin') {
444
+ logger.warn('🍎 macOS options:');
445
+ logger.warn(' 1. Use QuickTime Player: File → New Audio Recording');
446
+ logger.warn(' 2. Use Voice Memos app');
447
+ logger.warn(' 3. Install ffmpeg: brew install ffmpeg');
448
+ logger.warn(' 4. Install sox: brew install sox');
449
+ } else if (process.platform === 'win32') {
450
+ logger.warn('🪟 Windows options:');
451
+ logger.warn(' 1. Use Voice Recorder app');
452
+ logger.warn(' 2. Install ffmpeg: https://ffmpeg.org/download.html');
453
+ } else {
454
+ logger.warn('🐧 Linux options:');
455
+ logger.warn(' 1. Install alsa-utils: sudo apt install alsa-utils');
456
+ logger.warn(' 2. Install ffmpeg: sudo apt install ffmpeg');
457
+ }
458
+ logger.warn('');
459
+ logger.warn(`💾 Save your recording as: ${audioFilePath}`);
460
+ logger.warn('🎵 Recommended format: WAV, 44.1kHz, mono or stereo');
461
+ logger.warn('');
462
+ logger.warn('⌨️ Press ENTER when you have saved the audio file...');
463
+ // Wait for user input (disable our keyboard handling for this)
464
+ await new Promise((resolve)=>{
465
+ const originalRawMode = process.stdin.setRawMode;
466
+ process.stdin.setRawMode(true);
467
+ process.stdin.resume();
468
+ const enterHandler = (key)=>{
469
+ if (key[0] === 13) {
470
+ process.stdin.setRawMode(false);
471
+ process.stdin.pause();
472
+ process.stdin.removeListener('data', enterHandler);
473
+ resolve(void 0);
474
+ }
475
+ };
476
+ process.stdin.on('data', enterHandler);
477
+ });
478
+ // Skip the automatic recording and keyboard handling for manual recording
479
+ recordingProcess = null;
480
+ } else {
481
+ throw error;
482
+ }
483
+ }
484
+ // Set up graceful shutdown
485
+ const stopRecording = async ()=>{
486
+ if (!recordingFinished && !recordingCancelled) {
487
+ recordingFinished = true;
488
+ // Clear countdown
489
+ if (countdownInterval) {
490
+ clearInterval(countdownInterval);
491
+ countdownInterval = null;
492
+ }
493
+ // Clear the countdown line and show recording stopped
494
+ process.stdout.write('\r⏱️ Recording finished! \n');
495
+ if (recordingProcess && recordingProcess.kill) {
496
+ recordingProcess.kill('SIGTERM');
497
+ }
498
+ logger.info('🛑 Recording stopped - proceeding with review');
499
+ }
500
+ };
501
+ // Set up cancellation
502
+ const cancelRecording = async ()=>{
503
+ if (!recordingFinished && !recordingCancelled) {
504
+ recordingCancelled = true;
505
+ // Clear countdown
506
+ if (countdownInterval) {
507
+ clearInterval(countdownInterval);
508
+ countdownInterval = null;
509
+ }
510
+ // Clear the countdown line and show cancellation
511
+ process.stdout.write('\r❌ Recording cancelled! \n');
512
+ if (recordingProcess && recordingProcess.kill) {
513
+ recordingProcess.kill('SIGTERM');
514
+ }
515
+ logger.info('❌ Audio review cancelled by user');
516
+ process.exit(0);
517
+ }
518
+ };
519
+ // Remove the old SIGINT handler and use our new keyboard handling
520
+ // Note: We'll still handle SIGINT for cleanup, but route it through cancelRecording
521
+ process.on('SIGINT', cancelRecording);
522
+ // Start keyboard handling and countdown if we have a recording process
523
+ if (recordingProcess) {
524
+ setupKeyboardHandling();
525
+ startCountdown();
526
+ // Create a promise that resolves when user manually stops recording
527
+ const manualStopPromise = new Promise((resolve)=>{
528
+ const checkInterval = setInterval(()=>{
529
+ if (recordingFinished || recordingCancelled) {
530
+ clearInterval(checkInterval);
531
+ resolve();
532
+ }
533
+ }, 100);
534
+ });
535
+ // Wait for either the recording to finish naturally or manual stop
536
+ try {
537
+ await Promise.race([
538
+ recordingProcess,
539
+ manualStopPromise
540
+ ]);
541
+ // If manually stopped, force kill the process if it's still running
542
+ if (recordingFinished && recordingProcess && !recordingProcess.killed) {
543
+ recordingProcess.kill('SIGKILL');
544
+ // Give it a moment to die
545
+ await new Promise((resolve)=>setTimeout(resolve, 200));
546
+ }
547
+ // Only show completion message if not manually finished
548
+ if (!recordingCancelled && !recordingFinished) {
549
+ // Clear countdown on successful completion
550
+ if (countdownInterval) {
551
+ clearInterval(countdownInterval);
552
+ countdownInterval = null;
553
+ }
554
+ process.stdout.write('\r⏱️ Recording completed! \n');
555
+ logger.info('✅ Recording completed automatically');
556
+ }
557
+ } catch (error) {
558
+ // Only handle errors if not cancelled and not manually finished
559
+ if (!recordingCancelled && !recordingFinished) {
560
+ // Clear countdown on error
561
+ if (countdownInterval) {
562
+ clearInterval(countdownInterval);
563
+ countdownInterval = null;
564
+ }
565
+ if (error.signal === 'SIGTERM' || error.signal === 'SIGKILL') {
566
+ // This is expected when we kill the process
567
+ logger.debug('Recording process terminated as expected');
568
+ } else {
569
+ logger.warn('Recording process ended unexpectedly: %s', error.message);
570
+ }
571
+ }
572
+ }
573
+ // Always clean up keyboard input
574
+ if (process.stdin.setRawMode) {
575
+ process.stdin.setRawMode(false);
576
+ process.stdin.pause();
577
+ }
578
+ }
579
+ // If recording was cancelled, exit early
580
+ if (recordingCancelled) {
581
+ return '';
582
+ }
583
+ // Wait a moment for the recording file to be fully written
584
+ if (recordingFinished) {
585
+ await new Promise((resolve)=>setTimeout(resolve, 500));
586
+ }
587
+ // Check if audio file exists
588
+ try {
589
+ await fs.access(audioFilePath);
590
+ const stats = await fs.stat(audioFilePath);
591
+ if (stats.size === 0) {
592
+ throw new Error('Audio file is empty');
593
+ }
594
+ logger.info('✅ Audio file created successfully (%d bytes)', stats.size);
595
+ } catch (error) {
596
+ throw new Error(`Failed to create audio file: ${error.message}`);
597
+ }
598
+ // Transcribe the audio
599
+ logger.info('🎯 Transcribing audio...');
600
+ logger.info('⏳ This may take a few seconds depending on audio length...');
601
+ const transcription = await transcribeAudio(audioFilePath);
602
+ const audioContext = transcription.text;
603
+ logger.info('✅ Audio transcribed successfully');
604
+ logger.debug('Transcription: %s', audioContext);
605
+ // Save audio file and transcript to output directory
606
+ logger.info('💾 Saving audio file and transcript...');
607
+ try {
608
+ const outputDirectory = runConfig.outputDirectory || DEFAULT_OUTPUT_DIRECTORY;
609
+ const storage = create({
610
+ log: logger.info
611
+ });
612
+ await storage.ensureDirectory(outputDirectory);
613
+ // Save audio file copy
614
+ const audioOutputFilename = getTimestampedAudioFilename();
615
+ const audioOutputPath = getOutputPath(outputDirectory, audioOutputFilename);
616
+ await fs.copyFile(audioFilePath, audioOutputPath);
617
+ logger.debug('Saved audio file: %s', audioOutputPath);
618
+ // Save transcript
619
+ if (audioContext.trim()) {
620
+ const transcriptOutputFilename = getTimestampedTranscriptFilename();
621
+ const transcriptOutputPath = getOutputPath(outputDirectory, transcriptOutputFilename);
622
+ const transcriptContent = `# Audio Transcript\n\n**Recorded:** ${new Date().toISOString()}\n\n**Transcript:**\n\n${audioContext}`;
623
+ await storage.writeFile(transcriptOutputPath, transcriptContent, 'utf-8');
624
+ logger.debug('Saved transcript: %s', transcriptOutputPath);
625
+ }
626
+ } catch (error) {
627
+ logger.warn('Failed to save audio/transcript files: %s', error.message);
628
+ }
629
+ if (!audioContext.trim()) {
630
+ logger.warn('No audio content was transcribed. Proceeding without audio context.');
631
+ return '';
632
+ } else {
633
+ logger.info('📝 Using transcribed audio as review note');
634
+ return audioContext;
635
+ }
636
+ } catch (error) {
637
+ logger.error('Audio recording/transcription failed: %s', error.message);
638
+ logger.info('Proceeding with review analysis without audio context...');
639
+ return '';
640
+ } finally{
641
+ // Comprehensive cleanup to ensure program can exit
642
+ try {
643
+ // Clear any remaining countdown interval
644
+ if (countdownInterval) {
645
+ clearInterval(countdownInterval);
646
+ countdownInterval = null;
647
+ }
648
+ // Ensure stdin is properly reset
649
+ if (process.stdin.setRawMode) {
650
+ process.stdin.setRawMode(false);
651
+ process.stdin.pause();
652
+ process.stdin.removeAllListeners('data');
653
+ }
654
+ // Remove process event listeners that we added
655
+ process.removeAllListeners('SIGINT');
656
+ process.removeAllListeners('SIGTERM');
657
+ // Force kill any remaining recording process
658
+ if (recordingProcess && !recordingProcess.killed) {
659
+ try {
660
+ recordingProcess.kill('SIGKILL');
661
+ } catch (killError) {
662
+ // Ignore kill errors
663
+ }
664
+ }
665
+ // Clean up temporary directory
666
+ await fs.rm(tempDir, {
667
+ recursive: true,
668
+ force: true
669
+ });
670
+ } catch (cleanupError) {
671
+ logger.debug('Cleanup warning: %s', cleanupError.message);
672
+ }
673
+ }
674
+ };
675
+
676
+ export { execute };
677
+ //# sourceMappingURL=audio-review.js.map