pi-smart-voice-notify 0.5.3 → 0.6.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 +23 -0
- package/README.md +11 -5
- package/package.json +13 -6
- package/src/abortable-command.ts +39 -19
- package/src/ai-messages.ts +14 -31
- package/src/config-store.ts +81 -106
- package/src/desktop-notify.ts +178 -171
- package/src/focus-detect.ts +7 -7
- package/src/index.ts +102 -142
- package/src/linux.ts +64 -61
- package/src/logging.ts +7 -4
- package/src/notify-audio.ts +43 -58
- package/src/per-project-sound.ts +14 -34
- package/src/permission-forwarding-watcher.ts +20 -42
- package/src/reminder-playback.ts +1 -1
- package/src/shared/command.ts +22 -0
- package/src/shared/env.ts +63 -0
- package/src/shared/index.ts +47 -0
- package/src/shared/manifest.ts +56 -0
- package/src/shared/numbers.ts +79 -0
- package/src/shared/strings.ts +89 -0
- package/src/shared/tts-defaults.ts +32 -0
- package/src/sound-theme.ts +21 -37
- package/src/tts.ts +93 -139
- package/src/types/tts.ts +1 -1
- package/src/types.ts +21 -34
- package/src/webhook.ts +14 -23
- package/src/zellij-modal.ts +81 -55
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-07-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Added an `enabled` master toggle to the config so the extension can be disabled without uninstalling. ([76cd96f](https://github.com/MasuRii/pi-smart-voice-notify/commit/76cd96fbbd6bd0658f00e8d7c043c44260f856bd))
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Widened Pi coding-agent and Pi TUI peer dependency ranges to include `^0.80.0` and bumped development dependencies (`@types/node` to `26.1.0`, `@earendil-works/pi-coding-agent` to `^0.80.3`). ([146ff6e](https://github.com/MasuRii/pi-smart-voice-notify/commit/146ff6edf87de680958e61f0e1db34e6d337c729))
|
|
12
|
+
- Extracted shared utilities and consolidated duplicated logic across config, notification, and platform modules. ([76cd96f](https://github.com/MasuRii/pi-smart-voice-notify/commit/76cd96fbbd6bd0658f00e8d7c043c44260f856bd))
|
|
13
|
+
- Extracted shared test helpers and consolidated fixtures across the test suite. ([6cf3ed0](https://github.com/MasuRii/pi-smart-voice-notify/commit/6cf3ed0168c1460ffca3c447f4a7a55565f5cade))
|
|
14
|
+
|
|
15
|
+
## [0.5.4] - 2026-06-22
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Added a command allowlist to `runAbortableCommand` so non-allowlisted executables are rejected before spawning, preventing arbitrary command injection ([2074e30](https://github.com/MasuRii/pi-smart-voice-notify/commit/2074e30)).
|
|
19
|
+
- Replaced dynamic string-based command spawning in Linux helpers with a typed `LinuxCommandName` union and dedicated `spawnLinuxCommand` switch ([2074e30](https://github.com/MasuRii/pi-smart-voice-notify/commit/2074e30)).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Added `postinstall` hook that runs `patch-vulnerable-deps.mjs` when installed under `.pi/agent/extensions/` ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
|
|
23
|
+
- Pinned `protobufjs` 7.6.3, `ws` 8.21.0, and `uuid` 11.1.1 via npm `overrides` to resolve known vulnerabilities ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
|
|
24
|
+
- Added `@earendil-works/pi-coding-agent` as a devDependency for type-checking ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
|
|
25
|
+
- Updated README badge styling to for-the-badge, added platform badge and ko-fi support button ([f66fff6](https://github.com/MasuRii/pi-smart-voice-notify/commit/f66fff6)).
|
|
26
|
+
- Added `config-store-env-override` test to the test script ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
|
|
27
|
+
|
|
5
28
|
## [0.5.3] - 2026-06-16
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# pi-smart-voice-notify
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/pi-smart-voice-notify)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[]()
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
[](https://ko-fi.com/Y8Y01PSSVR)
|
|
8
10
|
|
|
11
|
+
Windows-optimized smart notification extension for the Pi coding agent.
|
|
12
|
+
**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.
|
|
9
13
|
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/c215a7fe-31b4-4bc4-a89e-992a4847819d" />
|
|
10
14
|
|
|
15
|
+
</div>
|
|
11
16
|
|
|
12
17
|
## Features
|
|
13
18
|
|
|
@@ -99,6 +104,7 @@ Configuration is stored at:
|
|
|
99
104
|
```text
|
|
100
105
|
Default global path: ~/.pi/agent/extensions/pi-smart-voice-notify/config.json
|
|
101
106
|
Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-smart-voice-notify/config.json when PI_CODING_AGENT_DIR is set
|
|
107
|
+
Local config path: <repo>/.pi/extensions/pi-smart-voice-notify/config.json
|
|
102
108
|
```
|
|
103
109
|
|
|
104
110
|
A starter template is provided in `config/config.example.json`. On startup, the extension creates `config.json` with defaults if missing.
|
|
@@ -302,4 +308,4 @@ npm run check # build + test
|
|
|
302
308
|
|
|
303
309
|
## License
|
|
304
310
|
|
|
305
|
-
MIT
|
|
311
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-smart-voice-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
"scripts": {
|
|
20
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 --experimental-strip-types --test test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
|
|
23
|
-
"check": "npm run build && npm run test"
|
|
22
|
+
"test": "node --experimental-strip-types --test test/config-store.test.ts test/config-store-no-global.test.ts test/config-store-env-override.test.ts test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
|
|
23
|
+
"check": "npm run build && npm run test",
|
|
24
|
+
"postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"pi-package",
|
|
@@ -61,14 +62,20 @@
|
|
|
61
62
|
]
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
|
-
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0",
|
|
65
|
-
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
|
|
65
|
+
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
|
|
66
|
+
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
68
69
|
"node-notifier": "^10.0.1",
|
|
69
70
|
"undici": "^8.5.0"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
|
-
"@types/node": "
|
|
73
|
+
"@types/node": "26.1.0",
|
|
74
|
+
"@earendil-works/pi-coding-agent": "^0.80.3"
|
|
75
|
+
},
|
|
76
|
+
"overrides": {
|
|
77
|
+
"protobufjs": "7.6.3",
|
|
78
|
+
"ws": "8.21.0",
|
|
79
|
+
"uuid": "11.1.1"
|
|
73
80
|
}
|
|
74
81
|
}
|
package/src/abortable-command.ts
CHANGED
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { buildCommandString, attachChildHandlers } from "./shared/index.ts";
|
|
5
|
+
|
|
6
|
+
const ALLOWED_ABORTABLE_COMMANDS = new Set([
|
|
7
|
+
"aplay",
|
|
8
|
+
"edge-tts",
|
|
9
|
+
"espeak-ng",
|
|
10
|
+
"ffplay",
|
|
11
|
+
"gdbus",
|
|
12
|
+
"paplay",
|
|
13
|
+
"powershell",
|
|
14
|
+
"powershell.exe",
|
|
15
|
+
"swaymsg",
|
|
16
|
+
"where",
|
|
17
|
+
"which",
|
|
18
|
+
"xdotool",
|
|
19
|
+
"xprop",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function normalizeCommandName(command: string): string {
|
|
23
|
+
return basename(command).trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isAllowedAbortableCommand(command: string): boolean {
|
|
27
|
+
const normalized = normalizeCommandName(command);
|
|
28
|
+
const nodeExecutable = normalizeCommandName(process.execPath);
|
|
29
|
+
return normalized === nodeExecutable || ALLOWED_ABORTABLE_COMMANDS.has(normalized);
|
|
30
|
+
}
|
|
2
31
|
|
|
3
32
|
export interface AbortableCommandOptions {
|
|
4
33
|
timeoutMs?: number;
|
|
@@ -23,10 +52,6 @@ function stringifyError(error: unknown): string {
|
|
|
23
52
|
return String(error);
|
|
24
53
|
}
|
|
25
54
|
|
|
26
|
-
function buildCommandString(command: string, args: readonly string[]): string {
|
|
27
|
-
return args.length > 0 ? `${command} ${args.join(" ")}` : command;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
55
|
function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void {
|
|
31
56
|
if (child.killed) {
|
|
32
57
|
return;
|
|
@@ -38,8 +63,10 @@ function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void
|
|
|
38
63
|
return;
|
|
39
64
|
}
|
|
40
65
|
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
41
|
-
} catch {
|
|
42
|
-
//
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Killing a process that has already exited can throw on some platforms;
|
|
68
|
+
// the error is non-actionable for shutdown/abort flows.
|
|
69
|
+
void error;
|
|
43
70
|
}
|
|
44
71
|
}
|
|
45
72
|
|
|
@@ -55,6 +82,9 @@ export async function runAbortableCommand(
|
|
|
55
82
|
if (!Array.isArray(args)) {
|
|
56
83
|
throw new Error("runAbortableCommand: args must be an array of strings");
|
|
57
84
|
}
|
|
85
|
+
if (!isAllowedAbortableCommand(normalizedCommand)) {
|
|
86
|
+
throw new Error(`runAbortableCommand: command is not allowlisted: ${normalizedCommand}`);
|
|
87
|
+
}
|
|
58
88
|
|
|
59
89
|
const commandLabel = buildCommandString(normalizedCommand, args);
|
|
60
90
|
if (options.signal?.aborted) {
|
|
@@ -69,7 +99,7 @@ export async function runAbortableCommand(
|
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
return await new Promise<AbortableCommandResult>((resolve) => {
|
|
72
|
-
const child = spawn(normalizedCommand,
|
|
102
|
+
const child = spawn(normalizedCommand, Array.from(args), { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- normalizedCommand is checked against ALLOWED_ABORTABLE_COMMANDS (plus the current Node executable for tests) before spawn; args are passed as an array with shell disabled.
|
|
73
103
|
env: options.env ?? process.env,
|
|
74
104
|
cwd: options.cwd,
|
|
75
105
|
});
|
|
@@ -118,19 +148,9 @@ export async function runAbortableCommand(
|
|
|
118
148
|
|
|
119
149
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
120
150
|
|
|
121
|
-
child
|
|
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
|
-
});
|
|
151
|
+
attachChildHandlers(child, (text: string) => { stdout += text; }, (text: string) => { stderr += text; }, (error: Error) => { spawnError = error; });
|
|
132
152
|
|
|
133
|
-
child.on("close", (code) => {
|
|
153
|
+
child.on("close", (code: number | null) => {
|
|
134
154
|
cleanup();
|
|
135
155
|
resolve({
|
|
136
156
|
code: code ?? (spawnError || timedOut || aborted ? 1 : 0),
|
package/src/ai-messages.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { normalizeFloat } from "./config-store.ts";
|
|
2
2
|
import { getErrorMessage } from "./logging.ts";
|
|
3
|
+
import { clampRoundedInt } from "./shared/index.ts";
|
|
4
|
+
import type { FlatAIMessageConfig } from "./types.ts";
|
|
3
5
|
|
|
4
6
|
export const AI_EVENT_TYPES = [
|
|
5
7
|
"idle",
|
|
@@ -24,20 +26,7 @@ export interface AIMessageContext {
|
|
|
24
26
|
variables?: Record<string, string | number | boolean | null | undefined>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export interface AIMessageConfig {
|
|
28
|
-
enableAIMessages: boolean;
|
|
29
|
-
aiEndpoint: string;
|
|
30
|
-
aiModel: string;
|
|
31
|
-
aiApiKey: string;
|
|
32
|
-
aiTimeoutMs: number;
|
|
33
|
-
aiTemperature: number;
|
|
34
|
-
aiMaxTokens: number;
|
|
35
|
-
aiFallbackToTemplates: boolean;
|
|
36
|
-
personality: string;
|
|
37
|
-
tone: string;
|
|
38
|
-
enableMessageCache: boolean;
|
|
39
|
-
messageCacheTtlMs: number;
|
|
40
|
-
maxCacheEntries: number;
|
|
29
|
+
export interface AIMessageConfig extends FlatAIMessageConfig {
|
|
41
30
|
templates: Partial<Record<CoreAIEventType, string[]>>;
|
|
42
31
|
}
|
|
43
32
|
|
|
@@ -128,24 +117,17 @@ export const DEFAULT_AI_MESSAGE_CONFIG: AIMessageConfig = {
|
|
|
128
117
|
templates: {},
|
|
129
118
|
};
|
|
130
119
|
|
|
131
|
-
function
|
|
132
|
-
if (!Number.isFinite(value)) {
|
|
133
|
-
return fallback;
|
|
134
|
-
}
|
|
135
|
-
return Math.min(max, Math.max(min, Math.round(value)));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
|
|
120
|
+
function normalizeAIMessageConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
|
|
139
121
|
return {
|
|
140
122
|
...DEFAULT_AI_MESSAGE_CONFIG,
|
|
141
123
|
...overrides,
|
|
142
|
-
aiTimeoutMs:
|
|
124
|
+
aiTimeoutMs: clampRoundedInt(
|
|
143
125
|
overrides.aiTimeoutMs ?? DEFAULT_AI_MESSAGE_CONFIG.aiTimeoutMs,
|
|
144
126
|
DEFAULT_AI_MESSAGE_CONFIG.aiTimeoutMs,
|
|
145
127
|
1_000,
|
|
146
128
|
60_000,
|
|
147
129
|
),
|
|
148
|
-
aiMaxTokens:
|
|
130
|
+
aiMaxTokens: clampRoundedInt(
|
|
149
131
|
overrides.aiMaxTokens ?? DEFAULT_AI_MESSAGE_CONFIG.aiMaxTokens,
|
|
150
132
|
DEFAULT_AI_MESSAGE_CONFIG.aiMaxTokens,
|
|
151
133
|
40,
|
|
@@ -157,13 +139,13 @@ function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageCon
|
|
|
157
139
|
0,
|
|
158
140
|
2,
|
|
159
141
|
),
|
|
160
|
-
messageCacheTtlMs:
|
|
142
|
+
messageCacheTtlMs: clampRoundedInt(
|
|
161
143
|
overrides.messageCacheTtlMs ?? DEFAULT_AI_MESSAGE_CONFIG.messageCacheTtlMs,
|
|
162
144
|
DEFAULT_AI_MESSAGE_CONFIG.messageCacheTtlMs,
|
|
163
145
|
5_000,
|
|
164
146
|
600_000,
|
|
165
147
|
),
|
|
166
|
-
maxCacheEntries:
|
|
148
|
+
maxCacheEntries: clampRoundedInt(
|
|
167
149
|
overrides.maxCacheEntries ?? DEFAULT_AI_MESSAGE_CONFIG.maxCacheEntries,
|
|
168
150
|
DEFAULT_AI_MESSAGE_CONFIG.maxCacheEntries,
|
|
169
151
|
20,
|
|
@@ -304,15 +286,15 @@ export class AIMessageService {
|
|
|
304
286
|
private readonly debugLog: (message: string, details?: Record<string, unknown>) => void;
|
|
305
287
|
|
|
306
288
|
constructor(options: AIMessageServiceOptions = {}) {
|
|
307
|
-
this.config =
|
|
289
|
+
this.config = normalizeAIMessageConfig(options.config);
|
|
308
290
|
this.debugLog = options.debugLog ?? (() => {});
|
|
309
291
|
}
|
|
310
292
|
|
|
311
|
-
public
|
|
312
|
-
this.config =
|
|
293
|
+
public updateAIMessageConfig(overrides: Partial<AIMessageConfig>): void {
|
|
294
|
+
this.config = normalizeAIMessageConfig({ ...this.config, ...overrides });
|
|
313
295
|
}
|
|
314
296
|
|
|
315
|
-
public
|
|
297
|
+
public getAIMessageConfig(): AIMessageConfig {
|
|
316
298
|
return { ...this.config, templates: { ...this.config.templates } };
|
|
317
299
|
}
|
|
318
300
|
|
|
@@ -449,7 +431,8 @@ export class AIMessageService {
|
|
|
449
431
|
}
|
|
450
432
|
|
|
451
433
|
while (this.cache.size > this.config.maxCacheEntries) {
|
|
452
|
-
const
|
|
434
|
+
const next = this.cache.keys().next();
|
|
435
|
+
const oldestKey = typeof next.value === "string" ? next.value : undefined;
|
|
453
436
|
if (!oldestKey) {
|
|
454
437
|
break;
|
|
455
438
|
}
|
package/src/config-store.ts
CHANGED
|
@@ -2,6 +2,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { resolvePiAgentDir } from "./agent-dir.ts";
|
|
5
|
+
import {
|
|
6
|
+
clampInt,
|
|
7
|
+
clampNumber,
|
|
8
|
+
normalizeFloat,
|
|
9
|
+
normalizeStringEnum,
|
|
10
|
+
normalizeStringEnumArray,
|
|
11
|
+
parseEnvBoolean,
|
|
12
|
+
readEnvFrom,
|
|
13
|
+
toRecord,
|
|
14
|
+
ENGINE_TTS_DEFAULTS,
|
|
15
|
+
} from "./shared/index.ts";
|
|
16
|
+
|
|
17
|
+
export { clampInt, clampNumber, normalizeFloat, toRecord };
|
|
5
18
|
import type {
|
|
6
19
|
ConcreteTTSEngine,
|
|
7
20
|
MessageSet,
|
|
@@ -16,6 +29,11 @@ export const EXTENSION_ID = "pi-smart-voice-notify";
|
|
|
16
29
|
export const STATUS_KEY = "smart-voice-notify";
|
|
17
30
|
export const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", EXTENSION_ID);
|
|
18
31
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
32
|
+
|
|
33
|
+
/** Project-local override config: <projectRoot>/.pi/extensions/<id>/config.json (read-only). */
|
|
34
|
+
export function resolveProjectConfigPath(projectRoot: string): string {
|
|
35
|
+
return join(projectRoot, ".pi", "extensions", EXTENSION_ID, "config.json");
|
|
36
|
+
}
|
|
19
37
|
export const DEBUG_DIR = join(CONFIG_DIR, "debug");
|
|
20
38
|
export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_ID}.log`);
|
|
21
39
|
|
|
@@ -154,25 +172,7 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
|
|
|
154
172
|
sapiPitch: "medium",
|
|
155
173
|
sapiVolume: "loud",
|
|
156
174
|
|
|
157
|
-
|
|
158
|
-
edgeRate: "+10%",
|
|
159
|
-
edgePitch: "+0Hz",
|
|
160
|
-
edgeVolume: "+0%",
|
|
161
|
-
espeakVoice: "en",
|
|
162
|
-
espeakRate: 175,
|
|
163
|
-
espeakPitch: 50,
|
|
164
|
-
elevenLabsApiKey: "",
|
|
165
|
-
elevenLabsVoiceId: "cgSgspJ2msm6clMCkdW9",
|
|
166
|
-
elevenLabsModel: "eleven_turbo_v2_5",
|
|
167
|
-
elevenLabsStability: 0.5,
|
|
168
|
-
elevenLabsSimilarity: 0.75,
|
|
169
|
-
elevenLabsStyle: 0.5,
|
|
170
|
-
openaiTtsEndpoint: "",
|
|
171
|
-
openaiTtsApiKey: "",
|
|
172
|
-
openaiTtsModel: "tts-1",
|
|
173
|
-
openaiTtsVoice: "alloy",
|
|
174
|
-
openaiTtsFormat: "mp3",
|
|
175
|
-
openaiTtsSpeed: 1,
|
|
175
|
+
...ENGINE_TTS_DEFAULTS,
|
|
176
176
|
|
|
177
177
|
idleSoundFile: "assets/soft-notification.mp3",
|
|
178
178
|
permissionSoundFile: "assets/attention-alert.mp3",
|
|
@@ -256,71 +256,12 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
|
|
|
256
256
|
debugLog: false,
|
|
257
257
|
};
|
|
258
258
|
|
|
259
|
-
export function toRecord(value: unknown): Record<string, unknown> {
|
|
260
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
261
|
-
return {};
|
|
262
|
-
}
|
|
263
|
-
return value as Record<string, unknown>;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
259
|
function readEnv(...keys: string[]): string {
|
|
267
|
-
|
|
268
|
-
const value = process.env[key];
|
|
269
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
270
|
-
return value.trim();
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return "";
|
|
260
|
+
return readEnvFrom(...keys);
|
|
274
261
|
}
|
|
275
262
|
|
|
276
263
|
function parseEnvBool(value: string): boolean | undefined {
|
|
277
|
-
|
|
278
|
-
return undefined;
|
|
279
|
-
}
|
|
280
|
-
const normalized = value.trim().toLowerCase();
|
|
281
|
-
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
285
|
-
return false;
|
|
286
|
-
}
|
|
287
|
-
return undefined;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function parseNumeric(value: unknown): number | undefined {
|
|
291
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
292
|
-
return value;
|
|
293
|
-
}
|
|
294
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
295
|
-
const parsed = Number(value.trim());
|
|
296
|
-
if (Number.isFinite(parsed)) {
|
|
297
|
-
return parsed;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
return undefined;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
304
|
-
const numeric = parseNumeric(value);
|
|
305
|
-
if (numeric === undefined) {
|
|
306
|
-
return fallback;
|
|
307
|
-
}
|
|
308
|
-
return Math.min(max, Math.max(min, Math.trunc(numeric)));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
312
|
-
const numeric = parseNumeric(value);
|
|
313
|
-
if (numeric === undefined) {
|
|
314
|
-
return fallback;
|
|
315
|
-
}
|
|
316
|
-
return Math.min(max, Math.max(min, numeric));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
|
|
320
|
-
if (!Number.isFinite(value)) {
|
|
321
|
-
return fallback;
|
|
322
|
-
}
|
|
323
|
-
return Math.min(max, Math.max(min, value));
|
|
264
|
+
return parseEnvBoolean(value);
|
|
324
265
|
}
|
|
325
266
|
|
|
326
267
|
function boolOrDefault(value: unknown, fallback: boolean): boolean {
|
|
@@ -397,17 +338,11 @@ function normalizeStringArray(value: unknown): string[] {
|
|
|
397
338
|
}
|
|
398
339
|
|
|
399
340
|
function normalizeNotificationTypes(value: unknown, fallback: NotificationType[]): NotificationType[] {
|
|
400
|
-
|
|
401
|
-
.map((entry) => entry.toLowerCase())
|
|
402
|
-
.filter((entry): entry is NotificationType => NOTIFICATION_TYPE_VALUES.includes(entry as NotificationType));
|
|
403
|
-
return candidates.length > 0 ? [...new Set(candidates)] : [...fallback];
|
|
341
|
+
return normalizeStringEnumArray(value, NOTIFICATION_TYPE_VALUES, fallback);
|
|
404
342
|
}
|
|
405
343
|
|
|
406
344
|
function normalizeFallbackChain(value: unknown, fallback: ConcreteTTSEngine[]): ConcreteTTSEngine[] {
|
|
407
|
-
|
|
408
|
-
.map((entry) => entry.toLowerCase())
|
|
409
|
-
.filter((entry): entry is ConcreteTTSEngine => CONCRETE_TTS_ENGINE_VALUES.includes(entry as ConcreteTTSEngine));
|
|
410
|
-
return candidates.length > 0 ? [...new Set(candidates)] : [...fallback];
|
|
345
|
+
return normalizeStringEnumArray(value, CONCRETE_TTS_ENGINE_VALUES, fallback);
|
|
411
346
|
}
|
|
412
347
|
|
|
413
348
|
function normalizeTemplates(value: unknown): Partial<Record<string, string[]>> {
|
|
@@ -479,17 +414,11 @@ function isHttpUrl(url: string): boolean {
|
|
|
479
414
|
}
|
|
480
415
|
|
|
481
416
|
export function normalizeMode(value: unknown): NotificationMode {
|
|
482
|
-
|
|
483
|
-
return value as NotificationMode;
|
|
484
|
-
}
|
|
485
|
-
return DEFAULT_CONFIG.notificationMode;
|
|
417
|
+
return normalizeStringEnum(value, NOTIFICATION_MODES, DEFAULT_CONFIG.notificationMode);
|
|
486
418
|
}
|
|
487
419
|
|
|
488
420
|
export function normalizeTtsEngine(value: unknown): TTSEngine {
|
|
489
|
-
|
|
490
|
-
return value as TTSEngine;
|
|
491
|
-
}
|
|
492
|
-
return DEFAULT_CONFIG.ttsEngine;
|
|
421
|
+
return normalizeStringEnum(value, TTS_ENGINE_VALUES, DEFAULT_CONFIG.ttsEngine);
|
|
493
422
|
}
|
|
494
423
|
|
|
495
424
|
export function normalizeConfig(raw: unknown): VoiceNotifyConfig {
|
|
@@ -979,28 +908,74 @@ export function ensureDebugDirectory(): void {
|
|
|
979
908
|
}
|
|
980
909
|
}
|
|
981
910
|
|
|
982
|
-
|
|
911
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
912
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Deep-merge `override` onto `base`. Nested objects merge; arrays and scalars replace. */
|
|
916
|
+
export function deepMergeConfigRecords(
|
|
917
|
+
base: Record<string, unknown>,
|
|
918
|
+
override: Record<string, unknown>,
|
|
919
|
+
): Record<string, unknown> {
|
|
920
|
+
const result: Record<string, unknown> = { ...base };
|
|
921
|
+
for (const [key, value] of Object.entries(override)) {
|
|
922
|
+
const existing = result[key];
|
|
923
|
+
result[key] = isPlainObject(existing) && isPlainObject(value)
|
|
924
|
+
? deepMergeConfigRecords(existing, value)
|
|
925
|
+
: value;
|
|
926
|
+
}
|
|
927
|
+
return result;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function readConfigRecord(path: string): Record<string, unknown> | null {
|
|
931
|
+
if (!existsSync(path)) {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
return toRecord(JSON.parse(readFileSync(path, "utf-8")) as unknown);
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Load and self-heal the global config file, returning its raw record. */
|
|
942
|
+
function loadGlobalConfigRecord(): Record<string, unknown> {
|
|
983
943
|
ensureConfigDirectory();
|
|
944
|
+
const defaultsSerialized = `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`;
|
|
984
945
|
if (!existsSync(CONFIG_PATH)) {
|
|
985
|
-
writeFileSync(CONFIG_PATH,
|
|
986
|
-
return
|
|
946
|
+
writeFileSync(CONFIG_PATH, defaultsSerialized, "utf-8");
|
|
947
|
+
return toRecord(JSON.parse(defaultsSerialized));
|
|
987
948
|
}
|
|
988
949
|
|
|
989
950
|
try {
|
|
990
951
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
991
952
|
const parsed = JSON.parse(raw) as unknown;
|
|
992
|
-
const
|
|
993
|
-
const validation = validateConfig(normalized);
|
|
994
|
-
const serialized = `${JSON.stringify(validation.config, null, 2)}\n`;
|
|
953
|
+
const serialized = `${JSON.stringify(validateConfig(normalizeConfig(parsed)).config, null, 2)}\n`;
|
|
995
954
|
if (raw !== serialized) {
|
|
996
955
|
writeFileSync(CONFIG_PATH, serialized, "utf-8");
|
|
997
956
|
}
|
|
998
|
-
|
|
999
|
-
return validateConfig(runtimeConfig).config;
|
|
957
|
+
return toRecord(parsed);
|
|
1000
958
|
} catch {
|
|
1001
|
-
writeFileSync(CONFIG_PATH,
|
|
1002
|
-
return
|
|
959
|
+
writeFileSync(CONFIG_PATH, defaultsSerialized, "utf-8");
|
|
960
|
+
return toRecord(JSON.parse(defaultsSerialized));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Read the effective config. When `projectRoot` is given and a project config
|
|
966
|
+
* exists, it deep-merges over the global config; environment variables still
|
|
967
|
+
* override both. The project file is read-only — only the global file is healed.
|
|
968
|
+
*/
|
|
969
|
+
export function readConfigFromDisk(projectRoot?: string): VoiceNotifyConfig {
|
|
970
|
+
let record = loadGlobalConfigRecord();
|
|
971
|
+
if (projectRoot) {
|
|
972
|
+
const projectRecord = readConfigRecord(resolveProjectConfigPath(projectRoot));
|
|
973
|
+
if (projectRecord) {
|
|
974
|
+
record = deepMergeConfigRecords(record, projectRecord);
|
|
975
|
+
}
|
|
1003
976
|
}
|
|
977
|
+
const runtimeConfig = applyEnvironmentOverrides(validateConfig(normalizeConfig(record)).config);
|
|
978
|
+
return validateConfig(runtimeConfig).config;
|
|
1004
979
|
}
|
|
1005
980
|
|
|
1006
981
|
export function writeConfigToDisk(config: VoiceNotifyConfig): void {
|