listener-ai 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,8 +41,18 @@ 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 transcript recording.wav # Print transcript to stdout (no summary)
45
+ listener transcript recording.wav -o out.txt
46
+ # Write transcript to a file
47
+ listener transcript recording.wav --prompt "Translate to English while transcribing"
48
+ # Override the default transcription instruction
49
+ listener config list # Show all config values (secrets masked)
50
+ listener config get <key> # Print one config value
51
+ listener config set <key> <value> # Set a config value
52
+ listener config unset <key> # Clear a config value (falls back to default)
45
53
  listener config path # Print config file path
54
+ listener --version # Print CLI version
55
+ listener --help # Show usage
46
56
  ```
47
57
 
48
58
  Supported formats: mp3, m4a, wav, ogg, flac, aac, wma, opus, webm
package/dist/cli.js CHANGED
@@ -57,32 +57,54 @@ 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 into a meeting note\n' +
71
+ ' listener transcript <file> [--output <path>] [--prompt <text>]\n' +
72
+ ' Transcribe to plain text only (no summary)\n' +
73
+ ' listener list [--limit <n>] List past transcriptions\n' +
74
+ ' listener show <ref> Print summary to stdout\n' +
75
+ ' listener export <ref> [<path>] [--json] [--transcript]\n' +
76
+ ' Export transcription\n' +
77
+ ' listener search <query> [--limit <n>] [--transcript] [--field <name>]\n' +
78
+ ' Search past transcriptions\n' +
79
+ ' listener merge <ref1> <ref2> [<ref3>...] [--title <t>]\n' +
80
+ ' Concat the source audio of two or more notes,\n' +
81
+ ' re-transcribe end-to-end, and save as a new note\n' +
82
+ ' listener ask <question> [--ref <ref>]\n' +
83
+ ' Ask the AI agent about saved meetings or settings\n' +
84
+ ' listener config list|get|set|unset|path\n' +
85
+ ' Manage configuration\n' +
86
+ '\n' +
87
+ '<ref> is a number from `listener list` or a folder name.\n' +
88
+ '\n' +
89
+ 'Options:\n' +
90
+ ' --output, -o <path>\n' +
91
+ ' Parent directory for the output folder (transcribe);\n' +
92
+ ' destination file or directory (transcript)\n' +
93
+ ' --prompt <text> Override the default transcription instruction (transcript)\n' +
94
+ ' --limit <n> Max results (0 = all, default: 20)\n' +
95
+ ' --json Export as JSON instead of markdown\n' +
96
+ ' --transcript Include transcript body (export: append; search: widen scope)\n' +
97
+ ' --field <name> Restrict search to one of: title, summary, keyPoints, actionItems, transcript, all\n' +
98
+ ' --version, -V Print CLI version\n' +
99
+ ' --help, -h Show this help message\n';
100
+ function usageError() {
101
+ process.stderr.write(USAGE_TEXT);
84
102
  process.exit(1);
85
103
  }
104
+ function showHelp() {
105
+ process.stdout.write(USAGE_TEXT);
106
+ process.exit(0);
107
+ }
86
108
  const KNOWN_CONFIG_KEYS = [
87
109
  'geminiApiKey',
88
110
  'geminiModel',
@@ -90,23 +112,124 @@ const KNOWN_CONFIG_KEYS = [
90
112
  'notionApiKey',
91
113
  'notionDatabaseId',
92
114
  'autoMode',
115
+ 'meetingDetection',
116
+ 'displayDetection',
93
117
  'globalShortcut',
118
+ 'knownWords',
119
+ 'summaryPrompt',
120
+ 'maxRecordingMinutes',
121
+ 'recordingReminderMinutes',
94
122
  'minRecordingSeconds',
123
+ 'recordSystemAudio',
124
+ 'slackWebhookUrl',
125
+ 'slackAutoShare',
95
126
  ];
127
+ function isSensitiveKey(key) {
128
+ const lk = key.toLowerCase();
129
+ return lk.includes('key') || lk.includes('webhook');
130
+ }
96
131
  function maskValue(key, value) {
97
132
  if (value == null || value === '')
98
133
  return '(not set)';
99
- if (key.toLowerCase().includes('key')) {
100
- return value.length > 4 ? `****${value.slice(-4)}` : '****';
134
+ if (Array.isArray(value)) {
135
+ if (value.length === 0)
136
+ return '(none)';
137
+ const joined = value.map((x) => String(x)).join(', ');
138
+ return joined.length > 60 ? `${joined.slice(0, 57)}...` : joined;
139
+ }
140
+ const str = String(value);
141
+ if (isSensitiveKey(key)) {
142
+ return str.length > 4 ? `****${str.slice(-4)}` : '****';
143
+ }
144
+ if (str.length > 60)
145
+ return `${str.slice(0, 57)}...`;
146
+ return str;
147
+ }
148
+ function parseBool(key, v) {
149
+ if (v !== 'true' && v !== 'false') {
150
+ process.stderr.write(`Error: ${key} must be "true" or "false"\n`);
151
+ process.exit(1);
152
+ }
153
+ return v === 'true';
154
+ }
155
+ function parseNonNegInt(key, v) {
156
+ const n = Number.parseInt(v, 10);
157
+ if (Number.isNaN(n) || n < 0 || String(n) !== v.trim()) {
158
+ process.stderr.write(`Error: ${key} must be a non-negative integer\n`);
159
+ process.exit(1);
160
+ }
161
+ return n;
162
+ }
163
+ function parseKnownWords(v) {
164
+ return v
165
+ .split(',')
166
+ .map((s) => s.trim())
167
+ .filter((s) => s.length > 0);
168
+ }
169
+ function applyConfigSet(config, key, value) {
170
+ switch (key) {
171
+ case 'geminiApiKey':
172
+ config.setGeminiApiKey(value);
173
+ return;
174
+ case 'geminiModel':
175
+ config.setGeminiModel(value);
176
+ return;
177
+ case 'geminiFlashModel':
178
+ config.setGeminiFlashModel(value);
179
+ return;
180
+ case 'notionApiKey':
181
+ config.setNotionApiKey(value);
182
+ return;
183
+ case 'notionDatabaseId':
184
+ config.setNotionDatabaseId(value);
185
+ return;
186
+ case 'autoMode':
187
+ config.setAutoMode(parseBool('autoMode', value));
188
+ return;
189
+ case 'meetingDetection':
190
+ config.updateConfig({ meetingDetection: parseBool('meetingDetection', value) });
191
+ return;
192
+ case 'displayDetection':
193
+ config.setDisplayDetection(parseBool('displayDetection', value));
194
+ return;
195
+ case 'globalShortcut':
196
+ config.setGlobalShortcut(value);
197
+ return;
198
+ case 'knownWords':
199
+ config.setKnownWords(parseKnownWords(value));
200
+ return;
201
+ case 'summaryPrompt':
202
+ config.setSummaryPrompt(value);
203
+ return;
204
+ case 'maxRecordingMinutes':
205
+ config.setMaxRecordingMinutes(parseNonNegInt('maxRecordingMinutes', value));
206
+ return;
207
+ case 'recordingReminderMinutes':
208
+ config.setRecordingReminderMinutes(parseNonNegInt('recordingReminderMinutes', value));
209
+ return;
210
+ case 'minRecordingSeconds':
211
+ config.setMinRecordingSeconds(parseNonNegInt('minRecordingSeconds', value));
212
+ return;
213
+ case 'recordSystemAudio':
214
+ config.setRecordSystemAudio(parseBool('recordSystemAudio', value));
215
+ return;
216
+ case 'slackWebhookUrl':
217
+ config.setSlackWebhookUrl(value);
218
+ return;
219
+ case 'slackAutoShare':
220
+ config.setSlackAutoShare(parseBool('slackAutoShare', value));
221
+ return;
101
222
  }
102
- return value;
103
223
  }
104
224
  function handleConfig(subArgs) {
105
225
  const dataPath = (0, dataPath_1.getDataPath)();
106
226
  const config = new configService_1.ConfigService(dataPath);
107
227
  const sub = subArgs[0];
108
- if (!sub || sub === '--help') {
109
- usage();
228
+ if (!sub) {
229
+ usageError();
230
+ }
231
+ if (sub === '--help' || sub === '-h') {
232
+ showHelp();
110
233
  }
111
234
  if (sub === 'path') {
112
235
  process.stdout.write(`${config.getConfigPath()}\n`);
@@ -116,8 +239,7 @@ function handleConfig(subArgs) {
116
239
  const all = config.getAllConfig();
117
240
  for (const key of KNOWN_CONFIG_KEYS) {
118
241
  const raw = all[key];
119
- const display = maskValue(key, raw == null ? undefined : String(raw));
120
- process.stdout.write(`${key}=${display}\n`);
242
+ process.stdout.write(`${key}=${maskValue(key, raw)}\n`);
121
243
  }
122
244
  return;
123
245
  }
@@ -134,7 +256,12 @@ function handleConfig(subArgs) {
134
256
  }
135
257
  const all = config.getAllConfig();
136
258
  const val = all[key];
137
- process.stdout.write(`${val ?? ''}\n`);
259
+ if (Array.isArray(val)) {
260
+ process.stdout.write(`${val.join(',')}\n`);
261
+ }
262
+ else {
263
+ process.stdout.write(`${val ?? ''}\n`);
264
+ }
138
265
  return;
139
266
  }
140
267
  if (sub === 'set') {
@@ -149,35 +276,27 @@ function handleConfig(subArgs) {
149
276
  process.stderr.write(`Known keys: ${KNOWN_CONFIG_KEYS.join(', ')}\n`);
150
277
  process.exit(1);
151
278
  }
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);
279
+ applyConfigSet(config, key, value);
176
280
  process.stderr.write(`Set ${key}\n`);
177
281
  return;
178
282
  }
283
+ if (sub === 'unset') {
284
+ const key = subArgs[1];
285
+ if (!key) {
286
+ process.stderr.write('Error: Missing key. Usage: listener config unset <key>\n');
287
+ process.exit(1);
288
+ }
289
+ if (!KNOWN_CONFIG_KEYS.includes(key)) {
290
+ process.stderr.write(`Error: Unknown key: ${key}\n`);
291
+ process.stderr.write(`Known keys: ${KNOWN_CONFIG_KEYS.join(', ')}\n`);
292
+ process.exit(1);
293
+ }
294
+ config.unsetKey(key);
295
+ process.stderr.write(`Unset ${key}\n`);
296
+ return;
297
+ }
179
298
  process.stderr.write(`Error: Unknown config command: ${sub}\n`);
180
- usage();
299
+ usageError();
181
300
  }
182
301
  async function resolveRef(ref, dataPath) {
183
302
  if (/^\d+$/.test(ref)) {
@@ -312,6 +431,8 @@ async function handleExport(args) {
312
431
  /* ignore */
313
432
  }
314
433
  }
434
+ const liveNotes = (0, outputService_1.parseLiveNotesField)(meta.liveNotes);
435
+ const highlights = (0, outputService_1.parseHighlightsField)(meta.highlights);
315
436
  const obj = {
316
437
  title: meta.title || '',
317
438
  transcribedAt: meta.transcribedAt || '',
@@ -319,6 +440,8 @@ async function handleExport(args) {
319
440
  keyPoints: meta.keyPoints || [],
320
441
  actionItems: meta.actionItems || [],
321
442
  customFields,
443
+ ...(liveNotes ? { liveNotes } : {}),
444
+ ...(highlights ? { highlights } : {}),
322
445
  };
323
446
  if (includeTranscript) {
324
447
  obj.transcript = meta.transcript || '';
@@ -548,10 +671,112 @@ async function handleAsk(args) {
548
671
  }
549
672
  }
550
673
  }
674
+ async function handleTranscript(args) {
675
+ let filePath;
676
+ let outputArg;
677
+ let promptText;
678
+ for (let i = 0; i < args.length; i++) {
679
+ const a = args[i];
680
+ if ((a === '--output' || a === '-o') && i + 1 < args.length) {
681
+ outputArg = args[++i];
682
+ continue;
683
+ }
684
+ if (a === '--prompt' && i + 1 < args.length) {
685
+ promptText = args[++i];
686
+ continue;
687
+ }
688
+ if (a.startsWith('-')) {
689
+ process.stderr.write(`Error: Unknown option: ${a}\n`);
690
+ process.exit(1);
691
+ }
692
+ if (filePath) {
693
+ process.stderr.write(`Error: Unexpected argument: ${a}\n`);
694
+ process.exit(1);
695
+ }
696
+ filePath = a;
697
+ }
698
+ if (!filePath) {
699
+ process.stderr.write('Error: No audio file specified. Usage: listener transcript <file> [--output <path>] [--prompt <text>]\n');
700
+ process.exit(1);
701
+ }
702
+ filePath = path.resolve(filePath);
703
+ if (!fs.existsSync(filePath)) {
704
+ process.stderr.write(`Error: File not found: ${filePath}\n`);
705
+ process.exit(1);
706
+ }
707
+ const ext = path.extname(filePath).toLowerCase();
708
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
709
+ process.stderr.write(`Error: Unsupported file type: ${ext}\n`);
710
+ process.stderr.write(`Supported formats: ${[...SUPPORTED_EXTENSIONS].join(', ')}\n`);
711
+ process.exit(1);
712
+ }
713
+ const dataPath = (0, dataPath_1.getDataPath)();
714
+ const config = new configService_1.ConfigService(dataPath);
715
+ const apiKey = config.getGeminiApiKey();
716
+ if (!apiKey) {
717
+ process.stderr.write('Error: Gemini API key not found.\n' +
718
+ 'Set GEMINI_API_KEY env var or configure via the Listener.AI app.\n');
719
+ process.exit(1);
720
+ }
721
+ // Resolve --output before the expensive transcription so we fail fast on a
722
+ // bad path. Existing directory => <dir>/<basename>.transcript.md.
723
+ // Anything else => the path itself, treated as a file.
724
+ let outputPath;
725
+ if (outputArg) {
726
+ const resolved = path.resolve(outputArg);
727
+ let isDir = false;
728
+ try {
729
+ isDir = fs.statSync(resolved).isDirectory();
730
+ }
731
+ catch {
732
+ // ENOENT or similar: treat as a file path, validated below.
733
+ }
734
+ if (isDir) {
735
+ outputPath = path.join(resolved, `${path.basename(filePath, ext)}.transcript.md`);
736
+ }
737
+ else {
738
+ outputPath = resolved;
739
+ const parent = path.dirname(outputPath);
740
+ if (!fs.existsSync(parent)) {
741
+ process.stderr.write(`Error: Output directory does not exist: ${parent}\n`);
742
+ process.exit(1);
743
+ }
744
+ }
745
+ }
746
+ const gemini = new geminiService_1.GeminiService({
747
+ apiKey,
748
+ dataPath,
749
+ knownWords: config.getKnownWords(),
750
+ proModel: config.getGeminiModel(),
751
+ flashModel: config.getGeminiFlashModel(),
752
+ });
753
+ process.stderr.write(`Processing: ${filePath}\n`);
754
+ const result = await gemini.transcribeAudio(filePath, (_percent, message) => {
755
+ process.stderr.write(` ${message}\n`);
756
+ }, undefined, undefined, { transcriptOnly: true, transcriptionPrompt: promptText });
757
+ if (outputPath) {
758
+ fs.writeFileSync(outputPath, result.transcript, 'utf-8');
759
+ process.stderr.write('Done.\n');
760
+ process.stdout.write(`${outputPath}\n`);
761
+ }
762
+ else {
763
+ // Wait for the OS to drain the write before returning, so multi-MB
764
+ // transcripts piped to a slow consumer are not truncated on process exit.
765
+ const out = result.transcript.endsWith('\n') ? result.transcript : `${result.transcript}\n`;
766
+ await new Promise((resolve) => process.stdout.write(out, () => resolve()));
767
+ }
768
+ }
551
769
  async function main() {
552
770
  const args = process.argv.slice(2);
553
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
554
- usage();
771
+ if (args.includes('--version') || args.includes('-V')) {
772
+ process.stdout.write(`listener ${VERSION}\n`);
773
+ return;
774
+ }
775
+ if (args.includes('--help') || args.includes('-h')) {
776
+ showHelp();
777
+ }
778
+ if (args.length === 0) {
779
+ usageError();
555
780
  }
556
781
  if (args[0] === 'config') {
557
782
  handleConfig(args.slice(1));
@@ -581,16 +806,20 @@ async function main() {
581
806
  await handleAsk(args.slice(1));
582
807
  return;
583
808
  }
809
+ if (args[0] === 'transcript') {
810
+ await handleTranscript(args.slice(1));
811
+ return;
812
+ }
584
813
  // Parse arguments
585
814
  let filePath;
586
815
  let outputDir;
587
816
  for (let i = 0; i < args.length; i++) {
588
- if (args[i] === '--output' && i + 1 < args.length) {
817
+ if ((args[i] === '--output' || args[i] === '-o') && i + 1 < args.length) {
589
818
  outputDir = args[++i];
590
819
  }
591
820
  else if (args[i].startsWith('-')) {
592
821
  process.stderr.write(`Error: Unknown option: ${args[i]}\n`);
593
- usage();
822
+ usageError();
594
823
  }
595
824
  else {
596
825
  filePath = args[i];
@@ -598,7 +827,7 @@ async function main() {
598
827
  }
599
828
  if (!filePath) {
600
829
  process.stderr.write('Error: No audio file specified.\n');
601
- usage();
830
+ usageError();
602
831
  }
603
832
  // Resolve to absolute path
604
833
  filePath = path.resolve(filePath);
@@ -240,6 +240,10 @@ class ConfigService {
240
240
  }
241
241
  this.saveConfig();
242
242
  }
243
+ unsetKey(key) {
244
+ delete this.config[key];
245
+ this.saveConfig();
246
+ }
243
247
  getAllConfig() {
244
248
  return {
245
249
  geminiApiKey: this.getGeminiApiKey(),