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 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
- # 🔔 pi-smart-voice-notify
1
+ <div align="center">
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/pi-smart-voice-notify?style=flat-square)](https://www.npmjs.com/package/pi-smart-voice-notify) [![License](https://img.shields.io/github/license/MasuRii/pi-smart-voice-notify?style=flat-square)](LICENSE)
3
+ # pi-smart-voice-notify
4
4
 
5
- Windows-optimized smart notification extension for the Pi coding agent.
5
+ [![npm version](https://img.shields.io/npm/v/pi-smart-voice-notify?style=for-the-badge)](https://www.npmjs.com/package/pi-smart-voice-notify)
6
+ [![License](https://img.shields.io/github/license/MasuRii/pi-smart-voice-notify?style=for-the-badge)](LICENSE)
7
+ [![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge)]()
6
8
 
7
- **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
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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.5.3",
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": "25.9.3"
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
  }
@@ -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
- // noop
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, [...args], {
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.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
- });
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),
@@ -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 normalizePositiveInt(value: number, fallback: number, min: number, max: number): number {
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: normalizePositiveInt(
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: normalizePositiveInt(
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: normalizePositiveInt(
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: normalizePositiveInt(
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 = normalizeConfig(options.config);
289
+ this.config = normalizeAIMessageConfig(options.config);
308
290
  this.debugLog = options.debugLog ?? (() => {});
309
291
  }
310
292
 
311
- public updateConfig(overrides: Partial<AIMessageConfig>): void {
312
- this.config = normalizeConfig({ ...this.config, ...overrides });
293
+ public updateAIMessageConfig(overrides: Partial<AIMessageConfig>): void {
294
+ this.config = normalizeAIMessageConfig({ ...this.config, ...overrides });
313
295
  }
314
296
 
315
- public getConfig(): AIMessageConfig {
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 oldestKey = this.cache.keys().next().value;
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
  }
@@ -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
- edgeVoice: "en-US-JennyNeural",
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
- for (const key of keys) {
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
- if (!value) {
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
- const candidates = normalizeStringArray(value)
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
- const candidates = normalizeStringArray(value)
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
- if (typeof value === "string" && NOTIFICATION_MODES.includes(value as NotificationMode)) {
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
- if (typeof value === "string" && TTS_ENGINE_VALUES.includes(value as TTSEngine)) {
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
- export function readConfigFromDisk(): VoiceNotifyConfig {
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, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
986
- return applyEnvironmentOverrides({ ...DEFAULT_CONFIG, webhook: { ...DEFAULT_CONFIG.webhook, events: [...DEFAULT_CONFIG.webhook.events] } });
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 normalized = normalizeConfig(parsed);
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
- const runtimeConfig = applyEnvironmentOverrides(validation.config);
999
- return validateConfig(runtimeConfig).config;
957
+ return toRecord(parsed);
1000
958
  } catch {
1001
- writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
1002
- return applyEnvironmentOverrides({ ...DEFAULT_CONFIG, webhook: { ...DEFAULT_CONFIG.webhook, events: [...DEFAULT_CONFIG.webhook.events] } });
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 {