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 +8 -0
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/assets/Machine-alert-beep-sound-effect.mp3 +0 -0
- package/assets/Soft-high-tech-notification-sound-effect.mp3 +0 -0
- package/assets/pi-smart-voice-notify.png +0 -0
- package/config/config.example.json +30 -0
- package/index.ts +3 -0
- package/package.json +53 -0
- package/src/config-store.ts +308 -0
- package/src/desktop-notify.ts +177 -0
- package/src/index.ts +860 -0
- package/src/logging.ts +73 -0
- package/src/notify-audio.ts +414 -0
- package/src/types.ts +52 -0
- package/src/zellij-modal.ts +999 -0
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
|
+

|
|
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
|
|
Binary file
|
|
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
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
|
+
}
|