opencode-smart-voice-notify 1.2.4 → 1.3.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/LICENSE +21 -21
- package/README.md +254 -66
- package/example.config.jsonc +135 -122
- package/index.js +541 -51
- package/package.json +59 -52
- package/util/ai-messages.js +73 -0
- package/util/config.js +653 -335
- package/util/desktop-notify.js +319 -0
- package/util/focus-detect.js +372 -0
- package/util/per-project-sound.js +90 -0
- package/util/sound-theme.js +129 -0
- package/util/tts.js +684 -596
- package/util/webhook.js +743 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-Project Sound Module
|
|
8
|
+
*
|
|
9
|
+
* Provides logic for assigning unique sounds to different projects.
|
|
10
|
+
* Hashes project directory + seed to pick a consistent sound from assets.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const projectSoundCache = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Internal debug logger
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @param {object} config
|
|
19
|
+
*/
|
|
20
|
+
const debugLog = (message, config) => {
|
|
21
|
+
if (!config || !config.debugLog) return;
|
|
22
|
+
|
|
23
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
24
|
+
const logsDir = path.join(configDir, 'logs');
|
|
25
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(logsDir)) {
|
|
29
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
const timestamp = new Date().toISOString();
|
|
32
|
+
fs.appendFileSync(logFile, `[${timestamp}] [per-project-sound] ${message}\n`);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// Silently fail - logging is optional
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get a unique sound for a project by hashing its path.
|
|
40
|
+
* @param {object} project - The project object (should contain directory)
|
|
41
|
+
* @param {object} config - Plugin configuration
|
|
42
|
+
* @returns {string | null} Relative path to the project-specific sound, or null if disabled/unavailable
|
|
43
|
+
*/
|
|
44
|
+
export const getProjectSound = (project, config) => {
|
|
45
|
+
if (!config || !config.perProjectSounds || !project?.directory) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const projectPath = project.directory;
|
|
50
|
+
|
|
51
|
+
// Use cache to ensure consistency within session
|
|
52
|
+
if (projectSoundCache.has(projectPath)) {
|
|
53
|
+
const cachedSound = projectSoundCache.get(projectPath);
|
|
54
|
+
debugLog(`Returning cached sound for project: ${projectPath} -> ${cachedSound}`, config);
|
|
55
|
+
return cachedSound;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Hash the path + seed
|
|
60
|
+
const seed = config.projectSoundSeed || 0;
|
|
61
|
+
// We use MD5 because it's fast and sufficient for this purpose
|
|
62
|
+
const hash = crypto.createHash('md5').update(projectPath + seed).digest('hex');
|
|
63
|
+
|
|
64
|
+
// Map hash to 1-6 (opencode-notificator pattern)
|
|
65
|
+
// Using first 8 chars of hash for a stable number
|
|
66
|
+
const soundIndex = (parseInt(hash.substring(0, 8), 16) % 6) + 1;
|
|
67
|
+
const soundFile = `assets/ding${soundIndex}.mp3`;
|
|
68
|
+
|
|
69
|
+
debugLog(`Assigned new sound for project: ${projectPath} (seed: ${seed}) -> ${soundFile}`, config);
|
|
70
|
+
|
|
71
|
+
// Cache and return
|
|
72
|
+
projectSoundCache.set(projectPath, soundFile);
|
|
73
|
+
return soundFile;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
debugLog(`Error assigning project sound: ${e.message}`, config);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clear the project sound cache (used for testing)
|
|
82
|
+
*/
|
|
83
|
+
export const clearProjectSoundCache = () => {
|
|
84
|
+
projectSoundCache.clear();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default {
|
|
88
|
+
getProjectSound,
|
|
89
|
+
clearProjectSoundCache
|
|
90
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sound Theme Module
|
|
7
|
+
*
|
|
8
|
+
* Provides functionality for themed sound packs.
|
|
9
|
+
* Supports directory structure with idle/, permission/, error/, and question/ subdirectories.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Internal debug logger
|
|
16
|
+
* @param {string} message
|
|
17
|
+
* @param {object} config
|
|
18
|
+
*/
|
|
19
|
+
const debugLog = (message, config) => {
|
|
20
|
+
if (!config || !config.debugLog) return;
|
|
21
|
+
|
|
22
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
23
|
+
const logsDir = path.join(configDir, 'logs');
|
|
24
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(logsDir)) {
|
|
28
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
const timestamp = new Date().toISOString();
|
|
31
|
+
fs.appendFileSync(logFile, `[${timestamp}] [sound-theme] ${message}\n`);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Silently fail - logging is optional
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* List all audio files in a theme subdirectory
|
|
39
|
+
* @param {string} themeDir - Root theme directory
|
|
40
|
+
* @param {string} eventType - Subdirectory name (idle, permission, error, question)
|
|
41
|
+
* @returns {string[]} Absolute paths to audio files
|
|
42
|
+
*/
|
|
43
|
+
export const listSoundsInTheme = (themeDir, eventType) => {
|
|
44
|
+
if (!themeDir) return [];
|
|
45
|
+
|
|
46
|
+
const subDir = path.join(themeDir, eventType);
|
|
47
|
+
if (!fs.existsSync(subDir) || !fs.statSync(subDir).isDirectory()) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return fs.readdirSync(subDir)
|
|
53
|
+
.filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase()))
|
|
54
|
+
.sort() // Sort alphabetically for consistent cross-platform behavior
|
|
55
|
+
.map(file => path.join(subDir, file))
|
|
56
|
+
.filter(filePath => fs.statSync(filePath).isFile());
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick a sound for the given event type from the theme directory
|
|
64
|
+
* @param {string} eventType - Type of event (idle, permission, error, question)
|
|
65
|
+
* @param {object} config - Plugin configuration
|
|
66
|
+
* @returns {string|null} Path to the selected sound, or null if theme not available
|
|
67
|
+
*/
|
|
68
|
+
export const pickThemeSound = (eventType, config) => {
|
|
69
|
+
if (!config.soundThemeDir) return null;
|
|
70
|
+
|
|
71
|
+
// Resolve absolute path if relative
|
|
72
|
+
let themeDir = config.soundThemeDir;
|
|
73
|
+
if (!path.isAbsolute(themeDir)) {
|
|
74
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
75
|
+
themeDir = path.join(configDir, themeDir);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(themeDir)) {
|
|
79
|
+
debugLog(`Theme directory not found: ${themeDir}`, config);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sounds = listSoundsInTheme(themeDir, eventType);
|
|
84
|
+
if (sounds.length === 0) {
|
|
85
|
+
debugLog(`No sounds found for event type '${eventType}' in theme: ${themeDir}`, config);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let selected;
|
|
90
|
+
if (config.randomizeSoundFromTheme) {
|
|
91
|
+
const randomIndex = Math.floor(Math.random() * sounds.length);
|
|
92
|
+
selected = sounds[randomIndex];
|
|
93
|
+
debugLog(`Randomly selected sound for '${eventType}': ${selected} (from ${sounds.length} files)`, config);
|
|
94
|
+
} else {
|
|
95
|
+
selected = sounds[0];
|
|
96
|
+
debugLog(`Selected first sound for '${eventType}': ${selected}`, config);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return selected;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pick a random sound from a directory
|
|
104
|
+
* @param {string} dirPath - Directory path
|
|
105
|
+
* @returns {string|null} Path to a random audio file
|
|
106
|
+
*/
|
|
107
|
+
export const pickRandomSound = (dirPath) => {
|
|
108
|
+
if (!dirPath || !fs.existsSync(dirPath)) return null;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const files = fs.readdirSync(dirPath)
|
|
112
|
+
.filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase()))
|
|
113
|
+
.map(file => path.join(dirPath, file))
|
|
114
|
+
.filter(filePath => fs.statSync(filePath).isFile());
|
|
115
|
+
|
|
116
|
+
if (files.length === 0) return null;
|
|
117
|
+
|
|
118
|
+
const randomIndex = Math.floor(Math.random() * files.length);
|
|
119
|
+
return files[randomIndex];
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export default {
|
|
126
|
+
listSoundsInTheme,
|
|
127
|
+
pickThemeSound,
|
|
128
|
+
pickRandomSound
|
|
129
|
+
};
|