pi-smart-voice-notify 0.1.1 → 0.2.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 +21 -0
- package/README.md +68 -24
- package/config/config.example.json +142 -2
- package/package.json +5 -5
- package/src/abortable-command.test.ts +45 -0
- package/src/abortable-command.ts +151 -0
- package/src/ai-messages.ts +479 -0
- package/src/config-store.ts +765 -32
- package/src/focus-detect.ts +395 -0
- package/src/index.test.ts +429 -0
- package/src/index.ts +826 -288
- package/src/linux.ts +344 -0
- package/src/notify-audio.ts +25 -8
- package/src/per-project-sound.ts +338 -0
- package/src/permission-forwarding-watcher.ts +383 -0
- package/src/reminder-playback.test.ts +62 -0
- package/src/reminder-playback.ts +148 -0
- package/src/sound-theme.ts +582 -0
- package/src/tts.ts +709 -0
- package/src/types/linux.ts +30 -0
- package/src/types/msedge-tts.d.ts +14 -0
- package/src/types/tts.ts +95 -0
- package/src/types.ts +137 -0
- package/src/webhook.ts +668 -0
- package/src/zellij-modal.ts +999 -999
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-03-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Added multi-engine TTS support with auto selection plus Edge, espeak-ng, ElevenLabs, OpenAI-compatible, and SAPI backends.
|
|
7
|
+
- Added forwarded permission request watching, reminder playback control, Linux wake/focus helpers, per-project sound discovery, webhook delivery, and AI-generated notification message support.
|
|
8
|
+
- Added targeted tests for abortable commands, reminder playback, and forwarded-permission notification flows.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Expanded configuration normalization and example config to cover nested reminder, webhook, AI-message, focus-detection, and sound-theme settings while keeping legacy keys compatible.
|
|
12
|
+
- Improved notification orchestration so reminder scheduling, focused-terminal suppression, and desktop/audio delivery can share richer runtime context.
|
|
13
|
+
- Switched the test runner to Node's built-in TypeScript stripping support and raised the documented runtime baseline to Node.js 24.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Restored the package test workflow so the published repo can run its checked-in TypeScript tests without a missing loader file.
|
|
17
|
+
- Updated README documentation to cover the new engines, watchers, webhook/AI integrations, and sound-theme behavior.
|
|
18
|
+
|
|
19
|
+
## [0.1.2] - 2026-03-04
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Use absolute GitHub raw URL for README image to fix npm display
|
|
23
|
+
|
|
3
24
|
## [0.1.1] - 2026-03-04
|
|
4
25
|
|
|
5
26
|
### Changed
|
package/README.md
CHANGED
|
@@ -2,31 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
Windows-optimized smart notification extension for the Pi coding agent.
|
|
4
4
|
|
|
5
|
-
**pi-smart-voice-notify** monitors Pi session and tool events to alert you via **
|
|
5
|
+
**pi-smart-voice-notify** monitors Pi session and tool events to alert you via **multi-engine TTS**, **sound playback**, **desktop toast notifications**, and optional **webhook/AI-assisted messaging** when the agent requires your attention.
|
|
6
6
|
|
|
7
|
-

|
|
7
|
+

|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Multi-channel notifications**
|
|
12
|
-
- **Sound** –
|
|
13
|
-
- **Voice** –
|
|
14
|
-
- **Desktop toasts** –
|
|
12
|
+
- **Sound** – local sound playback with fallback beeps and reusable reminder playback control
|
|
13
|
+
- **Voice** – auto-selectable TTS engines: Edge, espeak-ng, ElevenLabs, OpenAI-compatible, and Windows SAPI
|
|
14
|
+
- **Desktop toasts** – cross-platform notifications via `node-notifier` (Windows/macOS/Linux)
|
|
15
|
+
- **Webhook delivery** – optional Discord or generic HTTP webhook notifications
|
|
15
16
|
|
|
16
17
|
- **Intelligent event detection**
|
|
17
18
|
- Task completion (idle)
|
|
18
|
-
-
|
|
19
|
+
- Direct permission blocks plus forwarded subagent permission requests
|
|
19
20
|
- Questions requiring input (when custom `question` tool is loaded)
|
|
20
21
|
- Errors
|
|
21
22
|
|
|
22
23
|
- **Reminder system**
|
|
23
|
-
- Configurable reminder delays with follow-up scheduling
|
|
24
|
+
- Configurable per-event reminder delays with follow-up scheduling
|
|
24
25
|
- Exponential backoff multiplier for follow-ups
|
|
25
|
-
- Auto-cancel reminders on user activity
|
|
26
|
+
- Auto-cancel reminders on user activity or resolution
|
|
26
27
|
|
|
27
|
-
- **
|
|
28
|
+
- **Focus and wake handling**
|
|
28
29
|
- Wakes display from sleep before notifications
|
|
29
|
-
-
|
|
30
|
+
- Optional focused-terminal suppression on Linux
|
|
31
|
+
- Cross-platform wake strategies for Windows, macOS, and Linux sessions
|
|
32
|
+
|
|
33
|
+
- **Sound customization**
|
|
34
|
+
- Direct per-event sound files
|
|
35
|
+
- Theme-based sound selection and optional per-project sound discovery
|
|
36
|
+
- Theme randomization and default volume controls
|
|
37
|
+
|
|
38
|
+
- **AI message generation**
|
|
39
|
+
- Optional AI-generated notification text with caching and template fallback
|
|
30
40
|
|
|
31
41
|
- **Interactive settings UI**
|
|
32
42
|
- `/voice-notify` command opens a modal for live configuration
|
|
@@ -93,14 +103,18 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
93
103
|
| Option | Type | Default | Description |
|
|
94
104
|
|--------|------|---------|-------------|
|
|
95
105
|
| `enabled` | boolean | `true` | Master on/off switch |
|
|
96
|
-
| `windowsOptimized` | boolean | `true` | Show
|
|
106
|
+
| `windowsOptimized` | boolean | `true` | Show a compatibility notice on platforms other than Windows/Linux |
|
|
97
107
|
| `notificationMode` | string | `"sound-first"` | Mode: `sound-first`, `tts-first`, `both`, `sound-only` |
|
|
98
|
-
| `enableSound` | boolean | `true` | Enable sound playback
|
|
99
|
-
| `enableTts` | boolean | `true` | Enable text-to-speech
|
|
108
|
+
| `enableSound` | boolean | `true` | Enable sound playback |
|
|
109
|
+
| `enableTts` | boolean | `true` | Enable text-to-speech delivery |
|
|
110
|
+
| `ttsEngine` | string | `"auto"` | Engine: `auto`, `edge`, `espeak-ng`, `elevenlabs`, `openai`, `sapi` |
|
|
100
111
|
| `enableDesktopNotification` | boolean | `true` | Enable desktop toast notifications |
|
|
101
112
|
| `desktopNotificationTimeout` | number | `8` | Toast display duration in seconds (1–60) |
|
|
102
113
|
| `wakeMonitor` | boolean | `true` | Wake display from sleep before notifying |
|
|
103
114
|
| `idleThresholdSeconds` | number | `30` | System idle threshold before waking monitor (5–600) |
|
|
115
|
+
| `skipWhenFocused` | boolean | `false` | Suppress notifications while the active Linux terminal/editor is focused |
|
|
116
|
+
|
|
117
|
+
`windowsOptimized` keeps compatibility messaging for platforms that do not have Linux/Windows-native behavior. Linux users no longer see this notice.
|
|
104
118
|
|
|
105
119
|
### Event Toggles
|
|
106
120
|
|
|
@@ -108,12 +122,17 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
108
122
|
|--------|------|---------|-------------|
|
|
109
123
|
| `enableIdleNotification` | boolean | `true` | Notify when agent finishes a task |
|
|
110
124
|
| `enablePermissionNotification` | boolean | `true` | Notify on permission blocks |
|
|
125
|
+
| `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
|
|
126
|
+
| `includeForwardedPermissionAgentName` | boolean | `true` | Include sanitized requester agent name in forwarded permission notification text |
|
|
127
|
+
| `watchLegacyForwardedPermissionPath` | boolean | `true` | Also watch legacy `~/.pi/agent/permission-forwarding/requests` when present |
|
|
111
128
|
| `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
|
|
112
129
|
| `enableErrorNotification` | boolean | `true` | Notify on errors |
|
|
113
130
|
| `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
|
|
114
131
|
|
|
115
132
|
*Question notifications only work when a custom `question` tool is loaded.
|
|
116
133
|
|
|
134
|
+
Forwarded permission watcher notifications use privacy-safe text and never include raw forwarded `message` content.
|
|
135
|
+
|
|
117
136
|
### Reminder Settings
|
|
118
137
|
|
|
119
138
|
| Option | Type | Default | Description |
|
|
@@ -124,12 +143,20 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
124
143
|
| `maxFollowUps` | number | `3` | Maximum follow-up count (1–10) |
|
|
125
144
|
| `followUpBackoffMultiplier` | number | `1.5` | Delay multiplier for each follow-up |
|
|
126
145
|
|
|
127
|
-
### TTS Settings
|
|
146
|
+
### TTS Settings
|
|
128
147
|
|
|
129
148
|
| Option | Type | Default | Description |
|
|
130
149
|
|--------|------|---------|-------------|
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
150
|
+
| `voice` | string | `"Microsoft Zira Desktop"` | Generic preferred voice label |
|
|
151
|
+
| `rate` | number | `-1` | Generic speaking rate |
|
|
152
|
+
| `volume` | number | `85` | Preferred playback volume percentage |
|
|
153
|
+
| `fallbackChain` | array | `["edge", "espeak-ng", "sapi"]` | TTS engines tried when `ttsEngine` is `auto` |
|
|
154
|
+
| `ttsVoice` | string | `"Microsoft Zira Desktop"` | Legacy SAPI-compatible alias |
|
|
155
|
+
| `ttsRate` | number | `-1` | Legacy SAPI-compatible alias |
|
|
156
|
+
| `edgeVoice` | string | `"en-US-JennyNeural"` | Microsoft Edge voice |
|
|
157
|
+
| `espeakVoice` | string | `"en"` | `espeak-ng` voice for Linux fallback |
|
|
158
|
+
| `elevenLabsVoiceId` | string | `"cgSgspJ2msm6clMCkdW9"` | ElevenLabs voice id |
|
|
159
|
+
| `openaiTtsVoice` | string | `"alloy"` | OpenAI-compatible voice |
|
|
133
160
|
|
|
134
161
|
### Sound File Paths
|
|
135
162
|
|
|
@@ -142,10 +169,18 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
142
169
|
|
|
143
170
|
Paths can be absolute or relative to the extension directory.
|
|
144
171
|
|
|
145
|
-
###
|
|
172
|
+
### Sound, Webhook, and AI Settings
|
|
146
173
|
|
|
147
174
|
| Option | Type | Default | Description |
|
|
148
175
|
|--------|------|---------|-------------|
|
|
176
|
+
| `themeName` | string | `"default"` | Preferred sound theme name |
|
|
177
|
+
| `enablePerProjectSounds` | boolean | `false` | Search the current project for matching notification sounds |
|
|
178
|
+
| `randomizeThemeSounds` | boolean | `true` | Randomize among matching themed sounds |
|
|
179
|
+
| `webhook.enabled` | boolean | `false` | Enable Discord/generic webhook delivery |
|
|
180
|
+
| `webhook.events` | array | `["idle", "permission", "question", "error"]` | Notification types sent through webhooks |
|
|
181
|
+
| `aiMessages.enabled` | boolean | `false` | Enable AI-generated notification copy |
|
|
182
|
+
| `aiMessages.model` | string | `"llama3"` | Model id used for AI notification generation |
|
|
183
|
+
| `aiMessages.caching.enabled` | boolean | `true` | Cache generated messages to reduce repeat calls |
|
|
149
184
|
| `minNotificationIntervalMs` | number | `1500` | Throttle interval between same-type notifications |
|
|
150
185
|
| `debugLog` | boolean | `false` | Enable debug logging to file |
|
|
151
186
|
|
|
@@ -196,10 +231,19 @@ Ensure system idle time exceeds `idleThresholdSeconds` for wake to trigger.
|
|
|
196
231
|
index.ts → Extension entrypoint (re-exports src/index.ts)
|
|
197
232
|
src/
|
|
198
233
|
├── index.ts → Main extension logic, event handlers, command registration
|
|
199
|
-
├── config-store.ts → Config paths, normalization, load/save utilities
|
|
200
|
-
├── types.ts →
|
|
201
|
-
├── notify-audio.ts →
|
|
234
|
+
├── config-store.ts → Config paths, normalization, env overrides, load/save utilities
|
|
235
|
+
├── types.ts → Shared configuration and runtime types
|
|
236
|
+
├── notify-audio.ts → Audio dispatch and Windows/SAPI playback integration
|
|
237
|
+
├── tts.ts → Multi-engine TTS selection and speech dispatch
|
|
202
238
|
├── desktop-notify.ts → Desktop toast notifications via node-notifier
|
|
239
|
+
├── permission-forwarding-watcher.ts → Watches forwarded permission request directories
|
|
240
|
+
├── reminder-playback.ts → Deduplicates/cancels overlapping reminder playback
|
|
241
|
+
├── sound-theme.ts → Theme and sound file resolution
|
|
242
|
+
├── per-project-sound.ts → Project-local sound discovery helpers
|
|
243
|
+
├── webhook.ts → Discord and generic HTTP webhook delivery
|
|
244
|
+
├── ai-messages.ts → AI-generated notification message generation and caching
|
|
245
|
+
├── linux.ts → Linux wake/audio/focus helpers
|
|
246
|
+
├── focus-detect.ts → Terminal focus detection cache
|
|
203
247
|
├── logging.ts → Debug logger with JSONL output
|
|
204
248
|
└── zellij-modal.ts → Settings modal UI components
|
|
205
249
|
```
|
|
@@ -232,12 +276,12 @@ Events include: config changes, notifications triggered, audio dispatch, reminde
|
|
|
232
276
|
```bash
|
|
233
277
|
npm install
|
|
234
278
|
npm run build # TypeScript compilation
|
|
235
|
-
npm run lint #
|
|
236
|
-
npm run test #
|
|
237
|
-
npm run check #
|
|
279
|
+
npm run lint # Alias for build
|
|
280
|
+
npm run test # Node test runner with built-in TypeScript stripping
|
|
281
|
+
npm run check # build + test
|
|
238
282
|
```
|
|
239
283
|
|
|
240
|
-
**Requirements:** Node.js ≥
|
|
284
|
+
**Requirements:** Node.js ≥ 24
|
|
241
285
|
|
|
242
286
|
## License
|
|
243
287
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
"_comment_0": "pi-smart-voice-notify example config. Copy this file to ~/.pi/agent/extensions/pi-smart-voice-notify/config.json and edit values.",
|
|
3
|
+
"_comment_1": "Secrets should be provided via environment variables (ELEVENLABS_API_KEY, OPENAI_TTS_API_KEY, OPENAI_API_KEY, DISCORD_WEBHOOK_URL, WEBHOOK_URL).",
|
|
4
|
+
|
|
2
5
|
"version": 1,
|
|
3
6
|
"enabled": true,
|
|
4
7
|
"windowsOptimized": true,
|
|
8
|
+
|
|
9
|
+
"_comment_notifications": "Notification delivery and channel toggles.",
|
|
5
10
|
"notificationMode": "sound-first",
|
|
6
11
|
"enableSound": true,
|
|
7
12
|
"enableTts": true,
|
|
@@ -11,20 +16,155 @@
|
|
|
11
16
|
"idleThresholdSeconds": 30,
|
|
12
17
|
"enableIdleNotification": true,
|
|
13
18
|
"enablePermissionNotification": true,
|
|
19
|
+
"enableForwardedPermissionWatcher": true,
|
|
20
|
+
"includeForwardedPermissionAgentName": true,
|
|
21
|
+
"watchLegacyForwardedPermissionPath": true,
|
|
14
22
|
"enableQuestionNotification": true,
|
|
15
23
|
"enableErrorNotification": true,
|
|
24
|
+
"minNotificationIntervalMs": 1500,
|
|
25
|
+
"suppressIdleAfterError": true,
|
|
26
|
+
|
|
27
|
+
"_comment_focus": "Focus detection (Linux): when true, suppress notification while terminal/editor is focused.",
|
|
28
|
+
"skipWhenFocused": false,
|
|
29
|
+
"focusCacheTtl": 400,
|
|
30
|
+
"focusCacheTtlMs": 400,
|
|
31
|
+
|
|
32
|
+
"_comment_reminders": "Legacy reminder keys remain supported. New nested reminderIntervals/reminderEscalation are preferred.",
|
|
16
33
|
"reminderEnabled": true,
|
|
17
34
|
"reminderDelaySeconds": 30,
|
|
18
35
|
"followUpEnabled": true,
|
|
19
36
|
"maxFollowUps": 3,
|
|
20
37
|
"followUpBackoffMultiplier": 1.5,
|
|
21
|
-
"
|
|
22
|
-
|
|
38
|
+
"reminderIntervals": {
|
|
39
|
+
"_comment": "Per-event reminder delays (seconds).",
|
|
40
|
+
"defaultSeconds": 30,
|
|
41
|
+
"idleSeconds": 30,
|
|
42
|
+
"permissionSeconds": 20,
|
|
43
|
+
"questionSeconds": 25,
|
|
44
|
+
"errorSeconds": 20
|
|
45
|
+
},
|
|
46
|
+
"reminderEscalation": {
|
|
47
|
+
"_comment": "Escalation behavior for repeat reminders.",
|
|
48
|
+
"enabled": true,
|
|
49
|
+
"maxFollowUps": 3,
|
|
50
|
+
"backoffMultiplier": 1.5
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
"_comment_tts_core": "Generic TTS controls.",
|
|
54
|
+
"ttsEngine": "auto",
|
|
55
|
+
"voice": "Microsoft Zira Desktop",
|
|
56
|
+
"rate": -1,
|
|
57
|
+
"volume": 85,
|
|
58
|
+
"fallbackChain": ["edge", "espeak-ng", "sapi"],
|
|
59
|
+
"commandTimeoutMs": 30000,
|
|
60
|
+
|
|
61
|
+
"_comment_tts_legacy": "Legacy SAPI-compatible aliases (kept for backward compatibility).",
|
|
23
62
|
"ttsVoice": "Microsoft Zira Desktop",
|
|
24
63
|
"ttsRate": -1,
|
|
64
|
+
"sapiVoice": "Microsoft Zira Desktop",
|
|
65
|
+
"sapiRate": -1,
|
|
66
|
+
"sapiPitch": "medium",
|
|
67
|
+
"sapiVolume": "loud",
|
|
68
|
+
|
|
69
|
+
"_comment_tts_edge": "Edge TTS settings.",
|
|
70
|
+
"edgeVoice": "en-US-JennyNeural",
|
|
71
|
+
"edgeRate": "+10%",
|
|
72
|
+
"edgePitch": "+0Hz",
|
|
73
|
+
"edgeVolume": "+0%",
|
|
74
|
+
|
|
75
|
+
"_comment_tts_espeak": "espeak-ng settings (Linux fallback).",
|
|
76
|
+
"espeakVoice": "en",
|
|
77
|
+
"espeakRate": 175,
|
|
78
|
+
"espeakPitch": 50,
|
|
79
|
+
|
|
80
|
+
"_comment_tts_elevenlabs": "ElevenLabs API settings. Keep api key empty here and use ELEVENLABS_API_KEY env var.",
|
|
81
|
+
"elevenLabsApiKey": "",
|
|
82
|
+
"elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
|
|
83
|
+
"elevenLabsModel": "eleven_turbo_v2_5",
|
|
84
|
+
"elevenLabsStability": 0.5,
|
|
85
|
+
"elevenLabsSimilarity": 0.75,
|
|
86
|
+
"elevenLabsStyle": 0.5,
|
|
87
|
+
|
|
88
|
+
"_comment_tts_openai": "OpenAI-compatible TTS endpoint settings. Keep api key empty here and use OPENAI_TTS_API_KEY/OPENAI_API_KEY env vars.",
|
|
89
|
+
"openaiTtsEndpoint": "",
|
|
90
|
+
"openaiTtsApiKey": "",
|
|
91
|
+
"openaiTtsModel": "tts-1",
|
|
92
|
+
"openaiTtsVoice": "alloy",
|
|
93
|
+
"openaiTtsFormat": "mp3",
|
|
94
|
+
"openaiTtsSpeed": 1,
|
|
95
|
+
|
|
96
|
+
"_comment_sounds": "Direct sound file fallbacks.",
|
|
25
97
|
"idleSoundFile": "assets/Soft-high-tech-notification-sound-effect.mp3",
|
|
26
98
|
"permissionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
27
99
|
"questionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
28
100
|
"errorSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
101
|
+
|
|
102
|
+
"_comment_sound_theme": "Sound theme settings.",
|
|
103
|
+
"themePath": "",
|
|
104
|
+
"themeName": "default",
|
|
105
|
+
"themesRootPath": "",
|
|
106
|
+
"themeConfigPath": "",
|
|
107
|
+
"customSoundDirectories": [],
|
|
108
|
+
"perProjectSounds": false,
|
|
109
|
+
"enablePerProjectSounds": false,
|
|
110
|
+
"randomizeThemeSounds": true,
|
|
111
|
+
"themeDefaultVolume": 100,
|
|
112
|
+
|
|
113
|
+
"_comment_webhook": "Webhook notifications (Discord and generic HTTP webhooks).",
|
|
114
|
+
"webhook": {
|
|
115
|
+
"enabled": false,
|
|
116
|
+
"discordUrl": "",
|
|
117
|
+
"genericUrl": "",
|
|
118
|
+
"events": ["idle", "permission", "question", "error"],
|
|
119
|
+
"mentionOnPermission": false,
|
|
120
|
+
"username": "Pi Smart Notify",
|
|
121
|
+
"minIntervalMs": 1500,
|
|
122
|
+
"maxRetries": 3,
|
|
123
|
+
"requestTimeoutMs": 8000
|
|
124
|
+
},
|
|
125
|
+
"_comment_webhook_legacy": "Legacy flat webhook keys (kept for backward compatibility).",
|
|
126
|
+
"enableWebhook": false,
|
|
127
|
+
"webhookEnabled": false,
|
|
128
|
+
"discordWebhookUrl": "",
|
|
129
|
+
"genericWebhookUrl": "",
|
|
130
|
+
"webhookEvents": ["idle", "permission", "question", "error"],
|
|
131
|
+
|
|
132
|
+
"_comment_ai": "AI-generated message settings. Keep api key empty here and use PI_SMART_NOTIFY_AI_API_KEY or OPENAI_API_KEY env var.",
|
|
133
|
+
"aiMessages": {
|
|
134
|
+
"enabled": false,
|
|
135
|
+
"endpoint": "http://localhost:11434/v1",
|
|
136
|
+
"model": "llama3",
|
|
137
|
+
"apiKey": "",
|
|
138
|
+
"timeoutMs": 15000,
|
|
139
|
+
"temperature": 0.7,
|
|
140
|
+
"maxTokens": 120,
|
|
141
|
+
"fallbackToTemplates": true,
|
|
142
|
+
"personality": "helpful assistant",
|
|
143
|
+
"tone": "friendly and concise",
|
|
144
|
+
"caching": {
|
|
145
|
+
"enabled": true,
|
|
146
|
+
"ttlMs": 60000,
|
|
147
|
+
"maxEntries": 200
|
|
148
|
+
},
|
|
149
|
+
"templates": {}
|
|
150
|
+
},
|
|
151
|
+
"_comment_ai_legacy": "Legacy flat AI keys (kept for backward compatibility).",
|
|
152
|
+
"enableAIMessages": false,
|
|
153
|
+
"aiEndpoint": "http://localhost:11434/v1",
|
|
154
|
+
"aiModel": "llama3",
|
|
155
|
+
"aiApiKey": "",
|
|
156
|
+
"aiTimeoutMs": 15000,
|
|
157
|
+
"aiTemperature": 0.7,
|
|
158
|
+
"aiMaxTokens": 120,
|
|
159
|
+
"aiFallbackToTemplates": true,
|
|
160
|
+
"personality": "helpful assistant",
|
|
161
|
+
"tone": "friendly and concise",
|
|
162
|
+
"aiPersonality": "helpful assistant",
|
|
163
|
+
"aiTone": "friendly and concise",
|
|
164
|
+
"enableMessageCache": true,
|
|
165
|
+
"messageCacheTtlMs": 60000,
|
|
166
|
+
"maxCacheEntries": 200,
|
|
167
|
+
"aiTemplates": {},
|
|
168
|
+
|
|
29
169
|
"debugLog": false
|
|
30
170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-smart-voice-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --
|
|
20
|
+
"build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
|
|
21
21
|
"lint": "npm run build",
|
|
22
|
-
"test": "node --test",
|
|
23
|
-
"check": "npm run
|
|
22
|
+
"test": "node --import ./test-ts-register.mjs --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
|
|
23
|
+
"check": "npm run build && npm run test"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
26
26
|
"pi-package",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"author": "MasuRii",
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"engines": {
|
|
36
|
-
"node": ">=
|
|
36
|
+
"node": ">=24"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { runAbortableCommand } from "./abortable-command.ts";
|
|
5
|
+
|
|
6
|
+
test("runAbortableCommand returns an aborted result when the signal is already aborted", async () => {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
controller.abort();
|
|
9
|
+
|
|
10
|
+
const result = await runAbortableCommand(process.execPath, ["-e", "process.exit(0)"], {
|
|
11
|
+
signal: controller.signal,
|
|
12
|
+
timeoutMs: 1_000,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
assert.equal(result.aborted, true);
|
|
16
|
+
assert.equal(result.code, 1);
|
|
17
|
+
assert.match(result.errorMessage ?? "", /aborted before start/i);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("runAbortableCommand stops an in-flight process when aborted", async () => {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
const command = runAbortableCommand(
|
|
24
|
+
process.execPath,
|
|
25
|
+
[
|
|
26
|
+
"-e",
|
|
27
|
+
"process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(0), 10000);",
|
|
28
|
+
],
|
|
29
|
+
{
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
timeoutMs: 5_000,
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
controller.abort();
|
|
37
|
+
}, 100);
|
|
38
|
+
|
|
39
|
+
const result = await command;
|
|
40
|
+
const elapsedMs = Date.now() - startedAt;
|
|
41
|
+
|
|
42
|
+
assert.equal(result.aborted, true);
|
|
43
|
+
assert.equal(result.timedOut, false);
|
|
44
|
+
assert.ok(elapsedMs < 4_000, `Expected aborted command to stop quickly, received ${elapsedMs}ms`);
|
|
45
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface AbortableCommandOptions {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AbortableCommandResult {
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
timedOut: boolean;
|
|
15
|
+
aborted: boolean;
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stringifyError(error: unknown): string {
|
|
20
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
21
|
+
return error.message;
|
|
22
|
+
}
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildCommandString(command: string, args: readonly string[]): string {
|
|
27
|
+
return args.length > 0 ? `${command} ${args.join(" ")}` : command;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void {
|
|
31
|
+
if (child.killed) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
child.kill();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
41
|
+
} catch {
|
|
42
|
+
// noop
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runAbortableCommand(
|
|
47
|
+
command: string,
|
|
48
|
+
args: readonly string[] = [],
|
|
49
|
+
options: AbortableCommandOptions = {},
|
|
50
|
+
): Promise<AbortableCommandResult> {
|
|
51
|
+
const normalizedCommand = command.trim();
|
|
52
|
+
if (!normalizedCommand) {
|
|
53
|
+
throw new Error("runAbortableCommand: command must be a non-empty string");
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(args)) {
|
|
56
|
+
throw new Error("runAbortableCommand: args must be an array of strings");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const commandLabel = buildCommandString(normalizedCommand, args);
|
|
60
|
+
if (options.signal?.aborted) {
|
|
61
|
+
return {
|
|
62
|
+
code: 1,
|
|
63
|
+
stdout: "",
|
|
64
|
+
stderr: "",
|
|
65
|
+
timedOut: false,
|
|
66
|
+
aborted: true,
|
|
67
|
+
errorMessage: `Command aborted before start: ${commandLabel}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return await new Promise<AbortableCommandResult>((resolve) => {
|
|
72
|
+
const child = spawn(normalizedCommand, [...args], {
|
|
73
|
+
env: options.env ?? process.env,
|
|
74
|
+
cwd: options.cwd,
|
|
75
|
+
});
|
|
76
|
+
let stdout = "";
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
let aborted = false;
|
|
80
|
+
let spawnError: Error | null = null;
|
|
81
|
+
let forceKillTimer: NodeJS.Timeout | null = null;
|
|
82
|
+
|
|
83
|
+
const cleanup = (): void => {
|
|
84
|
+
if (timeoutTimer) {
|
|
85
|
+
clearTimeout(timeoutTimer);
|
|
86
|
+
}
|
|
87
|
+
if (forceKillTimer) {
|
|
88
|
+
clearTimeout(forceKillTimer);
|
|
89
|
+
forceKillTimer = null;
|
|
90
|
+
}
|
|
91
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const scheduleForceKill = (): void => {
|
|
95
|
+
if (process.platform === "win32" || forceKillTimer) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
forceKillTimer = setTimeout(() => {
|
|
99
|
+
stopChildProcess(child, true);
|
|
100
|
+
}, 750);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onAbort = (): void => {
|
|
104
|
+
aborted = true;
|
|
105
|
+
stopChildProcess(child);
|
|
106
|
+
scheduleForceKill();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const timeoutMs = typeof options.timeoutMs === "number" ? Math.max(1, Math.floor(options.timeoutMs)) : 0;
|
|
110
|
+
const timeoutTimer =
|
|
111
|
+
timeoutMs > 0
|
|
112
|
+
? setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
stopChildProcess(child);
|
|
115
|
+
scheduleForceKill();
|
|
116
|
+
}, timeoutMs)
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
120
|
+
|
|
121
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
122
|
+
stdout += chunk.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
126
|
+
stderr += chunk.toString();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
child.on("error", (error) => {
|
|
130
|
+
spawnError = error;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
child.on("close", (code) => {
|
|
134
|
+
cleanup();
|
|
135
|
+
resolve({
|
|
136
|
+
code: code ?? (spawnError || timedOut || aborted ? 1 : 0),
|
|
137
|
+
stdout,
|
|
138
|
+
stderr,
|
|
139
|
+
timedOut,
|
|
140
|
+
aborted,
|
|
141
|
+
errorMessage: spawnError
|
|
142
|
+
? stringifyError(spawnError)
|
|
143
|
+
: timedOut
|
|
144
|
+
? `Command timed out after ${timeoutMs}ms: ${commandLabel}`
|
|
145
|
+
: aborted
|
|
146
|
+
? `Command aborted: ${commandLabel}`
|
|
147
|
+
: undefined,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|