opencode-smart-voice-notify 1.0.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/index.js ADDED
@@ -0,0 +1,431 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fs from 'fs';
4
+ import { loadConfig } from './util/config.js';
5
+ import { createTTS, getTTSConfig } from './util/tts.js';
6
+
7
+ /**
8
+ * OpenCode Smart Voice Notify Plugin
9
+ *
10
+ * A smart notification plugin with multiple TTS engines (auto-fallback):
11
+ * 1. ElevenLabs (Online, High Quality, Anime-like voices)
12
+ * 2. Edge TTS (Free, Neural voices)
13
+ * 3. Windows SAPI (Offline, Built-in)
14
+ * 4. Local Sound Files (Fallback)
15
+ *
16
+ * Features:
17
+ * - Smart notification mode (sound-first, tts-first, both, sound-only)
18
+ * - Delayed TTS reminders if user doesn't respond
19
+ * - Follow-up reminders with exponential backoff
20
+ * - Monitor wake and volume boost
21
+ * - Cross-platform support (Windows, macOS, Linux)
22
+ */
23
+ export const SmartVoiceNotifyPlugin = async ({ $, client }) => {
24
+ const config = getTTSConfig();
25
+ const tts = createTTS({ $, client });
26
+
27
+ const platform = os.platform();
28
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
29
+ const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
30
+
31
+ // Track pending TTS reminders (can be cancelled if user responds)
32
+ const pendingReminders = new Map();
33
+
34
+ // Track last user activity time
35
+ let lastUserActivityTime = Date.now();
36
+
37
+ // Track seen user message IDs to avoid treating message UPDATES as new user activity
38
+ // Key insight: message.updated fires for EVERY modification to a message, not just new messages
39
+ // We only want to treat the FIRST occurrence of each user message as "user activity"
40
+ const seenUserMessageIds = new Set();
41
+
42
+ // Track the timestamp of when session went idle, to detect post-idle user messages
43
+ let lastSessionIdleTime = 0;
44
+
45
+ // Track active permission request to prevent race condition where user responds
46
+ // before async notification code runs. Set on permission.updated, cleared on permission.replied.
47
+ let activePermissionId = null;
48
+
49
+ /**
50
+ * Write debug message to log file
51
+ */
52
+ const debugLog = (message) => {
53
+ if (!config.debugLog) return;
54
+ try {
55
+ const timestamp = new Date().toISOString();
56
+ fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
57
+ } catch (e) {}
58
+ };
59
+
60
+ /**
61
+ * Get a random message from an array of messages
62
+ */
63
+ const getRandomMessage = (messages) => {
64
+ if (!Array.isArray(messages) || messages.length === 0) {
65
+ return 'Notification';
66
+ }
67
+ return messages[Math.floor(Math.random() * messages.length)];
68
+ };
69
+
70
+ /**
71
+ * Show a TUI toast notification
72
+ */
73
+ const showToast = async (message, variant = 'info', duration = 5000) => {
74
+ if (!config.enableToast) return;
75
+ try {
76
+ if (typeof client?.tui?.showToast === 'function') {
77
+ await client.tui.showToast({
78
+ body: {
79
+ message: message,
80
+ variant: variant,
81
+ duration: duration
82
+ }
83
+ });
84
+ }
85
+ } catch (e) {}
86
+ };
87
+
88
+ /**
89
+ * Play a sound file from assets
90
+ */
91
+ const playSound = async (soundFile, loops = 1) => {
92
+ if (!config.enableSound) return;
93
+ try {
94
+ const soundPath = path.isAbsolute(soundFile)
95
+ ? soundFile
96
+ : path.join(configDir, soundFile);
97
+
98
+ if (!fs.existsSync(soundPath)) {
99
+ debugLog(`playSound: file not found: ${soundPath}`);
100
+ return;
101
+ }
102
+
103
+ await tts.wakeMonitor();
104
+ await tts.forceVolume();
105
+ await tts.playAudioFile(soundPath, loops);
106
+ debugLog(`playSound: played ${soundPath} (${loops}x)`);
107
+ } catch (e) {
108
+ debugLog(`playSound error: ${e.message}`);
109
+ }
110
+ };
111
+
112
+ /**
113
+ * Cancel any pending TTS reminder for a given type
114
+ */
115
+ const cancelPendingReminder = (type) => {
116
+ const existing = pendingReminders.get(type);
117
+ if (existing) {
118
+ clearTimeout(existing.timeoutId);
119
+ pendingReminders.delete(type);
120
+ debugLog(`cancelPendingReminder: cancelled ${type}`);
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Cancel all pending TTS reminders (called on user activity)
126
+ */
127
+ const cancelAllPendingReminders = () => {
128
+ for (const [type, reminder] of pendingReminders.entries()) {
129
+ clearTimeout(reminder.timeoutId);
130
+ debugLog(`cancelAllPendingReminders: cancelled ${type}`);
131
+ }
132
+ pendingReminders.clear();
133
+ };
134
+
135
+ /**
136
+ * Schedule a TTS reminder if user doesn't respond within configured delay.
137
+ * The reminder uses a personalized TTS message.
138
+ * @param {string} type - 'idle' or 'permission'
139
+ * @param {string} message - The TTS message to speak
140
+ * @param {object} options - Additional options
141
+ */
142
+ const scheduleTTSReminder = (type, message, options = {}) => {
143
+ // Check if TTS reminders are enabled
144
+ if (!config.enableTTSReminder) {
145
+ debugLog(`scheduleTTSReminder: TTS reminders disabled`);
146
+ return;
147
+ }
148
+
149
+ // Get delay from config (in seconds, convert to ms)
150
+ const delaySeconds = type === 'permission'
151
+ ? (config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30)
152
+ : (config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30);
153
+ const delayMs = delaySeconds * 1000;
154
+
155
+ // Cancel any existing reminder of this type
156
+ cancelPendingReminder(type);
157
+
158
+ debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s`);
159
+
160
+ const timeoutId = setTimeout(async () => {
161
+ try {
162
+ // Check if reminder was cancelled (user responded)
163
+ if (!pendingReminders.has(type)) {
164
+ debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
165
+ return;
166
+ }
167
+
168
+ // Check if user has been active since notification
169
+ const reminder = pendingReminders.get(type);
170
+ if (reminder && lastUserActivityTime > reminder.scheduledAt) {
171
+ debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
172
+ pendingReminders.delete(type);
173
+ return;
174
+ }
175
+
176
+ debugLog(`scheduleTTSReminder: firing ${type} TTS reminder`);
177
+
178
+ // Get the appropriate reminder messages (more personalized/urgent)
179
+ const reminderMessages = type === 'permission'
180
+ ? config.permissionReminderTTSMessages
181
+ : config.idleReminderTTSMessages;
182
+
183
+ const reminderMessage = getRandomMessage(reminderMessages);
184
+
185
+ // Speak the reminder using TTS
186
+ await tts.wakeMonitor();
187
+ await tts.forceVolume();
188
+ await tts.speak(reminderMessage, {
189
+ enableTTS: true,
190
+ fallbackSound: options.fallbackSound
191
+ });
192
+
193
+ // Clean up
194
+ pendingReminders.delete(type);
195
+
196
+ // Schedule follow-up reminder if configured (exponential backoff or fixed)
197
+ if (config.enableFollowUpReminders) {
198
+ const followUpCount = (reminder?.followUpCount || 0) + 1;
199
+ const maxFollowUps = config.maxFollowUpReminders || 3;
200
+
201
+ if (followUpCount < maxFollowUps) {
202
+ // Schedule another reminder with optional backoff
203
+ const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
204
+ const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
205
+
206
+ debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
207
+
208
+ const followUpTimeoutId = setTimeout(async () => {
209
+ const followUpReminder = pendingReminders.get(type);
210
+ if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
211
+ pendingReminders.delete(type);
212
+ return;
213
+ }
214
+
215
+ const followUpMessage = getRandomMessage(reminderMessages);
216
+ await tts.wakeMonitor();
217
+ await tts.forceVolume();
218
+ await tts.speak(followUpMessage, {
219
+ enableTTS: true,
220
+ fallbackSound: options.fallbackSound
221
+ });
222
+
223
+ pendingReminders.delete(type);
224
+ }, nextDelay * 1000);
225
+
226
+ pendingReminders.set(type, {
227
+ timeoutId: followUpTimeoutId,
228
+ scheduledAt: Date.now(),
229
+ followUpCount
230
+ });
231
+ }
232
+ }
233
+ } catch (e) {
234
+ debugLog(`scheduleTTSReminder error: ${e.message}`);
235
+ pendingReminders.delete(type);
236
+ }
237
+ }, delayMs);
238
+
239
+ // Store the pending reminder
240
+ pendingReminders.set(type, {
241
+ timeoutId,
242
+ scheduledAt: Date.now(),
243
+ followUpCount: 0
244
+ });
245
+ };
246
+
247
+ /**
248
+ * Smart notification: play sound first, then schedule TTS reminder
249
+ * @param {string} type - 'idle' or 'permission'
250
+ * @param {object} options - Notification options
251
+ */
252
+ const smartNotify = async (type, options = {}) => {
253
+ const {
254
+ soundFile,
255
+ soundLoops = 1,
256
+ ttsMessage,
257
+ fallbackSound
258
+ } = options;
259
+
260
+ // Step 1: Play the immediate sound notification
261
+ if (soundFile) {
262
+ await playSound(soundFile, soundLoops);
263
+ }
264
+
265
+ // Step 2: Schedule TTS reminder if user doesn't respond
266
+ if (config.enableTTSReminder && ttsMessage) {
267
+ scheduleTTSReminder(type, ttsMessage, { fallbackSound });
268
+ }
269
+
270
+ // Step 3: If TTS-first mode is enabled, also speak immediately
271
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
272
+ const immediateMessage = type === 'permission'
273
+ ? getRandomMessage(config.permissionTTSMessages)
274
+ : getRandomMessage(config.idleTTSMessages);
275
+
276
+ await tts.speak(immediateMessage, {
277
+ enableTTS: true,
278
+ fallbackSound
279
+ });
280
+ }
281
+ };
282
+
283
+ return {
284
+ event: async ({ event }) => {
285
+ try {
286
+ // ========================================
287
+ // USER ACTIVITY DETECTION
288
+ // Cancels pending TTS reminders when user responds
289
+ // ========================================
290
+ // NOTE: OpenCode event types (as of SDK v1.0.203):
291
+ // - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
292
+ // - permission.replied: fires when user responds to a permission request
293
+ // - session.created: fires when a new session starts
294
+ //
295
+ // CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
296
+ // Context-injector and other plugins can trigger multiple updates for the same message.
297
+ // We must only treat NEW user messages (after session.idle) as actual user activity.
298
+
299
+ if (event.type === "message.updated") {
300
+ const messageInfo = event.properties?.info;
301
+ const messageId = messageInfo?.id;
302
+ const isUserMessage = messageInfo?.role === 'user';
303
+
304
+ if (isUserMessage && messageId) {
305
+ // Check if this is a NEW user message we haven't seen before
306
+ const isNewMessage = !seenUserMessageIds.has(messageId);
307
+
308
+ // Check if this message arrived AFTER the last session.idle
309
+ // This is the key: only a message sent AFTER idle indicates user responded
310
+ const messageTime = messageInfo?.time?.created;
311
+ const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
312
+
313
+ if (isNewMessage) {
314
+ seenUserMessageIds.add(messageId);
315
+
316
+ // Only cancel reminders if this is a NEW message AFTER session went idle
317
+ // OR if there are no pending reminders (initial message before any notifications)
318
+ if (isAfterIdle || pendingReminders.size === 0) {
319
+ if (isAfterIdle) {
320
+ lastUserActivityTime = Date.now();
321
+ cancelAllPendingReminders();
322
+ debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
323
+ } else {
324
+ debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
325
+ }
326
+ } else {
327
+ debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
328
+ }
329
+ } else {
330
+ // This is an UPDATE to an existing message (e.g., context injection)
331
+ debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
332
+ }
333
+ }
334
+ }
335
+
336
+ if (event.type === "permission.replied") {
337
+ // User responded to a permission request (granted or denied)
338
+ // Structure: event.properties.{ sessionID, permissionID, response }
339
+ // CRITICAL: Clear activePermissionId FIRST to prevent race condition
340
+ // where permission.updated handler is still running async operations
341
+ const repliedPermissionId = event.properties?.permissionID;
342
+ if (activePermissionId === repliedPermissionId) {
343
+ activePermissionId = null;
344
+ debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId}`);
345
+ }
346
+ lastUserActivityTime = Date.now();
347
+ cancelPendingReminder('permission'); // Cancel permission-specific reminder
348
+ debugLog(`Permission replied: ${event.type} (response=${event.properties?.response}) - cancelled permission reminder`);
349
+ }
350
+
351
+ if (event.type === "session.created") {
352
+ // New session started - reset tracking state
353
+ lastUserActivityTime = Date.now();
354
+ lastSessionIdleTime = 0;
355
+ activePermissionId = null;
356
+ seenUserMessageIds.clear();
357
+ cancelAllPendingReminders();
358
+ debugLog(`Session created: ${event.type} - reset all tracking state`);
359
+ }
360
+
361
+ // ========================================
362
+ // NOTIFICATION 1: Session Idle (Agent Finished)
363
+ // ========================================
364
+ if (event.type === "session.idle") {
365
+ const sessionID = event.properties?.sessionID;
366
+ if (!sessionID) return;
367
+
368
+ try {
369
+ const session = await client.session.get({ path: { id: sessionID } });
370
+ if (session?.data?.parentID) {
371
+ debugLog(`session.idle: skipped (sub-session ${sessionID})`);
372
+ return;
373
+ }
374
+ } catch (e) {}
375
+
376
+ // Record the time session went idle - used to filter out pre-idle messages
377
+ lastSessionIdleTime = Date.now();
378
+
379
+ debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
380
+ await showToast("✅ Agent has finished working", "success", 5000);
381
+
382
+ // Smart notification: sound first, TTS reminder later
383
+ await smartNotify('idle', {
384
+ soundFile: config.idleSound,
385
+ soundLoops: 1,
386
+ ttsMessage: getRandomMessage(config.idleTTSMessages),
387
+ fallbackSound: config.idleSound
388
+ });
389
+ }
390
+
391
+ // ========================================
392
+ // NOTIFICATION 2: Permission Request
393
+ // ========================================
394
+ if (event.type === "permission.updated") {
395
+ // CRITICAL: Capture permissionID IMMEDIATELY (before any async work)
396
+ // This prevents race condition where user responds before we finish notifying
397
+ const permissionId = event.properties?.permissionID;
398
+ activePermissionId = permissionId;
399
+
400
+ debugLog(`permission.updated: notifying (permissionId=${permissionId})`);
401
+ await showToast("⚠️ Permission request requires your attention", "warning", 8000);
402
+
403
+ // CHECK: Did user already respond while we were showing toast?
404
+ if (activePermissionId !== permissionId) {
405
+ debugLog(`permission.updated: aborted - user already responded (activePermissionId cleared)`);
406
+ return;
407
+ }
408
+
409
+ // Smart notification: sound first, TTS reminder later
410
+ await smartNotify('permission', {
411
+ soundFile: config.permissionSound,
412
+ soundLoops: 2,
413
+ ttsMessage: getRandomMessage(config.permissionTTSMessages),
414
+ fallbackSound: config.permissionSound
415
+ });
416
+
417
+ // Final check after smartNotify: if user responded during sound playback, cancel the scheduled reminder
418
+ if (activePermissionId !== permissionId) {
419
+ debugLog(`permission.updated: user responded during notification - cancelling any scheduled reminder`);
420
+ cancelPendingReminder('permission');
421
+ }
422
+ }
423
+ } catch (e) {
424
+ debugLog(`event handler error: ${e.message}`);
425
+ }
426
+ },
427
+ };
428
+ };
429
+
430
+ // Default export for compatibility
431
+ export default SmartVoiceNotifyPlugin;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "opencode-smart-voice-notify",
3
+ "version": "1.0.0",
4
+ "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI) and intelligent reminder system",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "author": "MasuRii",
8
+ "license": "MIT",
9
+ "keywords": [
10
+ "opencode",
11
+ "opencode-plugins",
12
+ "plugin",
13
+ "notification",
14
+ "tts",
15
+ "text-to-speech",
16
+ "elevenlabs",
17
+ "edge-tts",
18
+ "sapi",
19
+ "voice",
20
+ "alert",
21
+ "smart"
22
+ ],
23
+ "files": [
24
+ "index.js",
25
+ "util/",
26
+ "assets/",
27
+ "example.config.jsonc"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/MasuRii/opencode-smart-voice-notify.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/MasuRii/opencode-smart-voice-notify/issues"
35
+ },
36
+ "homepage": "https://github.com/MasuRii/opencode-smart-voice-notify#readme",
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@elevenlabs/elevenlabs-js": "^2.28.0"
42
+ }
43
+ }
package/util/config.js ADDED
@@ -0,0 +1,182 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ /**
7
+ * Basic JSONC parser that strips single-line and multi-line comments.
8
+ * @param {string} jsonc
9
+ * @returns {any}
10
+ */
11
+ const parseJSONC = (jsonc) => {
12
+ const stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
13
+ return JSON.parse(stripped);
14
+ };
15
+
16
+ /**
17
+ * Get the directory where this plugin is installed.
18
+ * Used to find bundled assets like example.config.jsonc
19
+ */
20
+ const getPluginDir = () => {
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ return path.dirname(__dirname); // Go up from util/ to plugin root
24
+ };
25
+
26
+ /**
27
+ * Generate a minimal default configuration file content.
28
+ * This provides a working config with helpful comments pointing to the full example.
29
+ */
30
+ const generateDefaultConfig = () => {
31
+ return `{
32
+ // ============================================================
33
+ // OpenCode Smart Voice Notify - Configuration
34
+ // ============================================================
35
+ // This file was auto-generated with minimal defaults.
36
+ // For ALL available options, see the full example config:
37
+ // node_modules/opencode-smart-voice-notify/example.config.jsonc
38
+ // Or visit: https://github.com/MasuRii/opencode-smart-voice-notify#configuration
39
+ // ============================================================
40
+
41
+ // NOTIFICATION MODE
42
+ // 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
43
+ // 'tts-first' - Speak TTS immediately, no sound
44
+ // 'both' - Play sound AND speak TTS immediately
45
+ // 'sound-only' - Only play sound, no TTS at all
46
+ "notificationMode": "sound-first",
47
+
48
+ // ============================================================
49
+ // TTS ENGINE SELECTION
50
+ // ============================================================
51
+ // 'elevenlabs' - Best quality (requires API key, free tier: 10k chars/month)
52
+ // 'edge' - Good quality (free, requires: pip install edge-tts)
53
+ // 'sapi' - Windows built-in (free, offline, robotic)
54
+ "ttsEngine": "edge",
55
+ "enableTTS": true,
56
+
57
+ // ============================================================
58
+ // ELEVENLABS SETTINGS (Optional - for best quality)
59
+ // ============================================================
60
+ // Get your free API key from: https://elevenlabs.io/app/settings/api-keys
61
+ // Uncomment and add your key to use ElevenLabs:
62
+ // "elevenLabsApiKey": "YOUR_API_KEY_HERE",
63
+ // "elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
64
+
65
+ // ============================================================
66
+ // EDGE TTS SETTINGS (Default - Free Neural Voices)
67
+ // ============================================================
68
+ // Requires: pip install edge-tts
69
+ "edgeVoice": "en-US-AnaNeural",
70
+ "edgePitch": "+50Hz",
71
+ "edgeRate": "+10%",
72
+
73
+ // ============================================================
74
+ // TTS REMINDER SETTINGS
75
+ // ============================================================
76
+ "enableTTSReminder": true,
77
+ "ttsReminderDelaySeconds": 30,
78
+ "permissionReminderDelaySeconds": 20,
79
+ "enableFollowUpReminders": true,
80
+ "maxFollowUpReminders": 3,
81
+
82
+ // ============================================================
83
+ // SOUND FILES (Relative to ~/.config/opencode/)
84
+ // ============================================================
85
+ // NOTE: You need to copy the sound files to your config directory!
86
+ // Copy from: node_modules/opencode-smart-voice-notify/assets/
87
+ // To: ~/.config/opencode/assets/
88
+ "idleSound": "assets/Soft-high-tech-notification-sound-effect.mp3",
89
+ "permissionSound": "assets/Machine-alert-beep-sound-effect.mp3",
90
+
91
+ // ============================================================
92
+ // GENERAL SETTINGS
93
+ // ============================================================
94
+ "enableSound": true,
95
+ "enableToast": true,
96
+ "wakeMonitor": true,
97
+ "forceVolume": true,
98
+ "debugLog": false
99
+ }
100
+ `;
101
+ };
102
+
103
+ /**
104
+ * Copy bundled assets (sound files) to the OpenCode config directory.
105
+ * @param {string} configDir - The OpenCode config directory path
106
+ */
107
+ const copyBundledAssets = (configDir) => {
108
+ try {
109
+ const pluginDir = getPluginDir();
110
+ const sourceAssetsDir = path.join(pluginDir, 'assets');
111
+ const targetAssetsDir = path.join(configDir, 'assets');
112
+
113
+ // Check if source assets exist (they should be bundled with the plugin)
114
+ if (!fs.existsSync(sourceAssetsDir)) {
115
+ return; // No bundled assets to copy
116
+ }
117
+
118
+ // Create target assets directory if it doesn't exist
119
+ if (!fs.existsSync(targetAssetsDir)) {
120
+ fs.mkdirSync(targetAssetsDir, { recursive: true });
121
+ }
122
+
123
+ // Copy each asset file if it doesn't already exist in target
124
+ const assetFiles = fs.readdirSync(sourceAssetsDir);
125
+ for (const file of assetFiles) {
126
+ const sourcePath = path.join(sourceAssetsDir, file);
127
+ const targetPath = path.join(targetAssetsDir, file);
128
+
129
+ // Only copy if target doesn't exist (don't overwrite user customizations)
130
+ if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
131
+ fs.copyFileSync(sourcePath, targetPath);
132
+ }
133
+ }
134
+ } catch (error) {
135
+ // Silently fail - assets are optional
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Loads a configuration file from the OpenCode config directory.
141
+ * If the file doesn't exist, creates a default config file.
142
+ * @param {string} name - Name of the config file (without .jsonc extension)
143
+ * @param {object} defaults - Default values if file doesn't exist or is invalid
144
+ * @returns {object}
145
+ */
146
+ export const loadConfig = (name, defaults = {}) => {
147
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
148
+ const filePath = path.join(configDir, `${name}.jsonc`);
149
+
150
+ if (!fs.existsSync(filePath)) {
151
+ // Auto-create the default config file
152
+ try {
153
+ // Ensure config directory exists
154
+ if (!fs.existsSync(configDir)) {
155
+ fs.mkdirSync(configDir, { recursive: true });
156
+ }
157
+
158
+ // Write the default config
159
+ const defaultConfig = generateDefaultConfig();
160
+ fs.writeFileSync(filePath, defaultConfig, 'utf-8');
161
+
162
+ // Also copy bundled assets (sound files) to the config directory
163
+ copyBundledAssets(configDir);
164
+
165
+ // Parse and return the newly created config merged with defaults
166
+ const config = parseJSONC(defaultConfig);
167
+ return { ...defaults, ...config };
168
+ } catch (error) {
169
+ // If we can't create the file, just return defaults
170
+ return defaults;
171
+ }
172
+ }
173
+
174
+ try {
175
+ const content = fs.readFileSync(filePath, 'utf-8');
176
+ const config = parseJSONC(content);
177
+ return { ...defaults, ...config };
178
+ } catch (error) {
179
+ // Silently return defaults - don't use console.error as it breaks TUI
180
+ return defaults;
181
+ }
182
+ };