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 +6 -1
- package/dist/cli.js +182 -61
- package/dist/configService.js +20 -0
- package/dist/main.js +105 -2
- package/dist/outputService.js +67 -7
- package/package.json +9 -7
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
100
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
+
usageError();
|
|
602
723
|
}
|
|
603
724
|
// Resolve to absolute path
|
|
604
725
|
filePath = path.resolve(filePath);
|
package/dist/configService.js
CHANGED
|
@@ -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
|
}
|
package/dist/outputService.js
CHANGED
|
@@ -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
|
-
|
|
172
|
-
if (!
|
|
173
|
-
return { meta: {}, body:
|
|
181
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
182
|
+
if (!normalized.startsWith('---\n')) {
|
|
183
|
+
return { meta: {}, body: normalized };
|
|
174
184
|
}
|
|
175
|
-
const end =
|
|
185
|
+
const end = normalized.indexOf('\n---', 4);
|
|
176
186
|
if (end === -1) {
|
|
177
|
-
return { meta: {}, body:
|
|
187
|
+
return { meta: {}, body: normalized };
|
|
178
188
|
}
|
|
179
|
-
const yamlBlock =
|
|
180
|
-
const body =
|
|
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.
|
|
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": "
|
|
29
|
-
"lint:fix": "
|
|
30
|
-
"format": "
|
|
31
|
-
"
|
|
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": "^
|
|
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
|
},
|