listener-ai 2.0.0 → 2.1.1
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/dist/agentService.js +4 -4
- package/dist/cli.js +15 -15
- package/dist/configService.js +4 -0
- package/dist/geminiService.js +5 -26
- package/dist/main.js +161 -46
- package/dist/outputService.js +27 -15
- package/dist/searchService.js +3 -3
- package/package.json +2 -2
package/dist/agentService.js
CHANGED
|
@@ -234,7 +234,7 @@ class AgentService {
|
|
|
234
234
|
const tools = buildTools(opts.scope, !!opts.confirm);
|
|
235
235
|
// Load the single-meeting record once if needed; title + primer derive from it.
|
|
236
236
|
const singleData = opts.scope.kind === 'single' && isValidFolderName(opts.scope.folderName)
|
|
237
|
-
? (0, outputService_1.readTranscription)(path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), opts.scope.folderName))
|
|
237
|
+
? await (0, outputService_1.readTranscription)(path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), opts.scope.folderName))
|
|
238
238
|
: null;
|
|
239
239
|
const systemInstruction = systemInstructionFor(opts.scope, singleData?.title);
|
|
240
240
|
const history = opts.history ? [...opts.history] : [];
|
|
@@ -305,7 +305,7 @@ class AgentService {
|
|
|
305
305
|
const limit = typeof args.limit === 'number' ? args.limit : 5;
|
|
306
306
|
const includeTranscript = args.include_transcript === true;
|
|
307
307
|
const fields = includeTranscript ? [...searchService_1.ALL_FIELDS] : ['title', 'summary', 'keyPoints', 'actionItems'];
|
|
308
|
-
const hits = (0, searchService_1.searchTranscriptions)(this.dataPath, { query, fields, limit });
|
|
308
|
+
const hits = await (0, searchService_1.searchTranscriptions)(this.dataPath, { query, fields, limit });
|
|
309
309
|
return {
|
|
310
310
|
hits: hits.map((h) => ({
|
|
311
311
|
folder_name: h.entry.folderName,
|
|
@@ -319,7 +319,7 @@ class AgentService {
|
|
|
319
319
|
}
|
|
320
320
|
case 'list_recent_transcriptions': {
|
|
321
321
|
const limit = typeof args.limit === 'number' ? args.limit : 10;
|
|
322
|
-
const entries = (0, outputService_1.listTranscriptions)(this.dataPath, limit);
|
|
322
|
+
const entries = await (0, outputService_1.listTranscriptions)(this.dataPath, limit);
|
|
323
323
|
return {
|
|
324
324
|
entries: entries.map((e) => ({
|
|
325
325
|
folder_name: e.folderName,
|
|
@@ -333,7 +333,7 @@ class AgentService {
|
|
|
333
333
|
if (!isValidFolderName(folderName))
|
|
334
334
|
return { error: 'folder_name must be a bare folder name returned by search/list (no slashes, no ..)' };
|
|
335
335
|
const folderPath = path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), folderName);
|
|
336
|
-
const data = (0, outputService_1.readTranscription)(folderPath);
|
|
336
|
+
const data = await (0, outputService_1.readTranscription)(folderPath);
|
|
337
337
|
if (!data)
|
|
338
338
|
return { error: `transcription not found: ${folderName}` };
|
|
339
339
|
const result = {
|
package/dist/cli.js
CHANGED
|
@@ -155,10 +155,10 @@ function handleConfig(subArgs) {
|
|
|
155
155
|
process.stderr.write(`Error: Unknown config command: ${sub}\n`);
|
|
156
156
|
usage();
|
|
157
157
|
}
|
|
158
|
-
function resolveRef(ref, dataPath) {
|
|
158
|
+
async function resolveRef(ref, dataPath) {
|
|
159
159
|
if (/^\d+$/.test(ref)) {
|
|
160
160
|
const index = parseInt(ref, 10);
|
|
161
|
-
const entries = (0, outputService_1.listTranscriptions)(dataPath, 0);
|
|
161
|
+
const entries = await (0, outputService_1.listTranscriptions)(dataPath, 0);
|
|
162
162
|
if (entries.length === 0) {
|
|
163
163
|
process.stderr.write('Error: No transcriptions found.\n');
|
|
164
164
|
process.exit(1);
|
|
@@ -176,7 +176,7 @@ function resolveRef(ref, dataPath) {
|
|
|
176
176
|
}
|
|
177
177
|
return folderPath;
|
|
178
178
|
}
|
|
179
|
-
function handleList(args) {
|
|
179
|
+
async function handleList(args) {
|
|
180
180
|
const dataPath = (0, dataPath_1.getDataPath)();
|
|
181
181
|
let limit;
|
|
182
182
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -188,7 +188,7 @@ function handleList(args) {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
const entries = (0, outputService_1.listTranscriptions)(dataPath, limit);
|
|
191
|
+
const entries = await (0, outputService_1.listTranscriptions)(dataPath, limit);
|
|
192
192
|
if (entries.length === 0) {
|
|
193
193
|
process.stderr.write('No transcriptions found.\n');
|
|
194
194
|
return;
|
|
@@ -202,14 +202,14 @@ function handleList(args) {
|
|
|
202
202
|
process.stdout.write(`${num} ${date} ${title}\n`);
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
|
-
function handleShow(args) {
|
|
205
|
+
async function handleShow(args) {
|
|
206
206
|
const ref = args[0];
|
|
207
207
|
if (!ref) {
|
|
208
208
|
process.stderr.write('Error: Missing ref. Usage: listener show <ref>\n');
|
|
209
209
|
process.exit(1);
|
|
210
210
|
}
|
|
211
211
|
const dataPath = (0, dataPath_1.getDataPath)();
|
|
212
|
-
const folderPath = resolveRef(ref, dataPath);
|
|
212
|
+
const folderPath = await resolveRef(ref, dataPath);
|
|
213
213
|
const summaryPath = path.join(folderPath, 'summary.md');
|
|
214
214
|
if (!fs.existsSync(summaryPath)) {
|
|
215
215
|
process.stderr.write(`Error: summary.md not found in ${folderPath}\n`);
|
|
@@ -219,7 +219,7 @@ function handleShow(args) {
|
|
|
219
219
|
const { body } = (0, outputService_1.parseFrontmatter)(content);
|
|
220
220
|
process.stdout.write(body);
|
|
221
221
|
}
|
|
222
|
-
function handleExport(args) {
|
|
222
|
+
async function handleExport(args) {
|
|
223
223
|
let ref;
|
|
224
224
|
let targetPath;
|
|
225
225
|
let json = false;
|
|
@@ -251,7 +251,7 @@ function handleExport(args) {
|
|
|
251
251
|
process.exit(1);
|
|
252
252
|
}
|
|
253
253
|
const dataPath = (0, dataPath_1.getDataPath)();
|
|
254
|
-
const folderPath = resolveRef(ref, dataPath);
|
|
254
|
+
const folderPath = await resolveRef(ref, dataPath);
|
|
255
255
|
const summaryPath = path.join(folderPath, 'summary.md');
|
|
256
256
|
if (!fs.existsSync(summaryPath)) {
|
|
257
257
|
process.stderr.write(`Error: summary.md not found in ${folderPath}\n`);
|
|
@@ -310,7 +310,7 @@ function handleExport(args) {
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
|
-
function handleSearch(args) {
|
|
313
|
+
async function handleSearch(args) {
|
|
314
314
|
const VALID_FIELDS = [...searchService_1.ALL_FIELDS, 'all'];
|
|
315
315
|
let query;
|
|
316
316
|
let limit = 20;
|
|
@@ -357,7 +357,7 @@ function handleSearch(args) {
|
|
|
357
357
|
}
|
|
358
358
|
const dataPath = (0, dataPath_1.getDataPath)();
|
|
359
359
|
const fields = (0, searchService_1.resolveFields)({ field, includeTranscript });
|
|
360
|
-
const hits = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
|
|
360
|
+
const hits = await (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
|
|
361
361
|
if (hits.length === 0) {
|
|
362
362
|
process.stderr.write('No results.\n');
|
|
363
363
|
return;
|
|
@@ -421,7 +421,7 @@ async function handleAsk(args) {
|
|
|
421
421
|
}
|
|
422
422
|
let scope = { kind: 'all' };
|
|
423
423
|
if (ref) {
|
|
424
|
-
const folderPath = resolveRef(ref, dataPath);
|
|
424
|
+
const folderPath = await resolveRef(ref, dataPath);
|
|
425
425
|
scope = { kind: 'single', folderName: path.basename(folderPath) };
|
|
426
426
|
}
|
|
427
427
|
const agent = new agentService_1.AgentService({ apiKey, dataPath, configService: config });
|
|
@@ -448,19 +448,19 @@ async function main() {
|
|
|
448
448
|
return;
|
|
449
449
|
}
|
|
450
450
|
if (args[0] === 'list') {
|
|
451
|
-
handleList(args.slice(1));
|
|
451
|
+
await handleList(args.slice(1));
|
|
452
452
|
return;
|
|
453
453
|
}
|
|
454
454
|
if (args[0] === 'show') {
|
|
455
|
-
handleShow(args.slice(1));
|
|
455
|
+
await handleShow(args.slice(1));
|
|
456
456
|
return;
|
|
457
457
|
}
|
|
458
458
|
if (args[0] === 'export') {
|
|
459
|
-
handleExport(args.slice(1));
|
|
459
|
+
await handleExport(args.slice(1));
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
462
|
if (args[0] === 'search') {
|
|
463
|
-
handleSearch(args.slice(1));
|
|
463
|
+
await handleSearch(args.slice(1));
|
|
464
464
|
return;
|
|
465
465
|
}
|
|
466
466
|
if (args[0] === 'ask') {
|
package/dist/configService.js
CHANGED
|
@@ -194,6 +194,9 @@ class ConfigService {
|
|
|
194
194
|
this.config.minRecordingSeconds = Math.max(0, Math.floor(seconds));
|
|
195
195
|
this.saveConfig();
|
|
196
196
|
}
|
|
197
|
+
getAudioDeviceId() {
|
|
198
|
+
return this.config.audioDeviceId;
|
|
199
|
+
}
|
|
197
200
|
getLastSeenVersion() {
|
|
198
201
|
return this.config.lastSeenVersion;
|
|
199
202
|
}
|
|
@@ -232,6 +235,7 @@ class ConfigService {
|
|
|
232
235
|
maxRecordingMinutes: this.getMaxRecordingMinutes(),
|
|
233
236
|
recordingReminderMinutes: this.getRecordingReminderMinutes(),
|
|
234
237
|
minRecordingSeconds: this.getMinRecordingSeconds(),
|
|
238
|
+
audioDeviceId: this.getAudioDeviceId(),
|
|
235
239
|
lastSeenVersion: this.getLastSeenVersion()
|
|
236
240
|
};
|
|
237
241
|
}
|
package/dist/geminiService.js
CHANGED
|
@@ -38,6 +38,7 @@ const genai_1 = require("@google/genai");
|
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const ffmpegManager_1 = require("./services/ffmpegManager");
|
|
41
|
+
const audioFormats_1 = require("./audioFormats");
|
|
41
42
|
class GeminiService {
|
|
42
43
|
// Get FFmpeg path for this service
|
|
43
44
|
async getFFmpegPath() {
|
|
@@ -315,14 +316,7 @@ Return as JSON:
|
|
|
315
316
|
if (progressCallback) {
|
|
316
317
|
progressCallback(25, 'Uploading large file to Gemini...');
|
|
317
318
|
}
|
|
318
|
-
const
|
|
319
|
-
let mimeType = 'audio/mp3';
|
|
320
|
-
if (fileExt === '.wav') {
|
|
321
|
-
mimeType = 'audio/wav';
|
|
322
|
-
}
|
|
323
|
-
else if (fileExt === '.m4a') {
|
|
324
|
-
mimeType = 'audio/mp4';
|
|
325
|
-
}
|
|
319
|
+
const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
|
|
326
320
|
const fileData = fs.readFileSync(audioFilePath);
|
|
327
321
|
const uploadResult = await this.ai.files.upload({
|
|
328
322
|
file: new Blob([fileData], { type: mimeType })
|
|
@@ -368,14 +362,7 @@ IMPORTANT:
|
|
|
368
362
|
- Return ONLY the transcription text, no JSON formatting`;
|
|
369
363
|
let result;
|
|
370
364
|
if (fileUri) {
|
|
371
|
-
const
|
|
372
|
-
let mimeType = 'audio/mp3';
|
|
373
|
-
if (fileExt === '.wav') {
|
|
374
|
-
mimeType = 'audio/wav';
|
|
375
|
-
}
|
|
376
|
-
else if (fileExt === '.m4a') {
|
|
377
|
-
mimeType = 'audio/mp4';
|
|
378
|
-
}
|
|
365
|
+
const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
|
|
379
366
|
result = await this.ai.models.generateContent({
|
|
380
367
|
model: this.flashModel,
|
|
381
368
|
contents: [
|
|
@@ -401,14 +388,7 @@ IMPORTANT:
|
|
|
401
388
|
else {
|
|
402
389
|
const audioData = fs.readFileSync(audioFilePath);
|
|
403
390
|
const base64Audio = audioData.toString('base64');
|
|
404
|
-
const
|
|
405
|
-
let mimeType = 'audio/mp3';
|
|
406
|
-
if (fileExt === '.wav') {
|
|
407
|
-
mimeType = 'audio/wav';
|
|
408
|
-
}
|
|
409
|
-
else if (fileExt === '.m4a') {
|
|
410
|
-
mimeType = 'audio/mp4';
|
|
411
|
-
}
|
|
391
|
+
const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
|
|
412
392
|
result = await this.ai.models.generateContent({
|
|
413
393
|
model: this.flashModel,
|
|
414
394
|
contents: [
|
|
@@ -484,8 +464,7 @@ IMPORTANT:
|
|
|
484
464
|
console.log(`Starting transcription for segment ${segmentIndex + 1}/${totalSegments} (attempt ${attempt}/${maxRetries})...`);
|
|
485
465
|
const audioData = fs.readFileSync(segmentFile);
|
|
486
466
|
const base64Audio = audioData.toString('base64');
|
|
487
|
-
const
|
|
488
|
-
const mimeType = fileExt === '.mp3' ? 'audio/mp3' : 'audio/wav';
|
|
467
|
+
const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(segmentFile));
|
|
489
468
|
const result = await this.ai.models.generateContent({
|
|
490
469
|
model: this.flashModel,
|
|
491
470
|
contents: [
|
package/dist/main.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
const electron_1 = require("electron");
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const util_1 = require("util");
|
|
37
39
|
const path = __importStar(require("path"));
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const simpleAudioRecorder_1 = require("./simpleAudioRecorder");
|
|
@@ -52,6 +54,7 @@ const releaseNotesService_1 = require("./services/releaseNotesService");
|
|
|
52
54
|
const outputService_1 = require("./outputService");
|
|
53
55
|
const searchService_1 = require("./searchService");
|
|
54
56
|
const agentService_1 = require("./agentService");
|
|
57
|
+
const audioFormats_1 = require("./audioFormats");
|
|
55
58
|
global.isQuitting = false;
|
|
56
59
|
let mainWindow = null;
|
|
57
60
|
const audioRecorder = new simpleAudioRecorder_1.SimpleAudioRecorder();
|
|
@@ -261,8 +264,6 @@ function createWindow() {
|
|
|
261
264
|
});
|
|
262
265
|
}
|
|
263
266
|
electron_1.app.whenReady().then(() => {
|
|
264
|
-
// Initialize auto-updater
|
|
265
|
-
autoUpdaterService_1.autoUpdaterService.checkForUpdates();
|
|
266
267
|
// Create menu with DevTools option
|
|
267
268
|
const template = [
|
|
268
269
|
{
|
|
@@ -364,6 +365,9 @@ electron_1.app.whenReady().then(() => {
|
|
|
364
365
|
autoUpdaterService_1.autoUpdaterService.setMainWindow(mainWindow);
|
|
365
366
|
notificationService_1.notificationService.setMainWindow(mainWindow);
|
|
366
367
|
}
|
|
368
|
+
// Kick off initial + periodic update checks once the renderer can receive IPC.
|
|
369
|
+
autoUpdaterService_1.autoUpdaterService.checkForUpdates();
|
|
370
|
+
autoUpdaterService_1.autoUpdaterService.startPeriodicCheck();
|
|
367
371
|
// Show release notes on first launch after an update.
|
|
368
372
|
checkAndShowReleaseNotes().catch((err) => {
|
|
369
373
|
console.error('Release notes check failed:', err);
|
|
@@ -433,6 +437,7 @@ electron_1.app.on('window-all-closed', () => {
|
|
|
433
437
|
electron_1.app.on('before-quit', () => {
|
|
434
438
|
meetingDetector.stop();
|
|
435
439
|
displayDetector.stop();
|
|
440
|
+
autoUpdaterService_1.autoUpdaterService.stopPeriodicCheck();
|
|
436
441
|
global.isQuitting = true;
|
|
437
442
|
});
|
|
438
443
|
// Unregister all shortcuts when app quits
|
|
@@ -501,46 +506,115 @@ electron_1.ipcMain.handle('cancel-ffmpeg-download', async () => {
|
|
|
501
506
|
ffmpegManager.cancelDownload();
|
|
502
507
|
return { success: true };
|
|
503
508
|
});
|
|
509
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
510
|
+
// Returns `code: 'ffmpeg-missing'` so the renderer can route users into the
|
|
511
|
+
// existing ffmpeg-download UI (triggered by transcription) rather than
|
|
512
|
+
// duplicating that flow here.
|
|
513
|
+
electron_1.ipcMain.handle('export-recording-m4a', async (_, srcPath) => {
|
|
514
|
+
try {
|
|
515
|
+
if (!srcPath || typeof srcPath !== 'string') {
|
|
516
|
+
return { success: false, error: 'Invalid source path' };
|
|
517
|
+
}
|
|
518
|
+
// Containment: the renderer is trusted today, but bound srcPath to the
|
|
519
|
+
// recordings directory so a future renderer bug can't transcode arbitrary
|
|
520
|
+
// local files. realpath resolves symlinks before the prefix check.
|
|
521
|
+
const recordingsDir = path.join(electron_1.app.getPath('userData'), 'recordings');
|
|
522
|
+
let resolvedSrc;
|
|
523
|
+
try {
|
|
524
|
+
resolvedSrc = await fs.promises.realpath(srcPath);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return { success: false, error: 'Source recording not found' };
|
|
528
|
+
}
|
|
529
|
+
const resolvedRoot = await fs.promises.realpath(recordingsDir).catch(() => recordingsDir);
|
|
530
|
+
if (!resolvedSrc.startsWith(resolvedRoot + path.sep)) {
|
|
531
|
+
return { success: false, error: 'Source path is outside the recordings directory' };
|
|
532
|
+
}
|
|
533
|
+
const ffmpegPath = await ffmpegManager.ensureFFmpeg();
|
|
534
|
+
if (!ffmpegPath) {
|
|
535
|
+
return { success: false, code: 'ffmpeg-missing', error: 'FFmpeg is required for M4A export.' };
|
|
536
|
+
}
|
|
537
|
+
const baseName = path.basename(resolvedSrc, path.extname(resolvedSrc));
|
|
538
|
+
const dialogOptions = {
|
|
539
|
+
title: 'Export recording as M4A',
|
|
540
|
+
defaultPath: `${baseName}.m4a`,
|
|
541
|
+
filters: [{ name: 'M4A Audio', extensions: ['m4a'] }],
|
|
542
|
+
};
|
|
543
|
+
const saveResult = mainWindow
|
|
544
|
+
? await electron_1.dialog.showSaveDialog(mainWindow, dialogOptions)
|
|
545
|
+
: await electron_1.dialog.showSaveDialog(dialogOptions);
|
|
546
|
+
if (saveResult.canceled || !saveResult.filePath) {
|
|
547
|
+
return { canceled: true };
|
|
548
|
+
}
|
|
549
|
+
const destPath = saveResult.filePath;
|
|
550
|
+
// Write to a sibling temp file and atomically rename on success so a
|
|
551
|
+
// failed encode never overwrites the user's picked path with a partial.
|
|
552
|
+
const tmpPath = `${destPath}.partial`;
|
|
553
|
+
try {
|
|
554
|
+
// Force -f ipod (M4A muxer) because the `.partial` extension defeats
|
|
555
|
+
// ffmpeg's format-by-extension detection otherwise.
|
|
556
|
+
await execFileAsync(ffmpegPath, [
|
|
557
|
+
'-y', '-loglevel', 'error',
|
|
558
|
+
'-i', resolvedSrc,
|
|
559
|
+
'-vn', '-c:a', 'aac', '-b:a', '128k',
|
|
560
|
+
'-movflags', '+faststart',
|
|
561
|
+
'-f', 'ipod',
|
|
562
|
+
tmpPath,
|
|
563
|
+
]);
|
|
564
|
+
await fs.promises.rename(tmpPath, destPath);
|
|
565
|
+
}
|
|
566
|
+
catch (encodeError) {
|
|
567
|
+
await fs.promises.unlink(tmpPath).catch(() => { });
|
|
568
|
+
throw encodeError;
|
|
569
|
+
}
|
|
570
|
+
return { success: true, path: destPath };
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
console.error('Error exporting M4A:', error);
|
|
574
|
+
// execFileAsync rejections carry stderr on the error object — surface it
|
|
575
|
+
// so renderer-side toasts aren't reduced to "Command failed".
|
|
576
|
+
const stderr = error?.stderr;
|
|
577
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
578
|
+
const message = stderr ? `${baseMessage.split('\n')[0]} — ${stderr.trim()}` : baseMessage;
|
|
579
|
+
return { success: false, error: message };
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
// Audio capture runs in the renderer via MediaRecorder; main only tracks state,
|
|
583
|
+
// runs max/reminder timers, updates the menu bar, and writes the final blob to disk.
|
|
504
584
|
electron_1.ipcMain.handle('start-recording', async (_, meetingTitle) => {
|
|
505
585
|
try {
|
|
506
|
-
const result =
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
elapsed += reminderMinutes;
|
|
539
|
-
if (audioRecorder.isRecording()) {
|
|
540
|
-
notificationService_1.notificationService.notifyRecordingReminder(elapsed);
|
|
541
|
-
}
|
|
542
|
-
}, reminderMinutes * 60 * 1000);
|
|
543
|
-
}
|
|
586
|
+
const result = audioRecorder.startRecording(meetingTitle);
|
|
587
|
+
if (!result.success)
|
|
588
|
+
return result;
|
|
589
|
+
menuBarManager.updateRecordingState(true, meetingTitle);
|
|
590
|
+
const maxMinutes = configService.getMaxRecordingMinutes();
|
|
591
|
+
if (maxMinutes > 0) {
|
|
592
|
+
recordingMaxTimer = setTimeout(() => {
|
|
593
|
+
clearRecordingTimers();
|
|
594
|
+
if (!audioRecorder.isRecording())
|
|
595
|
+
return;
|
|
596
|
+
notificationService_1.notificationService.notifyRecordingAutoStopped(maxMinutes);
|
|
597
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
598
|
+
// Renderer owns MediaRecorder — it will stop, save, and re-enter stop-recording via IPC.
|
|
599
|
+
mainWindow.webContents.send('recording-auto-stopped', { reason: 'maxDuration', maxMinutes });
|
|
600
|
+
}
|
|
601
|
+
// Always reset main-side state so an unresponsive or hung renderer can't leave the
|
|
602
|
+
// isRecording() flag stuck and block future recordings. stopRecording() is idempotent,
|
|
603
|
+
// so the renderer's own stop-recording IPC (if it runs) is a safe no-op here.
|
|
604
|
+
audioRecorder.stopRecording();
|
|
605
|
+
menuBarManager.updateRecordingState(false);
|
|
606
|
+
meetingAutoStartedRecording = false;
|
|
607
|
+
}, maxMinutes * 60 * 1000);
|
|
608
|
+
}
|
|
609
|
+
const reminderMinutes = configService.getRecordingReminderMinutes();
|
|
610
|
+
if (reminderMinutes > 0) {
|
|
611
|
+
let elapsed = 0;
|
|
612
|
+
recordingReminderTimer = setInterval(() => {
|
|
613
|
+
elapsed += reminderMinutes;
|
|
614
|
+
if (audioRecorder.isRecording()) {
|
|
615
|
+
notificationService_1.notificationService.notifyRecordingReminder(elapsed);
|
|
616
|
+
}
|
|
617
|
+
}, reminderMinutes * 60 * 1000);
|
|
544
618
|
}
|
|
545
619
|
return result;
|
|
546
620
|
}
|
|
@@ -553,17 +627,31 @@ electron_1.ipcMain.handle('start-recording', async (_, meetingTitle) => {
|
|
|
553
627
|
electron_1.ipcMain.handle('stop-recording', async () => {
|
|
554
628
|
try {
|
|
555
629
|
clearRecordingTimers();
|
|
556
|
-
const result =
|
|
557
|
-
// Update menu bar icon state
|
|
630
|
+
const result = audioRecorder.stopRecording();
|
|
558
631
|
menuBarManager.updateRecordingState(false);
|
|
559
632
|
meetingAutoStartedRecording = false;
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
console.error('Error stopping recording:', error);
|
|
637
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
// Receives the encoded audio blob from the renderer after MediaRecorder finalizes.
|
|
641
|
+
// The "stopped" notification only fires once the file is actually on disk — otherwise
|
|
642
|
+
// a failed write would still show a success toast.
|
|
643
|
+
electron_1.ipcMain.handle('save-recording', async (_, payload) => {
|
|
644
|
+
try {
|
|
645
|
+
const buf = Buffer.from(payload.data);
|
|
646
|
+
const extension = (0, audioFormats_1.extensionForMimeType)(payload.mimeType);
|
|
647
|
+
const result = await audioRecorder.saveRecording(payload.title, buf, extension, payload.durationMs);
|
|
560
648
|
if (result.success) {
|
|
561
649
|
notificationService_1.notificationService.notifyRecordingStopped();
|
|
562
650
|
}
|
|
563
651
|
return result;
|
|
564
652
|
}
|
|
565
653
|
catch (error) {
|
|
566
|
-
console.error('Error
|
|
654
|
+
console.error('Error saving recording:', error);
|
|
567
655
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
568
656
|
}
|
|
569
657
|
});
|
|
@@ -623,6 +711,19 @@ electron_1.ipcMain.handle('get-all-releases', async () => {
|
|
|
623
711
|
console.log(`Release list IPC: fetched ${results.length} releases`);
|
|
624
712
|
return results;
|
|
625
713
|
});
|
|
714
|
+
electron_1.ipcMain.handle('update:get-state', async () => {
|
|
715
|
+
return autoUpdaterService_1.autoUpdaterService.getUpdateState();
|
|
716
|
+
});
|
|
717
|
+
electron_1.ipcMain.handle('update:download', async () => {
|
|
718
|
+
autoUpdaterService_1.autoUpdaterService.downloadUpdate();
|
|
719
|
+
});
|
|
720
|
+
electron_1.ipcMain.handle('update:install', async () => {
|
|
721
|
+
autoUpdaterService_1.autoUpdaterService.quitAndInstall();
|
|
722
|
+
});
|
|
723
|
+
// Dev-only: drive the badge state machine manually from DevTools.
|
|
724
|
+
electron_1.ipcMain.handle('update:simulate', async (_, event, data) => {
|
|
725
|
+
autoUpdaterService_1.autoUpdaterService.simulateUpdateEvent(event, data);
|
|
726
|
+
});
|
|
626
727
|
electron_1.ipcMain.handle('check-config', async () => {
|
|
627
728
|
return {
|
|
628
729
|
hasConfig: configService.hasRequiredConfig(),
|
|
@@ -790,7 +891,7 @@ electron_1.ipcMain.handle('get-metadata', async (_, filePath) => {
|
|
|
790
891
|
return { success: true, data: null };
|
|
791
892
|
// New format: read from transcription folder
|
|
792
893
|
if (metadata.transcriptionPath) {
|
|
793
|
-
const transcription = (0, outputService_1.readTranscription)(metadata.transcriptionPath);
|
|
894
|
+
const transcription = await (0, outputService_1.readTranscription)(metadata.transcriptionPath);
|
|
794
895
|
if (transcription) {
|
|
795
896
|
return {
|
|
796
897
|
success: true,
|
|
@@ -834,6 +935,20 @@ electron_1.ipcMain.handle('open-recordings-folder', async () => {
|
|
|
834
935
|
// Open the folder in the system file explorer
|
|
835
936
|
electron_1.shell.openPath(recordingsPath);
|
|
836
937
|
});
|
|
938
|
+
// Reveal a specific recording in Finder/Explorer. This gives the user a
|
|
939
|
+
// one-click path to native OS share tools (right-click -> Share -> AirDrop,
|
|
940
|
+
// Messages, Mail, KakaoTalk, etc.) without us having to integrate each target.
|
|
941
|
+
electron_1.ipcMain.handle('show-in-finder', (_, filePath) => {
|
|
942
|
+
if (!filePath)
|
|
943
|
+
return { success: false, error: 'No file path provided' };
|
|
944
|
+
try {
|
|
945
|
+
electron_1.shell.showItemInFolder(filePath);
|
|
946
|
+
return { success: true };
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
950
|
+
}
|
|
951
|
+
});
|
|
837
952
|
// Search past transcriptions
|
|
838
953
|
electron_1.ipcMain.handle('search-transcriptions', async (_, opts) => {
|
|
839
954
|
try {
|
|
@@ -847,7 +962,7 @@ electron_1.ipcMain.handle('search-transcriptions', async (_, opts) => {
|
|
|
847
962
|
const fields = filtered.length > 0 ? filtered : searchService_1.ALL_FIELDS;
|
|
848
963
|
const limit = Number.isFinite(opts.limit) && opts.limit >= 0 ? opts.limit : 20;
|
|
849
964
|
const dataPath = electron_1.app.getPath('userData');
|
|
850
|
-
const raw = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
|
|
965
|
+
const raw = await (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
|
|
851
966
|
const hits = raw.map((h) => ({
|
|
852
967
|
folderName: h.entry.folderName,
|
|
853
968
|
folderPath: h.entry.folderPath,
|
|
@@ -891,7 +1006,7 @@ electron_1.ipcMain.handle('get-recordings', async () => {
|
|
|
891
1006
|
// Filter out segment files
|
|
892
1007
|
if (file.includes('_segment_'))
|
|
893
1008
|
return false;
|
|
894
|
-
return
|
|
1009
|
+
return (0, audioFormats_1.isSupportedAudioExtension)(path.extname(file));
|
|
895
1010
|
})
|
|
896
1011
|
.map((file) => {
|
|
897
1012
|
const filePath = path.join(recordingsDir, file);
|
package/dist/outputService.js
CHANGED
|
@@ -239,25 +239,39 @@ function saveTranscription(opts) {
|
|
|
239
239
|
* List transcription folders sorted by most-recent-first.
|
|
240
240
|
* Performs a lightweight line scan of each summary.md to extract only title and transcribedAt.
|
|
241
241
|
*/
|
|
242
|
-
function listTranscriptions(dataPath, limit) {
|
|
242
|
+
async function listTranscriptions(dataPath, limit) {
|
|
243
243
|
const dir = getTranscriptionsDir(dataPath);
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
let dirents;
|
|
245
|
+
try {
|
|
246
|
+
dirents = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (err.code === 'ENOENT')
|
|
250
|
+
return [];
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
246
253
|
const entries = [];
|
|
247
|
-
for (const
|
|
248
|
-
|
|
249
|
-
if (!fs.statSync(folderPath).isDirectory())
|
|
254
|
+
for (const dirent of dirents) {
|
|
255
|
+
if (!dirent.isDirectory())
|
|
250
256
|
continue;
|
|
257
|
+
const name = dirent.name;
|
|
258
|
+
const folderPath = path.join(dir, name);
|
|
251
259
|
const summaryPath = path.join(folderPath, 'summary.md');
|
|
252
|
-
if (!fs.existsSync(summaryPath))
|
|
253
|
-
continue;
|
|
254
260
|
let title = name;
|
|
255
261
|
let transcribedAt = '';
|
|
256
262
|
// Lightweight line scan -- read only frontmatter, not the full file
|
|
257
|
-
|
|
263
|
+
let fileHandle;
|
|
264
|
+
try {
|
|
265
|
+
fileHandle = await fs.promises.open(summaryPath, 'r');
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
if (err.code === 'ENOENT')
|
|
269
|
+
continue;
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
258
272
|
try {
|
|
259
273
|
const buf = Buffer.alloc(2048);
|
|
260
|
-
const bytesRead =
|
|
274
|
+
const { bytesRead } = await fileHandle.read(buf, 0, 2048, 0);
|
|
261
275
|
const head = buf.toString('utf-8', 0, bytesRead);
|
|
262
276
|
for (const line of head.split('\n')) {
|
|
263
277
|
const titleMatch = line.match(/^title:\s*(.+)$/);
|
|
@@ -272,7 +286,7 @@ function listTranscriptions(dataPath, limit) {
|
|
|
272
286
|
}
|
|
273
287
|
}
|
|
274
288
|
finally {
|
|
275
|
-
|
|
289
|
+
await fileHandle.close();
|
|
276
290
|
}
|
|
277
291
|
// Fall back to folder name timestamp suffix (e.g. _20260219_143000)
|
|
278
292
|
if (!transcribedAt) {
|
|
@@ -292,12 +306,10 @@ function listTranscriptions(dataPath, limit) {
|
|
|
292
306
|
* Read transcription data from a transcription folder.
|
|
293
307
|
* Returns raw data from frontmatter (machine-readable), not the markdown body.
|
|
294
308
|
*/
|
|
295
|
-
function readTranscription(folderPath) {
|
|
309
|
+
async function readTranscription(folderPath) {
|
|
296
310
|
try {
|
|
297
311
|
const summaryPath = path.join(folderPath, 'summary.md');
|
|
298
|
-
|
|
299
|
-
return null;
|
|
300
|
-
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
|
|
312
|
+
const summaryContent = await fs.promises.readFile(summaryPath, 'utf-8');
|
|
301
313
|
const { meta } = parseFrontmatter(summaryContent);
|
|
302
314
|
// Parse customFields from frontmatter (stored as JSON string)
|
|
303
315
|
let customFields;
|
package/dist/searchService.js
CHANGED
|
@@ -122,14 +122,14 @@ function resolveFields(opts) {
|
|
|
122
122
|
return opts.includeTranscript ? [...exports.ALL_FIELDS] : [...exports.DEFAULT_FIELDS];
|
|
123
123
|
}
|
|
124
124
|
/** Run a search against the local transcriptions archive. */
|
|
125
|
-
function searchTranscriptions(dataPath, opts) {
|
|
126
|
-
const entries = (0, outputService_1.listTranscriptions)(dataPath, 0);
|
|
125
|
+
async function searchTranscriptions(dataPath, opts) {
|
|
126
|
+
const entries = await (0, outputService_1.listTranscriptions)(dataPath, 0);
|
|
127
127
|
const fields = opts.fields ?? exports.DEFAULT_FIELDS;
|
|
128
128
|
const needle = opts.query.toLowerCase();
|
|
129
129
|
const scope = new Set(fields);
|
|
130
130
|
const hits = [];
|
|
131
131
|
for (const entry of entries) {
|
|
132
|
-
const data = (0, outputService_1.readTranscription)(entry.folderPath);
|
|
132
|
+
const data = await (0, outputService_1.readTranscription)(entry.folderPath);
|
|
133
133
|
if (!data)
|
|
134
134
|
continue;
|
|
135
135
|
const hit = scoreRecordPrepared(entry, data, needle, scope);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "listener-ai",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "A lightweight desktop application for recording and transcribing meetings with AI-powered notes.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/fs-extra": "^11.0.4",
|
|
49
49
|
"@types/node": "^24.0.12",
|
|
50
50
|
"dotenv": "^17.2.0",
|
|
51
|
-
"electron": "^
|
|
51
|
+
"electron": "^39.8.5",
|
|
52
52
|
"electron-builder": "^26.8.1",
|
|
53
53
|
"semver": "^7.7.2",
|
|
54
54
|
"typescript": "^5.8.3"
|