listener-ai 2.6.0 → 2.7.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.
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ConfigService = exports.DEFAULT_SUMMARY_PROMPT = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const aiProvider_1 = require("./aiProvider");
40
+ const codexOAuth_1 = require("./codexOAuth");
39
41
  exports.DEFAULT_SUMMARY_PROMPT = `Based on this meeting transcript, provide:
40
42
 
41
43
  1. A concise meeting title in Korean (10-20 characters that captures the main topic)
@@ -58,6 +60,12 @@ class ConfigService {
58
60
  }
59
61
  constructor(dataPath) {
60
62
  this.config = {};
63
+ // Keys this process has explicitly modified since the last successful save.
64
+ // saveConfig() re-reads the file on every write and applies only these keys on
65
+ // top of disk state, so a concurrent process (Electron app + CLI hitting the
66
+ // same config.json during OAuth refresh, etc.) cannot clobber unrelated keys.
67
+ this.dirtyKeys = new Set();
68
+ this.envProviderWarned = false;
61
69
  let userDataPath;
62
70
  if (dataPath) {
63
71
  userDataPath = dataPath;
@@ -72,6 +80,31 @@ class ConfigService {
72
80
  }
73
81
  this.configPath = path.join(userDataPath, 'config.json');
74
82
  this.loadConfig();
83
+ this.migrateLegacyDefaults();
84
+ }
85
+ // One-shot upgrade hook for keys that older versions auto-persisted from
86
+ // their then-current default. The settings modal in those versions wrote
87
+ // back the full payload on save -- including fields the user never
88
+ // touched -- so the next default change can't reach existing installs.
89
+ // Today's case: `codexTranscriptionModel: 'gpt-4o-transcribe'` was the
90
+ // legacy default before gpt-4o-transcribe-diarize shipped; clearing it
91
+ // here lets `getCodexTranscriptionModel()` return the current default
92
+ // (diarize) without forcing every user to manually unset it.
93
+ //
94
+ // The marker semantics are "we've considered migrating this user" --
95
+ // it lands on EVERY install on first launch, not just the ones we
96
+ // actually had to migrate. That way if a user later opts back into
97
+ // `gpt-4o-transcribe` deliberately (e.g. for glossary support), the
98
+ // next ConfigService construction sees the marker and skips the
99
+ // migration entirely instead of clobbering their explicit choice.
100
+ migrateLegacyDefaults() {
101
+ if (this.config.codexTranscriptionMigratedToDiarize)
102
+ return;
103
+ if (this.config.codexTranscriptionModel === 'gpt-4o-transcribe') {
104
+ this.setKey('codexTranscriptionModel', undefined);
105
+ }
106
+ this.setKey('codexTranscriptionMigratedToDiarize', true);
107
+ this.saveConfig();
75
108
  }
76
109
  loadConfig() {
77
110
  try {
@@ -85,10 +118,53 @@ class ConfigService {
85
118
  this.config = {};
86
119
  }
87
120
  }
121
+ setKey(key, value) {
122
+ if (value === undefined) {
123
+ delete this.config[key];
124
+ }
125
+ else {
126
+ this.config[key] = value;
127
+ }
128
+ this.dirtyKeys.add(key);
129
+ }
88
130
  saveConfig() {
89
131
  try {
90
132
  fs.mkdirSync(path.dirname(this.configPath), { recursive: true });
91
- fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
133
+ let merged = {};
134
+ if (fs.existsSync(this.configPath)) {
135
+ try {
136
+ merged = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
137
+ }
138
+ catch {
139
+ // ignore corrupt disk file; treat as empty and let our writes recover it
140
+ }
141
+ }
142
+ for (const key of this.dirtyKeys) {
143
+ const value = this.config[key];
144
+ if (value === undefined) {
145
+ delete merged[key];
146
+ }
147
+ else {
148
+ merged[key] = value;
149
+ }
150
+ }
151
+ // 0o600 keeps API keys + OAuth refresh tokens off other users on shared
152
+ // machines. writeFileSync's `mode` option only applies when the OS
153
+ // creates the file -- existing config.json from prior versions keeps its
154
+ // umask-derived mode (typically 0o644). Explicitly chmod after writing
155
+ // so upgrade paths get tightened too. chmodSync is a no-op for the bits
156
+ // that matter on Windows but doesn't throw, so the call is unconditional.
157
+ fs.writeFileSync(this.configPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
158
+ try {
159
+ fs.chmodSync(this.configPath, 0o600);
160
+ }
161
+ catch (chmodError) {
162
+ // Don't fail the save if chmod fails (e.g. exotic filesystem) -- the
163
+ // write succeeded and the override above already covers fresh files.
164
+ console.warn('Could not chmod config.json to 0o600:', chmodError);
165
+ }
166
+ this.config = merged;
167
+ this.dirtyKeys.clear();
92
168
  }
93
169
  catch (error) {
94
170
  console.error('Error saving config:', error);
@@ -98,30 +174,82 @@ class ConfigService {
98
174
  return this.config.geminiApiKey || process.env.GEMINI_API_KEY;
99
175
  }
100
176
  setGeminiApiKey(apiKey) {
101
- this.config.geminiApiKey = apiKey;
177
+ this.setKey('geminiApiKey', apiKey);
102
178
  this.saveConfig();
103
179
  }
180
+ getAiProvider() {
181
+ const envProvider = (0, aiProvider_1.normalizeAiProvider)(process.env.LISTENER_AI_PROVIDER);
182
+ if (envProvider) {
183
+ const configured = (0, aiProvider_1.normalizeAiProvider)(this.config.aiProvider);
184
+ if (configured && configured !== envProvider && !this.envProviderWarned) {
185
+ console.warn(`LISTENER_AI_PROVIDER=${envProvider} overrides configured aiProvider=${configured}.`);
186
+ this.envProviderWarned = true;
187
+ }
188
+ return envProvider;
189
+ }
190
+ const configured = (0, aiProvider_1.normalizeAiProvider)(this.config.aiProvider);
191
+ if (configured)
192
+ return configured;
193
+ if (!this.getGeminiApiKey() && this.hasCodexOAuth())
194
+ return 'codex';
195
+ return 'gemini';
196
+ }
197
+ setAiProvider(provider) {
198
+ this.setKey('aiProvider', provider);
199
+ this.saveConfig();
200
+ }
201
+ // Returns the active OAuth credentials whether they came from config or env.
202
+ // Callers that intend to PERSIST refreshed credentials must additionally check
203
+ // `hasStoredCodexOAuth()` and skip the persistence callback when source is env --
204
+ // otherwise a normal token refresh writes env-sourced tokens to plaintext disk.
205
+ getCodexOAuth() {
206
+ return this.config.codexOAuth || (0, codexOAuth_1.getCodexOAuthEnvCredentials)();
207
+ }
208
+ // True only when credentials are stored in config.json. Env-only credentials
209
+ // return false here. Use this to gate `onCodexOAuthUpdate` persistence callbacks.
210
+ hasStoredCodexOAuth() {
211
+ const c = this.config.codexOAuth;
212
+ return !!(c?.access && c.refresh && Number.isFinite(c.expires));
213
+ }
214
+ setCodexOAuth(credentials) {
215
+ this.setKey('codexOAuth', credentials);
216
+ this.saveConfig();
217
+ }
218
+ clearCodexOAuth() {
219
+ this.setKey('codexOAuth', undefined);
220
+ this.saveConfig();
221
+ }
222
+ hasCodexOAuth() {
223
+ return (0, codexOAuth_1.hasCodexOAuthEnvCredentials)() || this.hasStoredCodexOAuth();
224
+ }
225
+ hasAiAuth() {
226
+ const provider = this.getAiProvider();
227
+ if (provider === 'codex')
228
+ return this.hasCodexOAuth();
229
+ return !!this.getGeminiApiKey();
230
+ }
104
231
  getNotionApiKey() {
105
232
  return this.config.notionApiKey || process.env.NOTION_API_KEY;
106
233
  }
107
234
  setNotionApiKey(apiKey) {
108
- this.config.notionApiKey = apiKey;
235
+ this.setKey('notionApiKey', apiKey);
109
236
  this.saveConfig();
110
237
  }
111
238
  getNotionDatabaseId() {
112
239
  return this.config.notionDatabaseId || process.env.NOTION_DATABASE_ID;
113
240
  }
114
241
  setNotionDatabaseId(databaseId) {
115
- this.config.notionDatabaseId = databaseId;
242
+ this.setKey('notionDatabaseId', databaseId);
116
243
  this.saveConfig();
117
244
  }
118
245
  hasRequiredConfig() {
119
- return !!this.getGeminiApiKey() && !!this.getNotionApiKey() && !!this.getNotionDatabaseId();
246
+ return this.hasAiAuth() && !!this.getNotionApiKey() && !!this.getNotionDatabaseId();
120
247
  }
121
248
  getMissingConfigs() {
122
249
  const missing = [];
123
- if (!this.getGeminiApiKey())
124
- missing.push('Gemini API Key');
250
+ if (!this.hasAiAuth()) {
251
+ missing.push(this.getAiProvider() === 'codex' ? 'Codex OAuth sign-in' : 'Gemini API Key');
252
+ }
125
253
  if (!this.getNotionApiKey())
126
254
  missing.push('Notion Integration Token');
127
255
  if (!this.getNotionDatabaseId())
@@ -132,7 +260,7 @@ class ConfigService {
132
260
  return this.config.autoMode || false;
133
261
  }
134
262
  setAutoMode(enabled) {
135
- this.config.autoMode = enabled;
263
+ this.setKey('autoMode', enabled);
136
264
  this.saveConfig();
137
265
  }
138
266
  getMeetingDetection() {
@@ -142,63 +270,77 @@ class ConfigService {
142
270
  return this.config.displayDetection || false;
143
271
  }
144
272
  setDisplayDetection(enabled) {
145
- this.config.displayDetection = enabled;
273
+ this.setKey('displayDetection', enabled);
146
274
  this.saveConfig();
147
275
  }
148
276
  getGlobalShortcut() {
149
277
  return this.config.globalShortcut || 'CommandOrControl+Shift+L';
150
278
  }
151
279
  setGlobalShortcut(shortcut) {
152
- this.config.globalShortcut = shortcut;
280
+ this.setKey('globalShortcut', shortcut);
153
281
  this.saveConfig();
154
282
  }
155
283
  getKnownWords() {
156
284
  return this.config.knownWords || [];
157
285
  }
158
286
  setKnownWords(words) {
159
- this.config.knownWords = words;
287
+ this.setKey('knownWords', words);
160
288
  this.saveConfig();
161
289
  }
162
290
  getGeminiModel() {
163
- return this.config.geminiModel || 'gemini-2.5-pro';
291
+ return this.config.geminiModel || aiProvider_1.DEFAULT_GEMINI_MODEL;
164
292
  }
165
293
  setGeminiModel(model) {
166
- this.config.geminiModel = model;
294
+ this.setKey('geminiModel', model);
167
295
  this.saveConfig();
168
296
  }
169
297
  getGeminiFlashModel() {
170
- return this.config.geminiFlashModel || 'gemini-2.5-flash';
298
+ return this.config.geminiFlashModel || aiProvider_1.DEFAULT_GEMINI_FLASH_MODEL;
171
299
  }
172
300
  setGeminiFlashModel(model) {
173
- this.config.geminiFlashModel = model;
301
+ this.setKey('geminiFlashModel', model);
302
+ this.saveConfig();
303
+ }
304
+ getCodexModel() {
305
+ return this.config.codexModel || aiProvider_1.DEFAULT_CODEX_MODEL;
306
+ }
307
+ setCodexModel(model) {
308
+ this.setKey('codexModel', model);
309
+ this.saveConfig();
310
+ }
311
+ getCodexTranscriptionModel() {
312
+ return this.config.codexTranscriptionModel || aiProvider_1.DEFAULT_CODEX_TRANSCRIPTION_MODEL;
313
+ }
314
+ setCodexTranscriptionModel(model) {
315
+ this.setKey('codexTranscriptionModel', model);
174
316
  this.saveConfig();
175
317
  }
176
318
  getMaxRecordingMinutes() {
177
319
  return this.config.maxRecordingMinutes || 0;
178
320
  }
179
321
  setMaxRecordingMinutes(minutes) {
180
- this.config.maxRecordingMinutes = Math.max(0, Math.floor(minutes));
322
+ this.setKey('maxRecordingMinutes', Math.max(0, Math.floor(minutes)));
181
323
  this.saveConfig();
182
324
  }
183
325
  getRecordingReminderMinutes() {
184
326
  return this.config.recordingReminderMinutes || 0;
185
327
  }
186
328
  setRecordingReminderMinutes(minutes) {
187
- this.config.recordingReminderMinutes = Math.max(0, Math.floor(minutes));
329
+ this.setKey('recordingReminderMinutes', Math.max(0, Math.floor(minutes)));
188
330
  this.saveConfig();
189
331
  }
190
332
  getMinRecordingSeconds() {
191
333
  return this.config.minRecordingSeconds || 0;
192
334
  }
193
335
  setMinRecordingSeconds(seconds) {
194
- this.config.minRecordingSeconds = Math.max(0, Math.floor(seconds));
336
+ this.setKey('minRecordingSeconds', Math.max(0, Math.floor(seconds)));
195
337
  this.saveConfig();
196
338
  }
197
339
  getRecordSystemAudio() {
198
340
  return this.config.recordSystemAudio || false;
199
341
  }
200
342
  setRecordSystemAudio(enabled) {
201
- this.config.recordSystemAudio = enabled;
343
+ this.setKey('recordSystemAudio', enabled);
202
344
  this.saveConfig();
203
345
  }
204
346
  getAudioDeviceId() {
@@ -208,47 +350,51 @@ class ConfigService {
208
350
  return this.config.lastSeenVersion;
209
351
  }
210
352
  setLastSeenVersion(version) {
211
- this.config.lastSeenVersion = version;
353
+ this.setKey('lastSeenVersion', version);
212
354
  this.saveConfig();
213
355
  }
214
356
  getSummaryPrompt() {
215
357
  return this.config.summaryPrompt || exports.DEFAULT_SUMMARY_PROMPT;
216
358
  }
217
359
  setSummaryPrompt(prompt) {
218
- this.config.summaryPrompt = prompt;
360
+ this.setKey('summaryPrompt', prompt);
219
361
  this.saveConfig();
220
362
  }
221
363
  getSlackWebhookUrl() {
222
364
  return this.config.slackWebhookUrl || process.env.SLACK_WEBHOOK_URL;
223
365
  }
224
366
  setSlackWebhookUrl(url) {
225
- this.config.slackWebhookUrl = url;
367
+ this.setKey('slackWebhookUrl', url);
226
368
  this.saveConfig();
227
369
  }
228
370
  getSlackAutoShare() {
229
371
  return this.config.slackAutoShare || false;
230
372
  }
231
373
  setSlackAutoShare(enabled) {
232
- this.config.slackAutoShare = enabled;
374
+ this.setKey('slackAutoShare', enabled);
233
375
  this.saveConfig();
234
376
  }
235
377
  updateConfig(partial) {
236
378
  for (const [key, value] of Object.entries(partial)) {
237
379
  if (value !== undefined) {
238
- this.config[key] = value;
380
+ this.setKey(key, value);
239
381
  }
240
382
  }
241
383
  this.saveConfig();
242
384
  }
243
385
  unsetKey(key) {
244
- delete this.config[key];
386
+ this.setKey(key, undefined);
245
387
  this.saveConfig();
246
388
  }
247
389
  getAllConfig() {
248
390
  return {
391
+ aiProvider: this.getAiProvider(),
249
392
  geminiApiKey: this.getGeminiApiKey(),
250
393
  geminiModel: this.getGeminiModel(),
251
394
  geminiFlashModel: this.getGeminiFlashModel(),
395
+ codexModel: this.getCodexModel(),
396
+ codexTranscriptionModel: this.getCodexTranscriptionModel(),
397
+ codexOAuthConfigured: this.hasCodexOAuth(),
252
398
  notionApiKey: this.getNotionApiKey(),
253
399
  notionDatabaseId: this.getNotionDatabaseId(),
254
400
  autoMode: this.getAutoMode(),
package/dist/dataPath.js CHANGED
@@ -34,10 +34,27 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getDataPath = getDataPath;
37
+ const fs = __importStar(require("fs"));
37
38
  const os = __importStar(require("os"));
38
39
  const path = __importStar(require("path"));
39
- // Must match productName in package.json (used by Electron for app.getPath('userData'))
40
- const APP_NAME = 'Listener.AI';
40
+ const APP_NAME = 'listener-ai';
41
+ const LEGACY_APP_NAME = 'Listener.AI';
42
+ function platformDataPath(appName) {
43
+ switch (process.platform) {
44
+ case 'darwin':
45
+ return path.join(os.homedir(), 'Library', 'Application Support', appName);
46
+ case 'win32':
47
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), appName);
48
+ default:
49
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName);
50
+ }
51
+ }
52
+ function hasUserData(dataPath) {
53
+ return (fs.existsSync(path.join(dataPath, 'config.json')) ||
54
+ fs.existsSync(path.join(dataPath, 'transcriptions')) ||
55
+ fs.existsSync(path.join(dataPath, 'recordings')) ||
56
+ fs.existsSync(path.join(dataPath, 'metadata')));
57
+ }
41
58
  function getDataPath() {
42
59
  // Test escape hatch: integration tests set this to a temp dir to avoid
43
60
  // touching the user's real data. Gated on NODE_ENV=test so a stray
@@ -46,12 +63,15 @@ function getDataPath() {
46
63
  if (process.env.LISTENER_DATA_PATH && process.env.NODE_ENV === 'test') {
47
64
  return process.env.LISTENER_DATA_PATH;
48
65
  }
49
- switch (process.platform) {
50
- case 'darwin':
51
- return path.join(os.homedir(), 'Library', 'Application Support', APP_NAME);
52
- case 'win32':
53
- return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), APP_NAME);
54
- default:
55
- return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), APP_NAME);
56
- }
66
+ const currentPath = platformDataPath(APP_NAME);
67
+ const legacyPath = platformDataPath(LEGACY_APP_NAME);
68
+ if (hasUserData(currentPath))
69
+ return currentPath;
70
+ if (hasUserData(legacyPath))
71
+ return legacyPath;
72
+ if (fs.existsSync(currentPath))
73
+ return currentPath;
74
+ if (fs.existsSync(legacyPath))
75
+ return legacyPath;
76
+ return currentPath;
57
77
  }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.importEsm = void 0;
4
+ // Dynamic ESM import that survives tsc's CJS rewrite. With
5
+ // `module: "commonjs"` in tsconfig.json, a plain `import('pkg')` compiles to
6
+ // `Promise.resolve().then(() => require('pkg'))`, which fails on ESM-only
7
+ // packages with ERR_REQUIRE_ESM. `Function(...)` evaluates at runtime, so the
8
+ // literal `import()` survives untouched and stays an actual dynamic import.
9
+ //
10
+ // Use this for any ESM-only dependency (`@earendil-works/pi-ai`,
11
+ // `@earendil-works/pi-ai/oauth`).
12
+ exports.importEsm = (() => {
13
+ const fn = new Function('specifier', 'return import(specifier)');
14
+ return fn;
15
+ })();