pi-smart-voice-notify 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Standardized repository structure to `index.ts` shim + `src/` implementation.
6
+ - Added config template and package metadata/scripts aligned with Pi extension conventions.
7
+ - Vendored `zellij-modal` into this repository to remove cross-extension imports.
8
+ - Modularized implementation into config store, logging, and audio notification modules while preserving runtime behavior.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MasuRii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # pi-smart-voice-notify
2
+
3
+ Windows-optimized smart notification extension for the Pi coding agent.
4
+
5
+ `pi-smart-voice-notify` watches Pi session/tool events and can alert you via **Windows SAPI TTS**, **sound playback**, and/or **desktop toast notifications** when the agent:
6
+
7
+ - finishes a turn (idle)
8
+ - hits a permission block
9
+ - needs your input (question)
10
+ - encounters an error
11
+
12
+ ![alt text](assets/pi-smart-voice-notify.png)
13
+
14
+ ## Features
15
+
16
+ - Multi-channel notifications:
17
+ - **Sound** (Windows via `powershell.exe` playback; falls back to simple beeps if playback fails)
18
+ - **Voice** (Windows SAPI text-to-speech)
19
+ - **Desktop notifications** via `node-notifier` (win32/darwin/linux best-effort)
20
+ - Reminder + follow-up scheduling when attention is still needed
21
+ - Throttling to avoid notification spam (`minNotificationIntervalMs`)
22
+ - Interactive settings UI:
23
+ - `/voice-notify` opens a configuration modal in interactive mode
24
+ - hides question-related settings automatically when no custom `question` tool is available
25
+ - Optional debug logging to `debug/` for diagnosing platform / PowerShell / notifier issues
26
+
27
+ ## Installation
28
+
29
+ ### Local extension folder
30
+
31
+ Place this folder in either:
32
+
33
+ - Global: `~/.pi/agent/extensions/pi-smart-voice-notify`
34
+ - Project: `.pi/extensions/pi-smart-voice-notify`
35
+
36
+ Pi auto-discovers these locations.
37
+
38
+ ### As an npm package
39
+
40
+ ```bash
41
+ pi install npm:pi-smart-voice-notify
42
+ ```
43
+
44
+ Or from git:
45
+
46
+ ```bash
47
+ pi install git:github.com/MasuRii/pi-smart-voice-notify
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Runtime config is stored at:
53
+
54
+ ```text
55
+ ~/.pi/agent/extensions/pi-smart-voice-notify/config.json
56
+ ```
57
+
58
+ A starter template is included as:
59
+
60
+ ```text
61
+ config/config.example.json
62
+ ```
63
+
64
+ On startup the extension:
65
+
66
+ - creates `config.json` with defaults if missing
67
+ - normalizes/clamps values on load **and writes the normalized config back to disk**
68
+
69
+ ### Common settings
70
+
71
+ - `enabled` (boolean): master on/off switch
72
+ - `windowsOptimized` (boolean): when `true`, shows a one-time warning on non-Windows platforms that audio behavior is best-effort
73
+ - `notificationMode`:
74
+ - `sound-first` (default)
75
+ - `tts-first`
76
+ - `both`
77
+ - `sound-only`
78
+ - Channel toggles:
79
+ - `enableSound` (Windows)
80
+ - `enableTts` (Windows)
81
+ - `enableDesktopNotification` (toast via `node-notifier`)
82
+ - Per-event toggles:
83
+ - `enableIdleNotification`
84
+ - `enablePermissionNotification`
85
+ - `enableQuestionNotification` (only effective when a custom tool named `question` is loaded)
86
+ - `enableErrorNotification`
87
+ - Reminder / follow-ups:
88
+ - `reminderEnabled`, `reminderDelaySeconds`
89
+ - `followUpEnabled`, `maxFollowUps`, `followUpBackoffMultiplier`
90
+ - Debug:
91
+ - `debugLog` (boolean): writes JSONL debug events to the debug log file
92
+
93
+ ### Sound file paths
94
+
95
+ Sound fields (`idleSoundFile`, `permissionSoundFile`, `questionSoundFile`, `errorSoundFile`) may be:
96
+
97
+ - **absolute paths**, or
98
+ - **paths relative to the extension directory** (`~/.pi/agent/extensions/pi-smart-voice-notify/`)
99
+
100
+ The default template uses paths under `assets/`.
101
+
102
+ ## Usage / Commands
103
+
104
+ Command name:
105
+
106
+ ```text
107
+ /voice-notify
108
+ ```
109
+
110
+ - With **no arguments**:
111
+ - in interactive mode: opens the settings modal
112
+ - in non-interactive mode: prints a config summary (the UI is required for the modal)
113
+
114
+ Subcommands:
115
+
116
+ ```text
117
+ /voice-notify status
118
+ /voice-notify reload
119
+ /voice-notify on
120
+ /voice-notify off
121
+ /voice-notify test [idle|permission|question|error]
122
+ ```
123
+
124
+ Behavior notes:
125
+
126
+ - `/voice-notify reload` re-reads `config.json` and resets reminder state.
127
+ - `/voice-notify test ...` bypasses throttling so you can validate your setup quickly.
128
+ - If no custom `question` tool is loaded, question notifications are skipped and `/voice-notify test question` warns.
129
+
130
+ ## Notes (assets & debug)
131
+
132
+ - Notification sound assets live in: `assets/`
133
+ - When `debugLog: true`, debug logs are written under:
134
+ - directory: `~/.pi/agent/extensions/pi-smart-voice-notify/debug/`
135
+ - file: `~/.pi/agent/extensions/pi-smart-voice-notify/debug/pi-smart-voice-notify.log`
136
+
137
+ ## Troubleshooting
138
+
139
+ ### I ran `/voice-notify` but no modal appeared
140
+
141
+ - The settings modal requires **interactive UI mode** (`ctx.hasUI`).
142
+ - In non-interactive contexts, `/voice-notify` prints a summary instead.
143
+
144
+ ### Desktop notifications are not showing
145
+
146
+ - Ensure `enableDesktopNotification` is `true`.
147
+ - This extension uses `node-notifier`. If the underlying platform backend is unavailable, notifications may fail.
148
+ - Turn on `debugLog` and inspect `debug/pi-smart-voice-notify.log` for `desktop.notify.failed` events.
149
+
150
+ ### No sound / no voice on Windows
151
+
152
+ - Sound + SAPI TTS are Windows-only features (`process.platform === "win32"`).
153
+ - The extension invokes `powershell.exe` for sound playback and TTS. If PowerShell is restricted/unavailable, audio will fail.
154
+ - Turn on `debugLog` and search the log for `powershell.exec` entries.
155
+
156
+ ### Question notifications never trigger
157
+
158
+ - Question notifications are only enabled when Pi has a custom tool named `question` loaded.
159
+ - Run `/voice-notify status` and check `questionToolAvailable=true/false`.
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ npm install
165
+ npm run build
166
+ npm run lint
167
+ npm run test
168
+ npm run check
169
+ ```
170
+
171
+ Project requirements:
172
+
173
+ - Node.js `>= 20` (see `package.json`)
174
+
175
+ ## Project Layout
176
+
177
+ - `index.ts` - root extension entrypoint (kept for Pi auto-discovery)
178
+ - `src/index.ts` - extension bootstrap, event hooks, `/voice-notify` command
179
+ - `src/config-store.ts` - config paths, normalization, load/save, debug log path constants
180
+ - `src/notify-audio.ts` - Windows sound + SAPI TTS + monitor wake best-effort helpers
181
+ - `src/desktop-notify.ts` - toast notifications via `node-notifier`
182
+ - `config/config.example.json` - starter config template
183
+ - `assets/` - bundled sound assets referenced by default config
184
+ - `debug/` - created at runtime when debug logging is enabled
185
+
186
+ ## License
187
+
188
+ MIT
Binary file
@@ -0,0 +1,30 @@
1
+ {
2
+ "version": 1,
3
+ "enabled": true,
4
+ "windowsOptimized": true,
5
+ "notificationMode": "sound-first",
6
+ "enableSound": true,
7
+ "enableTts": true,
8
+ "enableDesktopNotification": true,
9
+ "desktopNotificationTimeout": 8,
10
+ "wakeMonitor": true,
11
+ "idleThresholdSeconds": 30,
12
+ "enableIdleNotification": true,
13
+ "enablePermissionNotification": true,
14
+ "enableQuestionNotification": true,
15
+ "enableErrorNotification": true,
16
+ "reminderEnabled": true,
17
+ "reminderDelaySeconds": 30,
18
+ "followUpEnabled": true,
19
+ "maxFollowUps": 3,
20
+ "followUpBackoffMultiplier": 1.5,
21
+ "minNotificationIntervalMs": 1500,
22
+ "suppressIdleAfterError": true,
23
+ "ttsVoice": "Microsoft Zira Desktop",
24
+ "ttsRate": -1,
25
+ "idleSoundFile": "assets/Soft-high-tech-notification-sound-effect.mp3",
26
+ "permissionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
27
+ "questionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
28
+ "errorSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
29
+ "debugLog": false
30
+ }
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import smartVoiceNotifyExtension from "./src/index.js";
2
+
3
+ export default smartVoiceNotifyExtension;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-smart-voice-notify",
3
+ "version": "0.1.0",
4
+ "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "assets",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
21
+ "lint": "npm run build",
22
+ "test": "node --test",
23
+ "check": "npm run lint && npm run test"
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi",
28
+ "pi-extension",
29
+ "voice-notify",
30
+ "windows",
31
+ "desktop-notification"
32
+ ],
33
+ "author": "MasuRii",
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "pi": {
42
+ "extensions": [
43
+ "./index.ts"
44
+ ]
45
+ },
46
+ "peerDependencies": {
47
+ "@mariozechner/pi-coding-agent": "*",
48
+ "@mariozechner/pi-tui": "*"
49
+ },
50
+ "dependencies": {
51
+ "node-notifier": "^10.0.1"
52
+ }
53
+ }
@@ -0,0 +1,308 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+
5
+ import type {
6
+ MessageSet,
7
+ NotificationMode,
8
+ NotificationType,
9
+ SoundFileField,
10
+ VoiceNotifyConfig,
11
+ } from "./types.js";
12
+
13
+ export const EXTENSION_ID = "pi-smart-voice-notify";
14
+ export const STATUS_KEY = "smart-voice-notify";
15
+ export const CONFIG_DIR = join(homedir(), ".pi", "agent", "extensions", EXTENSION_ID);
16
+ export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
17
+ export const DEBUG_DIR = join(CONFIG_DIR, "debug");
18
+ export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_ID}.log`);
19
+
20
+ export const NOTIFICATION_MODES = ["sound-first", "tts-first", "both", "sound-only"] as const;
21
+ export const BOOLEAN_VALUES = ["on", "off"] as const;
22
+ export const REMINDER_DELAY_VALUES = ["10", "20", "30", "45", "60", "90"] as const;
23
+ export const DESKTOP_NOTIFICATION_TIMEOUT_VALUES = ["3", "5", "8", "10", "15", "20", "30"] as const;
24
+ export const IDLE_THRESHOLD_VALUES = ["15", "30", "45", "60", "90", "120"] as const;
25
+ export const MAX_FOLLOW_UP_VALUES = ["1", "2", "3", "4", "5"] as const;
26
+ export const RATE_VALUES = ["-5", "-3", "-1", "0", "1", "3", "5"] as const;
27
+
28
+ export const INLINE_NOTIFY_TEXT: Record<NotificationType, string> = {
29
+ idle: "✅ Agent finished its current task.",
30
+ permission: "⚠️ Action blocked by permission policy.",
31
+ question: "❓ Agent needs your input.",
32
+ error: "❌ Agent encountered an error.",
33
+ };
34
+
35
+ export const SOUND_FILE_FIELD: Record<NotificationType, SoundFileField> = {
36
+ idle: "idleSoundFile",
37
+ permission: "permissionSoundFile",
38
+ question: "questionSoundFile",
39
+ error: "errorSoundFile",
40
+ };
41
+
42
+ export const SOUND_LOOPS: Record<NotificationType, number> = {
43
+ idle: 1,
44
+ permission: 2,
45
+ question: 1,
46
+ error: 2,
47
+ };
48
+
49
+ export const MESSAGE_LIBRARY: Record<NotificationType, MessageSet> = {
50
+ idle: {
51
+ initial: [
52
+ "All done. Your latest task has completed.",
53
+ "Task finished. Ready whenever you are.",
54
+ "Done. Please review the latest result.",
55
+ ],
56
+ reminder: [
57
+ "Reminder: the task is complete and waiting for you.",
58
+ "Heads up, your finished result is still waiting.",
59
+ ],
60
+ },
61
+ permission: {
62
+ initial: [
63
+ "Permission required. Please check your terminal.",
64
+ "I need approval before I can continue.",
65
+ ],
66
+ reminder: [
67
+ "Reminder: permission is still pending.",
68
+ "I am still waiting for your approval.",
69
+ ],
70
+ },
71
+ question: {
72
+ initial: [
73
+ "I have a question for you in the terminal.",
74
+ "Input required. Please answer the pending question.",
75
+ ],
76
+ reminder: [
77
+ "Reminder: I still need your answer.",
78
+ "Question pending. Please respond when ready.",
79
+ ],
80
+ },
81
+ error: {
82
+ initial: [
83
+ "The agent hit an error. Please inspect the latest output.",
84
+ "An error occurred and needs your attention.",
85
+ ],
86
+ reminder: [
87
+ "Reminder: there is still an unresolved error.",
88
+ "The error is still pending your attention.",
89
+ ],
90
+ },
91
+ };
92
+
93
+ export const PERMISSION_HINTS = [
94
+ "permission",
95
+ "not permitted",
96
+ "requires approval",
97
+ "approval",
98
+ "user denied",
99
+ "blocked by",
100
+ ];
101
+
102
+ export const QUESTION_HINTS = ["question", "need your input", "please answer", "requires your input"];
103
+
104
+ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
105
+ version: 1,
106
+ enabled: true,
107
+ windowsOptimized: true,
108
+ notificationMode: "sound-first",
109
+ enableSound: true,
110
+ enableTts: true,
111
+ enableDesktopNotification: true,
112
+ desktopNotificationTimeout: 8,
113
+ wakeMonitor: true,
114
+ idleThresholdSeconds: 30,
115
+ enableIdleNotification: true,
116
+ enablePermissionNotification: true,
117
+ enableQuestionNotification: true,
118
+ enableErrorNotification: true,
119
+ reminderEnabled: true,
120
+ reminderDelaySeconds: 30,
121
+ followUpEnabled: true,
122
+ maxFollowUps: 3,
123
+ followUpBackoffMultiplier: 1.5,
124
+ minNotificationIntervalMs: 1500,
125
+ suppressIdleAfterError: true,
126
+ ttsVoice: "Microsoft Zira Desktop",
127
+ ttsRate: -1,
128
+ idleSoundFile: "assets/Soft-high-tech-notification-sound-effect.mp3",
129
+ permissionSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
130
+ questionSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
131
+ errorSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
132
+ debugLog: false,
133
+ };
134
+
135
+ export function clampInt(value: unknown, fallback: number, min: number, max: number): number {
136
+ if (typeof value !== "number" || Number.isNaN(value)) {
137
+ return fallback;
138
+ }
139
+ return Math.min(max, Math.max(min, Math.trunc(value)));
140
+ }
141
+
142
+ export function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
143
+ if (typeof value !== "number" || Number.isNaN(value)) {
144
+ return fallback;
145
+ }
146
+ return Math.min(max, Math.max(min, value));
147
+ }
148
+
149
+ function boolOrDefault(value: unknown, fallback: boolean): boolean {
150
+ if (typeof value === "boolean") {
151
+ return value;
152
+ }
153
+ return fallback;
154
+ }
155
+
156
+ function stringOrDefault(value: unknown, fallback: string): string {
157
+ if (typeof value === "string") {
158
+ const normalized = value.trim();
159
+ return normalized.length > 0 ? normalized : fallback;
160
+ }
161
+ return fallback;
162
+ }
163
+
164
+ export function normalizeMode(value: unknown): NotificationMode {
165
+ if (typeof value === "string" && NOTIFICATION_MODES.includes(value as NotificationMode)) {
166
+ return value as NotificationMode;
167
+ }
168
+ return DEFAULT_CONFIG.notificationMode;
169
+ }
170
+
171
+ export function normalizeConfig(raw: unknown): VoiceNotifyConfig {
172
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
173
+ return {
174
+ version: 1,
175
+ enabled: boolOrDefault(record.enabled, DEFAULT_CONFIG.enabled),
176
+ windowsOptimized: boolOrDefault(record.windowsOptimized, DEFAULT_CONFIG.windowsOptimized),
177
+ notificationMode: normalizeMode(record.notificationMode),
178
+ enableSound: boolOrDefault(record.enableSound, DEFAULT_CONFIG.enableSound),
179
+ enableTts: boolOrDefault(record.enableTts, DEFAULT_CONFIG.enableTts),
180
+ enableDesktopNotification: boolOrDefault(
181
+ record.enableDesktopNotification,
182
+ DEFAULT_CONFIG.enableDesktopNotification,
183
+ ),
184
+ desktopNotificationTimeout: clampInt(
185
+ record.desktopNotificationTimeout,
186
+ DEFAULT_CONFIG.desktopNotificationTimeout,
187
+ 1,
188
+ 60,
189
+ ),
190
+ wakeMonitor: boolOrDefault(record.wakeMonitor, DEFAULT_CONFIG.wakeMonitor),
191
+ idleThresholdSeconds: clampInt(record.idleThresholdSeconds, DEFAULT_CONFIG.idleThresholdSeconds, 5, 600),
192
+ enableIdleNotification: boolOrDefault(record.enableIdleNotification, DEFAULT_CONFIG.enableIdleNotification),
193
+ enablePermissionNotification: boolOrDefault(
194
+ record.enablePermissionNotification,
195
+ DEFAULT_CONFIG.enablePermissionNotification,
196
+ ),
197
+ enableQuestionNotification: boolOrDefault(record.enableQuestionNotification, DEFAULT_CONFIG.enableQuestionNotification),
198
+ enableErrorNotification: boolOrDefault(record.enableErrorNotification, DEFAULT_CONFIG.enableErrorNotification),
199
+ reminderEnabled: boolOrDefault(record.reminderEnabled, DEFAULT_CONFIG.reminderEnabled),
200
+ reminderDelaySeconds: clampInt(record.reminderDelaySeconds, DEFAULT_CONFIG.reminderDelaySeconds, 5, 300),
201
+ followUpEnabled: boolOrDefault(record.followUpEnabled, DEFAULT_CONFIG.followUpEnabled),
202
+ maxFollowUps: clampInt(record.maxFollowUps, DEFAULT_CONFIG.maxFollowUps, 1, 10),
203
+ followUpBackoffMultiplier: clampNumber(
204
+ record.followUpBackoffMultiplier,
205
+ DEFAULT_CONFIG.followUpBackoffMultiplier,
206
+ 1,
207
+ 5,
208
+ ),
209
+ minNotificationIntervalMs: clampInt(
210
+ record.minNotificationIntervalMs,
211
+ DEFAULT_CONFIG.minNotificationIntervalMs,
212
+ 0,
213
+ 60_000,
214
+ ),
215
+ suppressIdleAfterError: boolOrDefault(record.suppressIdleAfterError, DEFAULT_CONFIG.suppressIdleAfterError),
216
+ ttsVoice: stringOrDefault(record.ttsVoice, DEFAULT_CONFIG.ttsVoice),
217
+ ttsRate: clampInt(record.ttsRate, DEFAULT_CONFIG.ttsRate, -10, 10),
218
+ idleSoundFile: stringOrDefault(record.idleSoundFile, DEFAULT_CONFIG.idleSoundFile),
219
+ permissionSoundFile: stringOrDefault(record.permissionSoundFile, DEFAULT_CONFIG.permissionSoundFile),
220
+ questionSoundFile: stringOrDefault(record.questionSoundFile, DEFAULT_CONFIG.questionSoundFile),
221
+ errorSoundFile: stringOrDefault(record.errorSoundFile, DEFAULT_CONFIG.errorSoundFile),
222
+ debugLog: boolOrDefault(record.debugLog, DEFAULT_CONFIG.debugLog),
223
+ };
224
+ }
225
+
226
+ export function ensureConfigDirectory(): void {
227
+ if (!existsSync(CONFIG_DIR)) {
228
+ mkdirSync(CONFIG_DIR, { recursive: true });
229
+ }
230
+ }
231
+
232
+ export function ensureDebugDirectory(): void {
233
+ if (!existsSync(DEBUG_DIR)) {
234
+ mkdirSync(DEBUG_DIR, { recursive: true });
235
+ }
236
+ }
237
+
238
+ export function readConfigFromDisk(): VoiceNotifyConfig {
239
+ ensureConfigDirectory();
240
+ if (!existsSync(CONFIG_PATH)) {
241
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
242
+ return { ...DEFAULT_CONFIG };
243
+ }
244
+
245
+ try {
246
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
247
+ const parsed = JSON.parse(raw) as unknown;
248
+ const normalized = normalizeConfig(parsed);
249
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
250
+ return normalized;
251
+ } catch {
252
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
253
+ return { ...DEFAULT_CONFIG };
254
+ }
255
+ }
256
+
257
+ export function writeConfigToDisk(config: VoiceNotifyConfig): void {
258
+ ensureConfigDirectory();
259
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
260
+ }
261
+
262
+ export function boolValue(value: string): boolean {
263
+ return value === "on";
264
+ }
265
+
266
+ export function isWindows(): boolean {
267
+ return process.platform === "win32";
268
+ }
269
+
270
+ export function resolveSoundFile(config: VoiceNotifyConfig, type: NotificationType): string | null {
271
+ const field = SOUND_FILE_FIELD[type];
272
+ const value = config[field];
273
+ if (!value.trim()) {
274
+ return null;
275
+ }
276
+ if (isAbsolute(value)) {
277
+ return value;
278
+ }
279
+ return join(CONFIG_DIR, value);
280
+ }
281
+
282
+ export function summarizeConfig(config: VoiceNotifyConfig): string {
283
+ return [
284
+ `enabled=${config.enabled}`,
285
+ `mode=${config.notificationMode}`,
286
+ `sound=${config.enableSound}`,
287
+ `tts=${config.enableTts}`,
288
+ `desktopNotify=${config.enableDesktopNotification}`,
289
+ `desktopNotifyTimeout=${config.desktopNotificationTimeout}s`,
290
+ `wakeMonitor=${config.wakeMonitor}`,
291
+ `idleThreshold=${config.idleThresholdSeconds}s`,
292
+ `reminder=${config.reminderEnabled}`,
293
+ `reminderDelay=${config.reminderDelaySeconds}s`,
294
+ `followUps=${config.followUpEnabled ? config.maxFollowUps : 0}`,
295
+ `sapiVoice=${config.ttsVoice}`,
296
+ `sapiRate=${config.ttsRate}`,
297
+ `debugLog=${config.debugLog}`,
298
+ `debugLogPath=${DEBUG_LOG_PATH}`,
299
+ `config=${CONFIG_PATH}`,
300
+ ].join("\n");
301
+ }
302
+
303
+ export function isNotificationEnabled(config: VoiceNotifyConfig, type: NotificationType): boolean {
304
+ if (type === "idle") return config.enableIdleNotification;
305
+ if (type === "permission") return config.enablePermissionNotification;
306
+ if (type === "question") return config.enableQuestionNotification;
307
+ return config.enableErrorNotification;
308
+ }