listener-ai 2.4.0 → 2.5.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/README.md CHANGED
@@ -41,8 +41,13 @@ listener config set notionDatabaseId <your-id>
41
41
  ```bash
42
42
  listener recording.mp3 # Transcribe to default output dir
43
43
  listener recording.m4a --output ./ # Transcribe to current directory
44
- listener config list # Show all config values
44
+ listener config list # Show all config values (secrets masked)
45
+ listener config get <key> # Print one config value
46
+ listener config set <key> <value> # Set a config value
47
+ listener config unset <key> # Clear a config value (falls back to default)
45
48
  listener config path # Print config file path
49
+ listener --version # Print CLI version
50
+ listener --help # Show usage
46
51
  ```
47
52
 
48
53
  Supported formats: mp3, m4a, wav, ogg, flac, aac, wma, opus, webm
package/dist/cli.js CHANGED
@@ -57,32 +57,49 @@ const SUPPORTED_EXTENSIONS = new Set([
57
57
  '.opus',
58
58
  '.webm',
59
59
  ]);
60
- function usage() {
61
- process.stderr.write('Usage: listener <file> [--output <dir>] Transcribe an audio file\n' +
62
- ' listener list [--limit <n>] List past transcriptions\n' +
63
- ' listener show <ref> Print summary to stdout\n' +
64
- ' listener export <ref> [<path>] [--json] [--transcript]\n' +
65
- ' Export transcription\n' +
66
- ' listener search <query> [--limit <n>] [--transcript] [--field <name>]\n' +
67
- ' Search past transcriptions\n' +
68
- ' listener merge <ref1> <ref2> [<ref3>...] [--title <t>]\n' +
69
- ' Concat the source audio of two or more notes,\n' +
70
- ' re-transcribe end-to-end, and save as a new note\n' +
71
- ' listener ask <question> [--ref <ref>]\n' +
72
- ' Ask the AI agent about saved meetings or settings\n' +
73
- ' listener config list|get|set|path Manage configuration\n' +
74
- '\n' +
75
- '<ref> is a number from `listener list` or a folder name.\n' +
76
- '\n' +
77
- 'Options:\n' +
78
- ' --output <dir> Parent directory for the output folder\n' +
79
- ' --limit <n> Max results (0 = all, default: 20)\n' +
80
- ' --json Export as JSON instead of markdown\n' +
81
- ' --transcript Include transcript body (export: append; search: widen scope)\n' +
82
- ' --field <name> Restrict search to one of: title, summary, keyPoints, actionItems, transcript, all\n' +
83
- ' --help Show this help message\n');
60
+ const VERSION = (() => {
61
+ try {
62
+ const pkgPath = path.join(__dirname, '..', 'package.json');
63
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
64
+ return pkg.version ?? 'unknown';
65
+ }
66
+ catch {
67
+ return 'unknown';
68
+ }
69
+ })();
70
+ const USAGE_TEXT = 'Usage: listener <file> [--output <dir>] Transcribe an audio file\n' +
71
+ ' listener list [--limit <n>] List past transcriptions\n' +
72
+ ' listener show <ref> Print summary to stdout\n' +
73
+ ' listener export <ref> [<path>] [--json] [--transcript]\n' +
74
+ ' Export transcription\n' +
75
+ ' listener search <query> [--limit <n>] [--transcript] [--field <name>]\n' +
76
+ ' Search past transcriptions\n' +
77
+ ' listener merge <ref1> <ref2> [<ref3>...] [--title <t>]\n' +
78
+ ' Concat the source audio of two or more notes,\n' +
79
+ ' re-transcribe end-to-end, and save as a new note\n' +
80
+ ' listener ask <question> [--ref <ref>]\n' +
81
+ ' Ask the AI agent about saved meetings or settings\n' +
82
+ ' listener config list|get|set|unset|path\n' +
83
+ ' Manage configuration\n' +
84
+ '\n' +
85
+ '<ref> is a number from `listener list` or a folder name.\n' +
86
+ '\n' +
87
+ 'Options:\n' +
88
+ ' --output <dir> Parent directory for the output folder\n' +
89
+ ' --limit <n> Max results (0 = all, default: 20)\n' +
90
+ ' --json Export as JSON instead of markdown\n' +
91
+ ' --transcript Include transcript body (export: append; search: widen scope)\n' +
92
+ ' --field <name> Restrict search to one of: title, summary, keyPoints, actionItems, transcript, all\n' +
93
+ ' --version, -V Print CLI version\n' +
94
+ ' --help, -h Show this help message\n';
95
+ function usageError() {
96
+ process.stderr.write(USAGE_TEXT);
84
97
  process.exit(1);
85
98
  }
99
+ function showHelp() {
100
+ process.stdout.write(USAGE_TEXT);
101
+ process.exit(0);
102
+ }
86
103
  const KNOWN_CONFIG_KEYS = [
87
104
  'geminiApiKey',
88
105
  'geminiModel',
@@ -90,23 +107,124 @@ const KNOWN_CONFIG_KEYS = [
90
107
  'notionApiKey',
91
108
  'notionDatabaseId',
92
109
  'autoMode',
110
+ 'meetingDetection',
111
+ 'displayDetection',
93
112
  'globalShortcut',
113
+ 'knownWords',
114
+ 'summaryPrompt',
115
+ 'maxRecordingMinutes',
116
+ 'recordingReminderMinutes',
94
117
  'minRecordingSeconds',
118
+ 'recordSystemAudio',
119
+ 'slackWebhookUrl',
120
+ 'slackAutoShare',
95
121
  ];
122
+ function isSensitiveKey(key) {
123
+ const lk = key.toLowerCase();
124
+ return lk.includes('key') || lk.includes('webhook');
125
+ }
96
126
  function maskValue(key, value) {
97
127
  if (value == null || value === '')
98
128
  return '(not set)';
99
- if (key.toLowerCase().includes('key')) {
100
- return value.length > 4 ? `****${value.slice(-4)}` : '****';
129
+ if (Array.isArray(value)) {
130
+ if (value.length === 0)
131
+ return '(none)';
132
+ const joined = value.map((x) => String(x)).join(', ');
133
+ return joined.length > 60 ? `${joined.slice(0, 57)}...` : joined;
134
+ }
135
+ const str = String(value);
136
+ if (isSensitiveKey(key)) {
137
+ return str.length > 4 ? `****${str.slice(-4)}` : '****';
138
+ }
139
+ if (str.length > 60)
140
+ return `${str.slice(0, 57)}...`;
141
+ return str;
142
+ }
143
+ function parseBool(key, v) {
144
+ if (v !== 'true' && v !== 'false') {
145
+ process.stderr.write(`Error: ${key} must be "true" or "false"\n`);
146
+ process.exit(1);
147
+ }
148
+ return v === 'true';
149
+ }
150
+ function parseNonNegInt(key, v) {
151
+ const n = Number.parseInt(v, 10);
152
+ if (Number.isNaN(n) || n < 0 || String(n) !== v.trim()) {
153
+ process.stderr.write(`Error: ${key} must be a non-negative integer\n`);
154
+ process.exit(1);
155
+ }
156
+ return n;
157
+ }
158
+ function parseKnownWords(v) {
159
+ return v
160
+ .split(',')
161
+ .map((s) => s.trim())
162
+ .filter((s) => s.length > 0);
163
+ }
164
+ function applyConfigSet(config, key, value) {
165
+ switch (key) {
166
+ case 'geminiApiKey':
167
+ config.setGeminiApiKey(value);
168
+ return;
169
+ case 'geminiModel':
170
+ config.setGeminiModel(value);
171
+ return;
172
+ case 'geminiFlashModel':
173
+ config.setGeminiFlashModel(value);
174
+ return;
175
+ case 'notionApiKey':
176
+ config.setNotionApiKey(value);
177
+ return;
178
+ case 'notionDatabaseId':
179
+ config.setNotionDatabaseId(value);
180
+ return;
181
+ case 'autoMode':
182
+ config.setAutoMode(parseBool('autoMode', value));
183
+ return;
184
+ case 'meetingDetection':
185
+ config.updateConfig({ meetingDetection: parseBool('meetingDetection', value) });
186
+ return;
187
+ case 'displayDetection':
188
+ config.setDisplayDetection(parseBool('displayDetection', value));
189
+ return;
190
+ case 'globalShortcut':
191
+ config.setGlobalShortcut(value);
192
+ return;
193
+ case 'knownWords':
194
+ config.setKnownWords(parseKnownWords(value));
195
+ return;
196
+ case 'summaryPrompt':
197
+ config.setSummaryPrompt(value);
198
+ return;
199
+ case 'maxRecordingMinutes':
200
+ config.setMaxRecordingMinutes(parseNonNegInt('maxRecordingMinutes', value));
201
+ return;
202
+ case 'recordingReminderMinutes':
203
+ config.setRecordingReminderMinutes(parseNonNegInt('recordingReminderMinutes', value));
204
+ return;
205
+ case 'minRecordingSeconds':
206
+ config.setMinRecordingSeconds(parseNonNegInt('minRecordingSeconds', value));
207
+ return;
208
+ case 'recordSystemAudio':
209
+ config.setRecordSystemAudio(parseBool('recordSystemAudio', value));
210
+ return;
211
+ case 'slackWebhookUrl':
212
+ config.setSlackWebhookUrl(value);
213
+ return;
214
+ case 'slackAutoShare':
215
+ config.setSlackAutoShare(parseBool('slackAutoShare', value));
216
+ return;
101
217
  }
102
- return value;
103
218
  }
104
219
  function handleConfig(subArgs) {
105
220
  const dataPath = (0, dataPath_1.getDataPath)();
106
221
  const config = new configService_1.ConfigService(dataPath);
107
222
  const sub = subArgs[0];
108
- if (!sub || sub === '--help') {
109
- usage();
223
+ if (!sub) {
224
+ usageError();
225
+ }
226
+ if (sub === '--help' || sub === '-h') {
227
+ showHelp();
110
228
  }
111
229
  if (sub === 'path') {
112
230
  process.stdout.write(`${config.getConfigPath()}\n`);
@@ -116,8 +234,7 @@ function handleConfig(subArgs) {
116
234
  const all = config.getAllConfig();
117
235
  for (const key of KNOWN_CONFIG_KEYS) {
118
236
  const raw = all[key];
119
- const display = maskValue(key, raw == null ? undefined : String(raw));
120
- process.stdout.write(`${key}=${display}\n`);
237
+ process.stdout.write(`${key}=${maskValue(key, raw)}\n`);
121
238
  }
122
239
  return;
123
240
  }
@@ -134,7 +251,12 @@ function handleConfig(subArgs) {
134
251
  }
135
252
  const all = config.getAllConfig();
136
253
  const val = all[key];
137
- process.stdout.write(`${val ?? ''}\n`);
254
+ if (Array.isArray(val)) {
255
+ process.stdout.write(`${val.join(',')}\n`);
256
+ }
257
+ else {
258
+ process.stdout.write(`${val ?? ''}\n`);
259
+ }
138
260
  return;
139
261
  }
140
262
  if (sub === 'set') {
@@ -149,35 +271,27 @@ function handleConfig(subArgs) {
149
271
  process.stderr.write(`Known keys: ${KNOWN_CONFIG_KEYS.join(', ')}\n`);
150
272
  process.exit(1);
151
273
  }
152
- const setters = {
153
- geminiApiKey: (v) => config.setGeminiApiKey(v),
154
- geminiModel: (v) => config.setGeminiModel(v),
155
- geminiFlashModel: (v) => config.setGeminiFlashModel(v),
156
- notionApiKey: (v) => config.setNotionApiKey(v),
157
- notionDatabaseId: (v) => config.setNotionDatabaseId(v),
158
- autoMode: (v) => {
159
- if (v !== 'true' && v !== 'false') {
160
- process.stderr.write('Error: autoMode must be "true" or "false"\n');
161
- process.exit(1);
162
- }
163
- config.setAutoMode(v === 'true');
164
- },
165
- globalShortcut: (v) => config.setGlobalShortcut(v),
166
- minRecordingSeconds: (v) => {
167
- const n = Number.parseInt(v, 10);
168
- if (Number.isNaN(n) || n < 0 || String(n) !== v.trim()) {
169
- process.stderr.write('Error: minRecordingSeconds must be a non-negative integer (0 disables)\n');
170
- process.exit(1);
171
- }
172
- config.setMinRecordingSeconds(n);
173
- },
174
- };
175
- setters[key](value);
274
+ applyConfigSet(config, key, value);
176
275
  process.stderr.write(`Set ${key}\n`);
177
276
  return;
178
277
  }
278
+ if (sub === 'unset') {
279
+ const key = subArgs[1];
280
+ if (!key) {
281
+ process.stderr.write('Error: Missing key. Usage: listener config unset <key>\n');
282
+ process.exit(1);
283
+ }
284
+ if (!KNOWN_CONFIG_KEYS.includes(key)) {
285
+ process.stderr.write(`Error: Unknown key: ${key}\n`);
286
+ process.stderr.write(`Known keys: ${KNOWN_CONFIG_KEYS.join(', ')}\n`);
287
+ process.exit(1);
288
+ }
289
+ config.unsetKey(key);
290
+ process.stderr.write(`Unset ${key}\n`);
291
+ return;
292
+ }
179
293
  process.stderr.write(`Error: Unknown config command: ${sub}\n`);
180
- usage();
294
+ usageError();
181
295
  }
182
296
  async function resolveRef(ref, dataPath) {
183
297
  if (/^\d+$/.test(ref)) {
@@ -550,8 +664,15 @@ async function handleAsk(args) {
550
664
  }
551
665
  async function main() {
552
666
  const args = process.argv.slice(2);
553
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
554
- usage();
667
+ if (args.includes('--version') || args.includes('-V')) {
668
+ process.stdout.write(`listener ${VERSION}\n`);
669
+ return;
670
+ }
671
+ if (args.includes('--help') || args.includes('-h')) {
672
+ showHelp();
673
+ }
674
+ if (args.length === 0) {
675
+ usageError();
555
676
  }
556
677
  if (args[0] === 'config') {
557
678
  handleConfig(args.slice(1));
@@ -590,7 +711,7 @@ async function main() {
590
711
  }
591
712
  else if (args[i].startsWith('-')) {
592
713
  process.stderr.write(`Error: Unknown option: ${args[i]}\n`);
593
- usage();
714
+ usageError();
594
715
  }
595
716
  else {
596
717
  filePath = args[i];
@@ -598,7 +719,7 @@ async function main() {
598
719
  }
599
720
  if (!filePath) {
600
721
  process.stderr.write('Error: No audio file specified.\n');
601
- usage();
722
+ usageError();
602
723
  }
603
724
  // Resolve to absolute path
604
725
  filePath = path.resolve(filePath);
@@ -218,6 +218,20 @@ class ConfigService {
218
218
  this.config.summaryPrompt = prompt;
219
219
  this.saveConfig();
220
220
  }
221
+ getSlackWebhookUrl() {
222
+ return this.config.slackWebhookUrl || process.env.SLACK_WEBHOOK_URL;
223
+ }
224
+ setSlackWebhookUrl(url) {
225
+ this.config.slackWebhookUrl = url;
226
+ this.saveConfig();
227
+ }
228
+ getSlackAutoShare() {
229
+ return this.config.slackAutoShare || false;
230
+ }
231
+ setSlackAutoShare(enabled) {
232
+ this.config.slackAutoShare = enabled;
233
+ this.saveConfig();
234
+ }
221
235
  updateConfig(partial) {
222
236
  for (const [key, value] of Object.entries(partial)) {
223
237
  if (value !== undefined) {
@@ -226,6 +240,10 @@ class ConfigService {
226
240
  }
227
241
  this.saveConfig();
228
242
  }
243
+ unsetKey(key) {
244
+ delete this.config[key];
245
+ this.saveConfig();
246
+ }
229
247
  getAllConfig() {
230
248
  return {
231
249
  geminiApiKey: this.getGeminiApiKey(),
@@ -245,6 +263,8 @@ class ConfigService {
245
263
  recordSystemAudio: this.getRecordSystemAudio(),
246
264
  audioDeviceId: this.getAudioDeviceId(),
247
265
  lastSeenVersion: this.getLastSeenVersion(),
266
+ slackWebhookUrl: this.getSlackWebhookUrl(),
267
+ slackAutoShare: this.getSlackAutoShare(),
248
268
  };
249
269
  }
250
270
  }
package/dist/main.js CHANGED
@@ -58,6 +58,7 @@ const notificationService_1 = require("./services/notificationService");
58
58
  const releaseNotesService_1 = require("./services/releaseNotesService");
59
59
  const systemAudioService_1 = require("./services/systemAudioService");
60
60
  const simpleAudioRecorder_1 = require("./simpleAudioRecorder");
61
+ const slackService_1 = require("./slackService");
61
62
  // Enable macOS system-audio loopback capture so getDisplayMedia({audio: 'loopback'})
62
63
  // can pull Zoom/Meet participant audio alongside the mic.
63
64
  // MacSckSystemAudioLoopbackCapture -- ScreenCaptureKit path, macOS 13-14
@@ -80,6 +81,7 @@ const displayDetector = new displayDetectorService_1.DisplayDetectorService();
80
81
  let meetingAutoStartedRecording = false;
81
82
  let geminiService = null;
82
83
  let notionService = null;
84
+ let slackService = null;
83
85
  let agentService = null;
84
86
  function getAgentService() {
85
87
  if (agentService)
@@ -1083,6 +1085,28 @@ function applyConfigSideEffects(changed) {
1083
1085
  notionService = new notionService_1.NotionService({ apiKey, databaseId });
1084
1086
  }
1085
1087
  }
1088
+ if (changed.slackWebhookUrl !== undefined) {
1089
+ slackService = null; // re-init lazily on next send
1090
+ }
1091
+ }
1092
+ function getSlackService() {
1093
+ if (slackService)
1094
+ return slackService;
1095
+ const url = configService.getSlackWebhookUrl();
1096
+ if (!url || !(0, slackService_1.isLikelySlackWebhookUrl)(url))
1097
+ return null;
1098
+ slackService = new slackService_1.SlackService({ webhookUrl: url });
1099
+ return slackService;
1100
+ }
1101
+ // Defense in depth: a renderer-supplied transcriptionPath could otherwise be
1102
+ // any path on disk and let an XSS-compromised renderer overwrite arbitrary
1103
+ // summary.md files via updateTranscriptionStatus.
1104
+ function isContainedTranscriptionPath(folderPath) {
1105
+ if (!folderPath)
1106
+ return false;
1107
+ const root = (0, outputService_1.getTranscriptionsDir)(electron_1.app.getPath('userData'));
1108
+ const resolved = path.resolve(folderPath);
1109
+ return resolved === root || resolved.startsWith(root + path.sep);
1086
1110
  }
1087
1111
  // Tell the renderer the config has changed out-of-band so it can re-read and
1088
1112
  // re-render its UI state (toggle checkboxes etc.). Used by the agent flow.
@@ -1250,9 +1274,9 @@ electron_1.ipcMain.handle('transcribe-audio', async (_, filePath) => {
1250
1274
  await metadataService_1.metadataService.deleteMetadata(filePath);
1251
1275
  await metadataService_1.metadataService.saveMetadata(newFilePath, existingMetadata);
1252
1276
  }
1253
- return { success: true, data: result, newFilePath };
1277
+ return { success: true, data: result, newFilePath, transcriptionPath };
1254
1278
  }
1255
- return { success: true, data: result };
1279
+ return { success: true, data: result, transcriptionPath };
1256
1280
  }
1257
1281
  catch (error) {
1258
1282
  console.error('Error transcribing audio:', error);
@@ -1279,6 +1303,16 @@ electron_1.ipcMain.handle('upload-to-notion', async (_, data) => {
1279
1303
  // Add "by L.AI" to the title for distinction
1280
1304
  const titleWithSuffix = `${data.title} by L.AI`;
1281
1305
  const result = await notionService.createMeetingNote(titleWithSuffix, new Date(), data.transcriptionData, data.audioFilePath);
1306
+ if (result.success && result.url && isContainedTranscriptionPath(data.transcriptionPath)) {
1307
+ try {
1308
+ await (0, outputService_1.updateTranscriptionStatus)(data.transcriptionPath, {
1309
+ notionPageUrl: result.url,
1310
+ });
1311
+ }
1312
+ catch (error) {
1313
+ console.error('Failed to persist Notion URL to transcription:', error);
1314
+ }
1315
+ }
1282
1316
  notificationService_1.notificationService.notifyUploadComplete(data.title);
1283
1317
  return result;
1284
1318
  }
@@ -1288,6 +1322,72 @@ electron_1.ipcMain.handle('upload-to-notion', async (_, data) => {
1288
1322
  return { success: false, error: error instanceof Error ? error.message : String(error) };
1289
1323
  }
1290
1324
  });
1325
+ electron_1.ipcMain.handle('send-to-slack', async (_, data) => {
1326
+ try {
1327
+ console.log('Sending to Slack:', data.title);
1328
+ const service = getSlackService();
1329
+ if (!service) {
1330
+ return { success: false, error: 'Slack webhook URL is not configured' };
1331
+ }
1332
+ // For a historical resend, use the original meeting time from frontmatter
1333
+ // so the Slack message shows when the meeting actually happened, not now.
1334
+ let meetingDate = new Date();
1335
+ if (isContainedTranscriptionPath(data.transcriptionPath)) {
1336
+ const stored = await (0, outputService_1.readTranscription)(data.transcriptionPath).catch(() => null);
1337
+ if (stored?.transcribedAt) {
1338
+ const parsed = new Date(stored.transcribedAt);
1339
+ if (!Number.isNaN(parsed.getTime()))
1340
+ meetingDate = parsed;
1341
+ }
1342
+ }
1343
+ const result = await service.sendMeetingSummary({
1344
+ title: data.title,
1345
+ date: meetingDate,
1346
+ result: data.transcriptionData,
1347
+ notionUrl: data.notionUrl,
1348
+ notionError: data.notionError,
1349
+ });
1350
+ if (isContainedTranscriptionPath(data.transcriptionPath)) {
1351
+ try {
1352
+ // Preserve the previous successful slackSentAt on a failed resend;
1353
+ // only the error field reflects the new failure.
1354
+ await (0, outputService_1.updateTranscriptionStatus)(data.transcriptionPath, {
1355
+ ...(result.success ? { slackSentAt: result.sentAt } : {}),
1356
+ slackError: result.success ? null : result.error,
1357
+ });
1358
+ }
1359
+ catch (error) {
1360
+ console.error('Failed to persist Slack status to transcription:', error);
1361
+ }
1362
+ }
1363
+ return result;
1364
+ }
1365
+ catch (error) {
1366
+ console.error('Error sending to Slack:', error);
1367
+ const message = error instanceof Error ? error.message : String(error);
1368
+ return { success: false, error: message };
1369
+ }
1370
+ });
1371
+ electron_1.ipcMain.handle('test-slack-webhook', async (_, webhookUrl) => {
1372
+ try {
1373
+ const url = (webhookUrl ?? configService.getSlackWebhookUrl() ?? '').trim();
1374
+ if (!url) {
1375
+ return { success: false, error: 'Slack webhook URL is not provided' };
1376
+ }
1377
+ if (!(0, slackService_1.isLikelySlackWebhookUrl)(url)) {
1378
+ return {
1379
+ success: false,
1380
+ error: `URL must start with ${slackService_1.SLACK_WEBHOOK_PREFIX}`,
1381
+ };
1382
+ }
1383
+ const service = new slackService_1.SlackService({ webhookUrl: url });
1384
+ return await service.sendTestMessage();
1385
+ }
1386
+ catch (error) {
1387
+ const message = error instanceof Error ? error.message : String(error);
1388
+ return { success: false, error: message };
1389
+ }
1390
+ });
1291
1391
  // Open external URL
1292
1392
  electron_1.ipcMain.handle('open-external', async (_, url) => {
1293
1393
  electron_1.shell.openExternal(url);
@@ -1313,6 +1413,9 @@ electron_1.ipcMain.handle('get-metadata', async (_, filePath) => {
1313
1413
  actionItems: transcription.actionItems,
1314
1414
  customFields: transcription.customFields ?? metadata.customFields,
1315
1415
  emoji: transcription.emoji,
1416
+ notionPageUrl: transcription.notionPageUrl,
1417
+ slackSentAt: transcription.slackSentAt,
1418
+ slackError: transcription.slackError,
1316
1419
  },
1317
1420
  };
1318
1421
  }
@@ -43,6 +43,7 @@ exports.getTranscriptionsDir = getTranscriptionsDir;
43
43
  exports.saveTranscription = saveTranscription;
44
44
  exports.listTranscriptions = listTranscriptions;
45
45
  exports.readTranscription = readTranscription;
46
+ exports.updateTranscriptionStatus = updateTranscriptionStatus;
46
47
  const fs = __importStar(require("fs"));
47
48
  const path = __importStar(require("path"));
48
49
  function sanitizeForPath(name) {
@@ -156,6 +157,15 @@ function buildFrontmatter(meta) {
156
157
  for (const src of meta.mergedFrom)
157
158
  lines.push(` - ${yamlQuote(src)}`);
158
159
  }
160
+ if (meta.notionPageUrl) {
161
+ lines.push(`notionPageUrl: ${yamlQuote(meta.notionPageUrl)}`);
162
+ }
163
+ if (meta.slackSentAt) {
164
+ lines.push(`slackSentAt: ${yamlQuote(meta.slackSentAt)}`);
165
+ }
166
+ if (meta.slackError) {
167
+ lines.push(`slackError: ${yamlQuote(meta.slackError)}`);
168
+ }
159
169
  lines.push('---');
160
170
  return lines.join('\n');
161
171
  }
@@ -168,16 +178,16 @@ function yamlQuote(value) {
168
178
  }
169
179
  function parseFrontmatter(content) {
170
180
  // Normalize line endings
171
- content = content.replace(/\r\n/g, '\n');
172
- if (!content.startsWith('---\n')) {
173
- return { meta: {}, body: content };
181
+ const normalized = content.replace(/\r\n/g, '\n');
182
+ if (!normalized.startsWith('---\n')) {
183
+ return { meta: {}, body: normalized };
174
184
  }
175
- const end = content.indexOf('\n---', 4);
185
+ const end = normalized.indexOf('\n---', 4);
176
186
  if (end === -1) {
177
- return { meta: {}, body: content };
187
+ return { meta: {}, body: normalized };
178
188
  }
179
- const yamlBlock = content.slice(4, end);
180
- const body = content.slice(end + 4).trimStart();
189
+ const yamlBlock = normalized.slice(4, end);
190
+ const body = normalized.slice(end + 4).trimStart();
181
191
  const meta = {};
182
192
  let currentArrayKey = null;
183
193
  let currentArray = [];
@@ -357,9 +367,59 @@ async function readTranscription(folderPath) {
357
367
  transcribedAt: meta.transcribedAt,
358
368
  emoji: meta.emoji,
359
369
  mergedFrom: meta.mergedFrom,
370
+ notionPageUrl: meta.notionPageUrl,
371
+ slackSentAt: meta.slackSentAt,
372
+ slackError: meta.slackError,
360
373
  };
361
374
  }
362
375
  catch {
363
376
  return null;
364
377
  }
365
378
  }
379
+ /**
380
+ * Update tracking fields (Notion URL, Slack send status) in summary.md frontmatter
381
+ * without rewriting the markdown body. Pass `null` to clear a field, `undefined` to leave unchanged.
382
+ */
383
+ async function updateTranscriptionStatus(folderPath, updates) {
384
+ const summaryPath = path.join(folderPath, 'summary.md');
385
+ const content = await fs.promises.readFile(summaryPath, 'utf-8');
386
+ const { meta, body } = parseFrontmatter(content);
387
+ // Recover customFields shape (parseFrontmatter returns it as a JSON string)
388
+ let customFields;
389
+ if (meta.customFields) {
390
+ try {
391
+ customFields =
392
+ typeof meta.customFields === 'string'
393
+ ? JSON.parse(meta.customFields)
394
+ : meta.customFields;
395
+ }
396
+ catch {
397
+ customFields = undefined;
398
+ }
399
+ }
400
+ // Spread first so any unknown frontmatter keys (added by future writers, or
401
+ // by hand-edits) survive the round-trip; named fields override with proper
402
+ // typing and defaults.
403
+ const merged = {
404
+ ...meta,
405
+ title: meta.title || path.basename(folderPath),
406
+ summary: meta.summary || '',
407
+ transcript: meta.transcript || '',
408
+ transcribedAt: meta.transcribedAt || new Date().toISOString(),
409
+ customFields,
410
+ };
411
+ applyStatusUpdate(merged, 'notionPageUrl', updates.notionPageUrl);
412
+ applyStatusUpdate(merged, 'slackSentAt', updates.slackSentAt);
413
+ applyStatusUpdate(merged, 'slackError', updates.slackError);
414
+ const frontmatter = buildFrontmatter(merged);
415
+ await fs.promises.writeFile(summaryPath, `${frontmatter}\n\n${body}`, 'utf-8');
416
+ }
417
+ function applyStatusUpdate(meta, key, value) {
418
+ if (value === undefined)
419
+ return;
420
+ if (value === null || value === '') {
421
+ delete meta[key];
422
+ return;
423
+ }
424
+ meta[key] = value;
425
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listener-ai",
3
- "version": "2.4.0",
3
+ "version": "2.5.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": {
@@ -25,10 +25,11 @@
25
25
  "build:renderer": "vite build",
26
26
  "build:check-renderer": "tsc -p tsconfig.renderer.json",
27
27
  "dev:renderer": "vite",
28
- "lint": "biome check .",
29
- "lint:fix": "biome check --write .",
30
- "format": "biome format --write .",
31
- "test": "tsc && node --test dist/meetingDetectorService.test.js dist/searchService.test.js dist/agentService.test.js dist/simpleAudioRecorder.test.js dist/outputService.test.js dist/services/audioConcatService.test.js dist/cli.test.js",
28
+ "lint": "oxlint src renderer scripts",
29
+ "lint:fix": "oxlint --fix src renderer scripts",
30
+ "format": "oxfmt 'src/**/*.ts' 'renderer/**/*.ts' 'renderer/**/*.tsx' 'scripts/**/*.js' 'scripts/**/*.ts'",
31
+ "format:check": "oxfmt --check 'src/**/*.ts' 'renderer/**/*.ts' 'renderer/**/*.tsx' 'scripts/**/*.js' 'scripts/**/*.ts'",
32
+ "test": "tsc && node --test dist/meetingDetectorService.test.js dist/searchService.test.js dist/agentService.test.js dist/simpleAudioRecorder.test.js dist/outputService.test.js dist/slackService.test.js dist/services/audioConcatService.test.js dist/cli.test.js",
32
33
  "dist": "pnpm run build && electron-builder",
33
34
  "dist:mac": "pnpm run build && electron-builder --mac",
34
35
  "dist:mac-x64": "pnpm run build && electron-builder --mac --x64",
@@ -53,14 +54,15 @@
53
54
  },
54
55
  "homepage": "https://github.com/asleep-ai/listener-ai#readme",
55
56
  "devDependencies": {
56
- "@biomejs/biome": "^1.9.4",
57
57
  "@electron/notarize": "^3.0.1",
58
58
  "@types/fs-extra": "^11.0.4",
59
59
  "@types/node": "^24.0.12",
60
60
  "dotenv": "^17.2.0",
61
61
  "electron": "^39.8.5",
62
- "vite": "^5.4.10",
62
+ "vite": "^6.4.2",
63
63
  "electron-builder": "^26.8.1",
64
+ "oxfmt": "^0.48.0",
65
+ "oxlint": "^1.63.0",
64
66
  "semver": "^7.7.2",
65
67
  "typescript": "^5.8.3"
66
68
  },