pi-smart-voice-notify 0.2.1 → 0.2.3
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 +17 -0
- package/LICENSE +21 -21
- package/package.json +1 -1
- package/src/ai-messages.ts +1 -7
- package/src/config-store.ts +8 -1
- package/src/desktop-notify.ts +171 -177
- package/src/focus-detect.ts +3 -9
- package/src/index.test.ts +107 -0
- package/src/index.ts +113 -7
- package/src/logging.ts +73 -73
- package/src/per-project-sound.ts +11 -11
- package/src/permission-forwarding-watcher.ts +1 -7
- package/src/sound-theme.ts +19 -68
- package/src/tts.ts +1 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.3] - 2026-03-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Integration with `pi-permission-system:permission-request` event channel for permission request notifications
|
|
7
|
+
- Automatic cancellation of permission reminders when approval/denial is received from the permission system
|
|
8
|
+
- Deduplication to prevent duplicate notifications when permission system events precede tool_call events
|
|
9
|
+
- New test coverage for permission system event integration
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Refactored to use shared `toRecord` utility from `pi-permission-system`, removing duplicate implementation
|
|
13
|
+
|
|
14
|
+
## [0.2.2] - 2026-03-12
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Refactored to use shared `toRecord` utility, removing duplicate implementation
|
|
18
|
+
- Consolidated exports and simplified `index.ts`
|
|
19
|
+
|
|
3
20
|
## [0.2.0] - 2026-03-07
|
|
4
21
|
|
|
5
22
|
### Added
|
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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/package.json
CHANGED
package/src/ai-messages.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeFloat } from "./config-store.ts";
|
|
1
2
|
import { getErrorMessage } from "./logging.ts";
|
|
2
3
|
|
|
3
4
|
export const AI_EVENT_TYPES = [
|
|
@@ -134,13 +135,6 @@ function normalizePositiveInt(value: number, fallback: number, min: number, max:
|
|
|
134
135
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
|
|
138
|
-
if (!Number.isFinite(value)) {
|
|
139
|
-
return fallback;
|
|
140
|
-
}
|
|
141
|
-
return Math.min(max, Math.max(min, value));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
138
|
function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
|
|
145
139
|
return {
|
|
146
140
|
...DEFAULT_AI_MESSAGE_CONFIG,
|
package/src/config-store.ts
CHANGED
|
@@ -265,7 +265,7 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
|
|
|
265
265
|
debugLog: false,
|
|
266
266
|
};
|
|
267
267
|
|
|
268
|
-
function toRecord(value: unknown): Record<string, unknown> {
|
|
268
|
+
export function toRecord(value: unknown): Record<string, unknown> {
|
|
269
269
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
270
270
|
return {};
|
|
271
271
|
}
|
|
@@ -325,6 +325,13 @@ export function clampNumber(value: unknown, fallback: number, min: number, max:
|
|
|
325
325
|
return Math.min(max, Math.max(min, numeric));
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
export function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
|
|
329
|
+
if (!Number.isFinite(value)) {
|
|
330
|
+
return fallback;
|
|
331
|
+
}
|
|
332
|
+
return Math.min(max, Math.max(min, value));
|
|
333
|
+
}
|
|
334
|
+
|
|
328
335
|
function boolOrDefault(value: unknown, fallback: boolean): boolean {
|
|
329
336
|
if (typeof value === "boolean") {
|
|
330
337
|
return value;
|
package/src/desktop-notify.ts
CHANGED
|
@@ -1,177 +1,171 @@
|
|
|
1
|
-
type NotificationType
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
platform: process.platform,
|
|
173
|
-
error: getErrorMessage(error),
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
}
|
|
1
|
+
import type { NotificationType } from "./types.ts";
|
|
2
|
+
import { getErrorMessage } from "./logging.ts";
|
|
3
|
+
|
|
4
|
+
type LinuxUrgency = "low" | "normal" | "critical";
|
|
5
|
+
|
|
6
|
+
interface DesktopNotificationSupport {
|
|
7
|
+
supported: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DesktopNotificationRequest {
|
|
12
|
+
type: NotificationType;
|
|
13
|
+
message: string;
|
|
14
|
+
timeoutSeconds: number;
|
|
15
|
+
debugLog?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DesktopNotificationResult {
|
|
19
|
+
success: boolean;
|
|
20
|
+
platform: NodeJS.Platform;
|
|
21
|
+
unsupported?: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NotifierLike {
|
|
26
|
+
notify(
|
|
27
|
+
options: Record<string, unknown>,
|
|
28
|
+
callback?: (error: Error | null, response?: unknown, metadata?: unknown) => void,
|
|
29
|
+
): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TITLES: Record<NotificationType, string> = {
|
|
33
|
+
idle: "✅ Pi - Task Complete",
|
|
34
|
+
permission: "⚠️ Pi - Permission Required",
|
|
35
|
+
question: "❓ Pi - Input Needed",
|
|
36
|
+
error: "❌ Pi - Error",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LINUX_URGENCY: Record<NotificationType, LinuxUrgency> = {
|
|
40
|
+
idle: "normal",
|
|
41
|
+
permission: "critical",
|
|
42
|
+
question: "normal",
|
|
43
|
+
error: "critical",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let notifierPromise: Promise<NotifierLike | null> | null = null;
|
|
47
|
+
|
|
48
|
+
function clampTimeoutSeconds(value: number): number {
|
|
49
|
+
if (!Number.isFinite(value)) {
|
|
50
|
+
return 5;
|
|
51
|
+
}
|
|
52
|
+
return Math.min(60, Math.max(1, Math.trunc(value)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkDesktopNotificationSupport(platform = process.platform): DesktopNotificationSupport {
|
|
56
|
+
switch (platform) {
|
|
57
|
+
case "win32":
|
|
58
|
+
case "darwin":
|
|
59
|
+
case "linux":
|
|
60
|
+
return { supported: true };
|
|
61
|
+
default:
|
|
62
|
+
return {
|
|
63
|
+
supported: false,
|
|
64
|
+
reason: `Desktop notifications are unsupported on platform '${platform}'.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildNotifierOptions(request: DesktopNotificationRequest): Record<string, unknown> {
|
|
70
|
+
const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
|
|
71
|
+
const baseOptions: Record<string, unknown> = {
|
|
72
|
+
title: TITLES[request.type],
|
|
73
|
+
message: request.message,
|
|
74
|
+
wait: false,
|
|
75
|
+
timeout: timeoutSeconds,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (process.platform === "linux") {
|
|
79
|
+
baseOptions.urgency = LINUX_URGENCY[request.type];
|
|
80
|
+
baseOptions["app-name"] = "Pi Smart Voice Notify";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (process.platform === "win32") {
|
|
84
|
+
baseOptions.appID = "PiSmartVoiceNotify";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (process.platform === "darwin") {
|
|
88
|
+
baseOptions.subtitle = "Smart Voice Notify";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return baseOptions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getNotifier(): Promise<NotifierLike | null> {
|
|
95
|
+
if (!notifierPromise) {
|
|
96
|
+
notifierPromise = import("node-notifier")
|
|
97
|
+
.then((module) => {
|
|
98
|
+
const candidate = (module.default ?? module) as { notify?: NotifierLike["notify"] };
|
|
99
|
+
if (typeof candidate.notify !== "function") {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return { notify: candidate.notify };
|
|
103
|
+
})
|
|
104
|
+
.catch(() => null);
|
|
105
|
+
}
|
|
106
|
+
return notifierPromise;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function sendDesktopNotification(request: DesktopNotificationRequest): Promise<DesktopNotificationResult> {
|
|
110
|
+
const support = checkDesktopNotificationSupport();
|
|
111
|
+
if (!support.supported) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
platform: process.platform,
|
|
115
|
+
unsupported: true,
|
|
116
|
+
error: support.reason,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const notifier = await getNotifier();
|
|
121
|
+
if (!notifier) {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
platform: process.platform,
|
|
125
|
+
error: "node-notifier is not available. Install it in this extension directory.",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const notifyOptions = buildNotifierOptions(request);
|
|
130
|
+
const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
|
|
131
|
+
const callbackTimeoutMs = Math.min(1200, Math.max(250, timeoutSeconds * 1000 + 250));
|
|
132
|
+
|
|
133
|
+
return new Promise<DesktopNotificationResult>((resolve) => {
|
|
134
|
+
let settled = false;
|
|
135
|
+
const settle = (result: DesktopNotificationResult): void => {
|
|
136
|
+
if (settled) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
settled = true;
|
|
140
|
+
clearTimeout(safetyTimeout);
|
|
141
|
+
resolve(result);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const safetyTimeout = setTimeout(() => {
|
|
145
|
+
if (request.debugLog) {
|
|
146
|
+
// Callback can be dropped by some notifier backends; treat as queued/success.
|
|
147
|
+
}
|
|
148
|
+
settle({ success: true, platform: process.platform });
|
|
149
|
+
}, callbackTimeoutMs);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
notifier.notify(notifyOptions, (error) => {
|
|
153
|
+
if (error) {
|
|
154
|
+
settle({
|
|
155
|
+
success: false,
|
|
156
|
+
platform: process.platform,
|
|
157
|
+
error: getErrorMessage(error),
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
settle({ success: true, platform: process.platform });
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
settle({
|
|
165
|
+
success: false,
|
|
166
|
+
platform: process.platform,
|
|
167
|
+
error: getErrorMessage(error),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
package/src/focus-detect.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec, type ExecOptionsWithStringEncoding } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { getErrorMessage } from "./logging.ts";
|
|
3
4
|
|
|
4
5
|
export type LinuxSessionType = "x11" | "wayland" | "unknown";
|
|
5
6
|
|
|
@@ -100,13 +101,6 @@ function emitLog(
|
|
|
100
101
|
console.warn("[focus-detect]", message, details);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
function errorToString(error: unknown): string {
|
|
104
|
-
if (error instanceof Error) {
|
|
105
|
-
return error.message;
|
|
106
|
-
}
|
|
107
|
-
return String(error);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
104
|
export function detectLinuxSessionType(env: NodeJS.ProcessEnv = process.env): LinuxSessionType {
|
|
111
105
|
const explicit = env.XDG_SESSION_TYPE?.toLowerCase().trim();
|
|
112
106
|
if (explicit === "x11" || explicit === "wayland") {
|
|
@@ -170,7 +164,7 @@ async function runCommand(
|
|
|
170
164
|
} catch (error) {
|
|
171
165
|
emitLog("error", `${label}: command failed`, options, {
|
|
172
166
|
command,
|
|
173
|
-
error:
|
|
167
|
+
error: getErrorMessage(error),
|
|
174
168
|
});
|
|
175
169
|
return null;
|
|
176
170
|
}
|
|
@@ -252,7 +246,7 @@ async function getFocusedWindowWaylandSway(options: FocusDetectOptions): Promise
|
|
|
252
246
|
);
|
|
253
247
|
} catch (error) {
|
|
254
248
|
emitLog("error", "wayland.sway failed to parse sway tree", options, {
|
|
255
|
-
error:
|
|
249
|
+
error: getErrorMessage(error),
|
|
256
250
|
});
|
|
257
251
|
return null;
|
|
258
252
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -26,6 +26,9 @@ type PermissionForwardingWatcherController = ReturnType<PermissionForwardingWatc
|
|
|
26
26
|
type ForwardedPermissionRequestEvent = Parameters<PermissionForwardingWatcherOptions["onRequest"]>[0];
|
|
27
27
|
type ForwardedPermissionResolutionEvent = Parameters<PermissionForwardingWatcherOptions["onResolve"]>[0];
|
|
28
28
|
type PermissionForwardingWatcherConfig = Parameters<PermissionForwardingWatcherController["start"]>[0];
|
|
29
|
+
type EventBusHandler = (payload: unknown) => void;
|
|
30
|
+
|
|
31
|
+
const PERMISSION_SYSTEM_EVENT_CHANNEL = "pi-permission-system:permission-request";
|
|
29
32
|
|
|
30
33
|
const EMPTY_AVAILABILITY: TTSAvailability = {
|
|
31
34
|
"espeak-ng": false,
|
|
@@ -35,9 +38,31 @@ const EMPTY_AVAILABILITY: TTSAvailability = {
|
|
|
35
38
|
sapi: false,
|
|
36
39
|
};
|
|
37
40
|
|
|
41
|
+
class FakeEventBus {
|
|
42
|
+
private readonly handlers = new Map<string, EventBusHandler[]>();
|
|
43
|
+
|
|
44
|
+
public on(channel: string, handler: EventBusHandler): () => void {
|
|
45
|
+
const existing = this.handlers.get(channel) ?? [];
|
|
46
|
+
existing.push(handler);
|
|
47
|
+
this.handlers.set(channel, existing);
|
|
48
|
+
return () => {
|
|
49
|
+
const current = this.handlers.get(channel) ?? [];
|
|
50
|
+
this.handlers.set(channel, current.filter((entry) => entry !== handler));
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public emit(channel: string, payload: unknown): void {
|
|
55
|
+
for (const handler of this.handlers.get(channel) ?? []) {
|
|
56
|
+
handler(payload);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
class FakePi {
|
|
39
62
|
private readonly handlers = new Map<string, EventHandler[]>();
|
|
40
63
|
|
|
64
|
+
public readonly events = new FakeEventBus();
|
|
65
|
+
|
|
41
66
|
public on(eventName: string, handler: EventHandler): void {
|
|
42
67
|
const existing = this.handlers.get(eventName) ?? [];
|
|
43
68
|
existing.push(handler);
|
|
@@ -223,6 +248,32 @@ function permissionEvent(toolCallId: string): { block: boolean; reason: string;
|
|
|
223
248
|
};
|
|
224
249
|
}
|
|
225
250
|
|
|
251
|
+
function permissionSystemEvent(
|
|
252
|
+
state: "waiting" | "approved" | "denied",
|
|
253
|
+
requestId: string,
|
|
254
|
+
overrides: Partial<{
|
|
255
|
+
source: "tool_call" | "skill_input" | "skill_read";
|
|
256
|
+
message: string;
|
|
257
|
+
toolCallId: string;
|
|
258
|
+
toolName: string;
|
|
259
|
+
skillName: string;
|
|
260
|
+
path: string;
|
|
261
|
+
agentName: string | null;
|
|
262
|
+
}> = {},
|
|
263
|
+
): Record<string, unknown> {
|
|
264
|
+
return {
|
|
265
|
+
requestId,
|
|
266
|
+
state,
|
|
267
|
+
source: overrides.source ?? "tool_call",
|
|
268
|
+
message: overrides.message ?? "Current agent requested tool 'write'. Allow this call?",
|
|
269
|
+
toolCallId: overrides.toolCallId,
|
|
270
|
+
toolName: overrides.toolName,
|
|
271
|
+
skillName: overrides.skillName,
|
|
272
|
+
path: overrides.path,
|
|
273
|
+
agentName: overrides.agentName ?? null,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
226
277
|
function forwardedPermissionRequest(
|
|
227
278
|
requestId: string,
|
|
228
279
|
requesterAgentName = "Delegate Alpha",
|
|
@@ -287,6 +338,62 @@ async function tickAndFlush(milliseconds: number): Promise<void> {
|
|
|
287
338
|
await flushAsyncWork();
|
|
288
339
|
}
|
|
289
340
|
|
|
341
|
+
test("permission-system waiting events trigger a permission notification and cancel on resolution", async (t) => {
|
|
342
|
+
disableFocusDetection(t);
|
|
343
|
+
useMockClock(t);
|
|
344
|
+
|
|
345
|
+
const { ctx, pi, ttsCalls } = createHarness();
|
|
346
|
+
|
|
347
|
+
await pi.emit("session_start", {}, ctx);
|
|
348
|
+
await flushAsyncWork();
|
|
349
|
+
pi.events.emit(
|
|
350
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
351
|
+
permissionSystemEvent("waiting", "permission-wait", {
|
|
352
|
+
toolCallId: "call-wait",
|
|
353
|
+
toolName: "write_file",
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
await flushAsyncWork();
|
|
357
|
+
|
|
358
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
359
|
+
|
|
360
|
+
pi.events.emit(
|
|
361
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
362
|
+
permissionSystemEvent("approved", "permission-wait", {
|
|
363
|
+
toolCallId: "call-wait",
|
|
364
|
+
toolName: "write_file",
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
await flushAsyncWork();
|
|
368
|
+
await tickAndFlush(1_000);
|
|
369
|
+
|
|
370
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("permission-system waiting events do not duplicate a later blocked tool_call notification", async (t) => {
|
|
374
|
+
disableFocusDetection(t);
|
|
375
|
+
useMockClock(t);
|
|
376
|
+
|
|
377
|
+
const { ctx, pi, ttsCalls } = createHarness();
|
|
378
|
+
|
|
379
|
+
await pi.emit("session_start", {}, ctx);
|
|
380
|
+
await flushAsyncWork();
|
|
381
|
+
pi.events.emit(
|
|
382
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
383
|
+
permissionSystemEvent("waiting", "permission-dedupe", {
|
|
384
|
+
toolCallId: "call-dedupe",
|
|
385
|
+
toolName: "write_file",
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
await flushAsyncWork();
|
|
389
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
390
|
+
|
|
391
|
+
await pi.emit("tool_call", permissionEvent("call-dedupe"), ctx);
|
|
392
|
+
await flushAsyncWork();
|
|
393
|
+
|
|
394
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
395
|
+
});
|
|
396
|
+
|
|
290
397
|
test("tool_execution_start only cancels the resolved permission reminder flow", async (t) => {
|
|
291
398
|
disableFocusDetection(t);
|
|
292
399
|
useMockClock(t);
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
SOUND_LOOPS,
|
|
26
26
|
STATUS_KEY,
|
|
27
27
|
summarizeConfig,
|
|
28
|
+
toRecord,
|
|
28
29
|
TTS_ENGINE_VALUES,
|
|
29
30
|
writeConfigToDisk,
|
|
30
31
|
boolValue,
|
|
@@ -102,13 +103,6 @@ function classifyToolResult(
|
|
|
102
103
|
return "error";
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function toRecord(value: unknown): Record<string, unknown> {
|
|
106
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
107
|
-
return {};
|
|
108
|
-
}
|
|
109
|
-
return value as Record<string, unknown>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
106
|
function readBlockedReason(value: unknown): string | null {
|
|
113
107
|
const record = toRecord(value);
|
|
114
108
|
const blockValue = record.block;
|
|
@@ -252,6 +246,67 @@ function forwardedPermissionReminderKey(requestId: string): ReminderKey {
|
|
|
252
246
|
: defaultReminderKey("permission");
|
|
253
247
|
}
|
|
254
248
|
|
|
249
|
+
const PERMISSION_SYSTEM_EVENT_CHANNEL = "pi-permission-system:permission-request";
|
|
250
|
+
type PermissionSystemEventState = "waiting" | "approved" | "denied";
|
|
251
|
+
type PermissionSystemEventSource = "tool_call" | "skill_input" | "skill_read";
|
|
252
|
+
|
|
253
|
+
interface PermissionSystemEvent {
|
|
254
|
+
requestId: string;
|
|
255
|
+
state: PermissionSystemEventState;
|
|
256
|
+
source: PermissionSystemEventSource;
|
|
257
|
+
message: string;
|
|
258
|
+
toolCallId?: string;
|
|
259
|
+
toolName?: string;
|
|
260
|
+
skillName?: string;
|
|
261
|
+
path?: string;
|
|
262
|
+
agentName?: string | null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
266
|
+
if (typeof value !== "string") {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const trimmed = value.trim();
|
|
270
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readPermissionSystemEvent(value: unknown): PermissionSystemEvent | null {
|
|
274
|
+
const record = toRecord(value);
|
|
275
|
+
const requestId = normalizeOptionalString(record.requestId);
|
|
276
|
+
const state = normalizeOptionalString(record.state);
|
|
277
|
+
const source = normalizeOptionalString(record.source);
|
|
278
|
+
const message = normalizeOptionalString(record.message);
|
|
279
|
+
if (!requestId || !message) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (state !== "waiting" && state !== "approved" && state !== "denied") {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (source !== "tool_call" && source !== "skill_input" && source !== "skill_read") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
requestId,
|
|
291
|
+
state,
|
|
292
|
+
source,
|
|
293
|
+
message,
|
|
294
|
+
toolCallId: normalizeOptionalString(record.toolCallId),
|
|
295
|
+
toolName: normalizeOptionalString(record.toolName),
|
|
296
|
+
skillName: normalizeOptionalString(record.skillName),
|
|
297
|
+
path: normalizeOptionalString(record.path),
|
|
298
|
+
agentName: typeof record.agentName === "string" ? record.agentName : null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function permissionSystemReminderKey(event: PermissionSystemEvent): ReminderKey {
|
|
303
|
+
if (event.toolCallId) {
|
|
304
|
+
return permissionReminderKey(event.toolCallId);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return `permission:request:${event.requestId}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
255
310
|
export default function smartVoiceNotifyExtension(
|
|
256
311
|
pi: ExtensionAPI,
|
|
257
312
|
dependencies: SmartVoiceNotifyDependencies = {},
|
|
@@ -1027,6 +1082,57 @@ export default function smartVoiceNotifyExtension(
|
|
|
1027
1082
|
);
|
|
1028
1083
|
};
|
|
1029
1084
|
|
|
1085
|
+
pi.events.on(PERMISSION_SYSTEM_EVENT_CHANNEL, (payload: unknown) => {
|
|
1086
|
+
const event = readPermissionSystemEvent(payload);
|
|
1087
|
+
if (!event) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (!config.enabled || !config.enablePermissionNotification) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (!activeSessionContext) {
|
|
1094
|
+
logger.debug("permission_system.notification_skipped", {
|
|
1095
|
+
reason: "missing_session_context",
|
|
1096
|
+
requestId: event.requestId,
|
|
1097
|
+
state: event.state,
|
|
1098
|
+
source: event.source,
|
|
1099
|
+
});
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const reminderKey = permissionSystemReminderKey(event);
|
|
1104
|
+
if (event.state === "waiting") {
|
|
1105
|
+
if (event.toolCallId) {
|
|
1106
|
+
pendingPermissionToolCallIds.add(event.toolCallId);
|
|
1107
|
+
rememberScopedToolCallId(event.toolCallId, blockedPermissionToolCallIds);
|
|
1108
|
+
}
|
|
1109
|
+
logger.debug("permission_system.wait_detected", {
|
|
1110
|
+
requestId: event.requestId,
|
|
1111
|
+
toolCallId: event.toolCallId ?? null,
|
|
1112
|
+
toolName: event.toolName ?? null,
|
|
1113
|
+
skillName: event.skillName ?? null,
|
|
1114
|
+
source: event.source,
|
|
1115
|
+
});
|
|
1116
|
+
triggerNotification("permission", activeSessionContext, {
|
|
1117
|
+
reason: event.message,
|
|
1118
|
+
reminderKey,
|
|
1119
|
+
});
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (event.toolCallId) {
|
|
1124
|
+
pendingPermissionToolCallIds.delete(event.toolCallId);
|
|
1125
|
+
}
|
|
1126
|
+
cancelReminderActivityForKey(reminderKey, "permission_system_wait_resolved", {
|
|
1127
|
+
requestId: event.requestId,
|
|
1128
|
+
toolCallId: event.toolCallId ?? null,
|
|
1129
|
+
toolName: event.toolName ?? null,
|
|
1130
|
+
skillName: event.skillName ?? null,
|
|
1131
|
+
state: event.state,
|
|
1132
|
+
source: event.source,
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1030
1136
|
const applySetting = (draft: VoiceNotifyConfig, id: string, value: string): void => {
|
|
1031
1137
|
switch (id) {
|
|
1032
1138
|
case "enabled":
|
package/src/logging.ts
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
import { appendFileSync } from "node:fs";
|
|
2
|
-
|
|
3
|
-
export function getErrorMessage(error: unknown): string {
|
|
4
|
-
if (error instanceof Error) {
|
|
5
|
-
return error.message;
|
|
6
|
-
}
|
|
7
|
-
return String(error);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function safeJsonStringify(value: unknown): string {
|
|
11
|
-
const seen = new WeakSet<object>();
|
|
12
|
-
return JSON.stringify(value, (_key, currentValue) => {
|
|
13
|
-
if (currentValue instanceof Error) {
|
|
14
|
-
return {
|
|
15
|
-
name: currentValue.name,
|
|
16
|
-
message: currentValue.message,
|
|
17
|
-
stack: currentValue.stack,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
if (typeof currentValue === "bigint") {
|
|
21
|
-
return currentValue.toString();
|
|
22
|
-
}
|
|
23
|
-
if (typeof currentValue === "object" && currentValue !== null) {
|
|
24
|
-
if (seen.has(currentValue)) {
|
|
25
|
-
return "[Circular]";
|
|
26
|
-
}
|
|
27
|
-
seen.add(currentValue);
|
|
28
|
-
}
|
|
29
|
-
return currentValue;
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface LoggerOptions {
|
|
34
|
-
extensionId: string;
|
|
35
|
-
debugLogPath: string;
|
|
36
|
-
isDebugEnabled: () => boolean;
|
|
37
|
-
ensureDebugDirectory: () => void;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ExtensionLogger {
|
|
41
|
-
debug: (event: string, details?: Record<string, unknown>) => void;
|
|
42
|
-
error: (error: unknown) => void;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
|
|
46
|
-
const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
|
|
47
|
-
|
|
48
|
-
const debug = (event: string, details: Record<string, unknown> = {}): void => {
|
|
49
|
-
if (!isDebugEnabled()) {
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
ensureDebugDirectory();
|
|
55
|
-
const line = safeJsonStringify({
|
|
56
|
-
timestamp: new Date().toISOString(),
|
|
57
|
-
extension: extensionId,
|
|
58
|
-
event,
|
|
59
|
-
...details,
|
|
60
|
-
});
|
|
61
|
-
appendFileSync(debugLogPath, `${line}\n`, "utf-8");
|
|
62
|
-
} catch (error) {
|
|
63
|
-
console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const error = (cause: unknown): void => {
|
|
68
|
-
debug("runtime.error", { error: cause });
|
|
69
|
-
console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
return { debug, error };
|
|
73
|
-
}
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function getErrorMessage(error: unknown): string {
|
|
4
|
+
if (error instanceof Error) {
|
|
5
|
+
return error.message;
|
|
6
|
+
}
|
|
7
|
+
return String(error);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function safeJsonStringify(value: unknown): string {
|
|
11
|
+
const seen = new WeakSet<object>();
|
|
12
|
+
return JSON.stringify(value, (_key, currentValue) => {
|
|
13
|
+
if (currentValue instanceof Error) {
|
|
14
|
+
return {
|
|
15
|
+
name: currentValue.name,
|
|
16
|
+
message: currentValue.message,
|
|
17
|
+
stack: currentValue.stack,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (typeof currentValue === "bigint") {
|
|
21
|
+
return currentValue.toString();
|
|
22
|
+
}
|
|
23
|
+
if (typeof currentValue === "object" && currentValue !== null) {
|
|
24
|
+
if (seen.has(currentValue)) {
|
|
25
|
+
return "[Circular]";
|
|
26
|
+
}
|
|
27
|
+
seen.add(currentValue);
|
|
28
|
+
}
|
|
29
|
+
return currentValue;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface LoggerOptions {
|
|
34
|
+
extensionId: string;
|
|
35
|
+
debugLogPath: string;
|
|
36
|
+
isDebugEnabled: () => boolean;
|
|
37
|
+
ensureDebugDirectory: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExtensionLogger {
|
|
41
|
+
debug: (event: string, details?: Record<string, unknown>) => void;
|
|
42
|
+
error: (error: unknown) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
|
|
46
|
+
const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
|
|
47
|
+
|
|
48
|
+
const debug = (event: string, details: Record<string, unknown> = {}): void => {
|
|
49
|
+
if (!isDebugEnabled()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
ensureDebugDirectory();
|
|
55
|
+
const line = safeJsonStringify({
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
extension: extensionId,
|
|
58
|
+
event,
|
|
59
|
+
...details,
|
|
60
|
+
});
|
|
61
|
+
appendFileSync(debugLogPath, `${line}\n`, "utf-8");
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const error = (cause: unknown): void => {
|
|
68
|
+
debug("runtime.error", { error: cause });
|
|
69
|
+
console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { debug, error };
|
|
73
|
+
}
|
package/src/per-project-sound.ts
CHANGED
|
@@ -14,7 +14,7 @@ const PROJECT_MARKERS = [
|
|
|
14
14
|
".pi",
|
|
15
15
|
] as const;
|
|
16
16
|
|
|
17
|
-
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
|
|
17
|
+
export const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
|
|
18
18
|
|
|
19
19
|
export const PROJECT_SOUND_CATEGORIES = ["notification", "alert", "success", "error", "reminder"] as const;
|
|
20
20
|
|
|
@@ -49,11 +49,11 @@ interface ProjectSoundsManifest {
|
|
|
49
49
|
let activeProjectRoot: string | null = null;
|
|
50
50
|
const projectSoundCache = new Map<string, ProjectSoundContext | null>();
|
|
51
51
|
|
|
52
|
-
function noop(): void {
|
|
52
|
+
export function noop(): void {
|
|
53
53
|
// no-op
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function pathExists(pathValue: string): Promise<boolean> {
|
|
56
|
+
export async function pathExists(pathValue: string): Promise<boolean> {
|
|
57
57
|
try {
|
|
58
58
|
await access(pathValue, fsConstants.F_OK);
|
|
59
59
|
return true;
|
|
@@ -62,7 +62,7 @@ async function pathExists(pathValue: string): Promise<boolean> {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async function isDirectory(pathValue: string): Promise<boolean> {
|
|
65
|
+
export async function isDirectory(pathValue: string): Promise<boolean> {
|
|
66
66
|
try {
|
|
67
67
|
const stats = await stat(pathValue);
|
|
68
68
|
return stats.isDirectory();
|
|
@@ -71,7 +71,7 @@ async function isDirectory(pathValue: string): Promise<boolean> {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function isReadableAudioFile(pathValue: string): Promise<boolean> {
|
|
74
|
+
export async function isReadableAudioFile(pathValue: string): Promise<boolean> {
|
|
75
75
|
if (!AUDIO_EXTENSIONS.has(extname(pathValue).toLowerCase())) {
|
|
76
76
|
return false;
|
|
77
77
|
}
|
|
@@ -88,7 +88,7 @@ async function isReadableAudioFile(pathValue: string): Promise<boolean> {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
async function listAudioFiles(directory: string): Promise<string[]> {
|
|
91
|
+
export async function listAudioFiles(directory: string): Promise<string[]> {
|
|
92
92
|
if (!(await isDirectory(directory))) {
|
|
93
93
|
return [];
|
|
94
94
|
}
|
|
@@ -110,16 +110,16 @@ async function listAudioFiles(directory: string): Promise<string[]> {
|
|
|
110
110
|
return files;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function uniquePaths(paths: string[]): string[] {
|
|
113
|
+
export function uniquePaths(paths: string[]): string[] {
|
|
114
114
|
return [...new Set(paths.map((entry) => resolve(entry)))];
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function normalizeVolume(value: number | undefined): number |
|
|
117
|
+
export function normalizeVolume(value: number | undefined | null): number | null {
|
|
118
118
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
119
|
-
return
|
|
119
|
+
return null;
|
|
120
120
|
}
|
|
121
121
|
const clamped = Math.max(0, Math.min(100, Math.round(value)));
|
|
122
|
-
return Number.isFinite(clamped) ? clamped :
|
|
122
|
+
return Number.isFinite(clamped) ? clamped : null;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
async function hasProjectMarker(directory: string): Promise<boolean> {
|
|
@@ -240,7 +240,7 @@ async function resolveVolumeByFile(
|
|
|
240
240
|
const resolved: Record<string, number> = {};
|
|
241
241
|
for (const [filePath, rawVolume] of Object.entries(volumeByFile)) {
|
|
242
242
|
const normalizedVolume = normalizeVolume(rawVolume);
|
|
243
|
-
if (normalizedVolume ===
|
|
243
|
+
if (normalizedVolume === null) {
|
|
244
244
|
continue;
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { getErrorMessage } from "./logging.ts";
|
|
6
|
+
import { toRecord } from "./config-store.ts";
|
|
6
7
|
|
|
7
8
|
export type PermissionForwardingSource = "primary" | "legacy";
|
|
8
9
|
export type ForwardedPermissionResolutionReason = "request_removed" | "watch_disabled" | "watcher_stopped";
|
|
@@ -53,13 +54,6 @@ interface TrackedForwardedPermissionRequest extends ForwardedPermissionRequestEv
|
|
|
53
54
|
lastSeenAt: number;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function toRecord(value: unknown): Record<string, unknown> {
|
|
57
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
-
return {};
|
|
59
|
-
}
|
|
60
|
-
return value as Record<string, unknown>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
57
|
function normalizeRequestId(value: unknown, filePath: string): string {
|
|
64
58
|
if (typeof value === "string") {
|
|
65
59
|
const trimmed = value.trim();
|
package/src/sound-theme.ts
CHANGED
|
@@ -4,7 +4,16 @@ import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
import { getCurrentVolume, playAudio, setVolume } from "./linux.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
AUDIO_EXTENSIONS,
|
|
9
|
+
isDirectory as checkIsDirectory,
|
|
10
|
+
isReadableAudioFile as checkIsReadableAudioFile,
|
|
11
|
+
listAudioFiles as listProjectAudioFiles,
|
|
12
|
+
noop,
|
|
13
|
+
normalizeVolume,
|
|
14
|
+
resolveProjectSoundContext,
|
|
15
|
+
uniquePaths,
|
|
16
|
+
} from "./per-project-sound.ts";
|
|
8
17
|
|
|
9
18
|
export const SOUND_CATEGORIES = ["notification", "alert", "success", "error", "reminder"] as const;
|
|
10
19
|
|
|
@@ -64,7 +73,6 @@ export interface SoundThemeServiceOptions {
|
|
|
64
73
|
debugLog?: (message: string) => void;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
const SUPPORTED_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
|
|
68
76
|
const EVENT_TO_CATEGORY: Record<string, SoundCategory> = {
|
|
69
77
|
notification: "notification",
|
|
70
78
|
alert: "alert",
|
|
@@ -82,24 +90,7 @@ const DEFAULT_ASSETS_DIRECTORY = fileURLToPath(new URL("../assets", import.meta.
|
|
|
82
90
|
|
|
83
91
|
type DebugLog = (message: string) => void;
|
|
84
92
|
|
|
85
|
-
function
|
|
86
|
-
// no-op
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function normalizeVolume(value: number | undefined | null): number | null {
|
|
90
|
-
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
const clamped = Math.max(0, Math.min(100, Math.round(value)));
|
|
94
|
-
return Number.isFinite(clamped) ? clamped : null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function toUniquePaths(paths: string[]): string[] {
|
|
98
|
-
const normalized = paths.map((value) => resolve(value));
|
|
99
|
-
return [...new Set(normalized)];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function pathExists(pathValue: string): Promise<boolean> {
|
|
93
|
+
async function isReadable(pathValue: string): Promise<boolean> {
|
|
103
94
|
try {
|
|
104
95
|
await access(pathValue, fsConstants.R_OK);
|
|
105
96
|
return true;
|
|
@@ -108,51 +99,11 @@ async function pathExists(pathValue: string): Promise<boolean> {
|
|
|
108
99
|
}
|
|
109
100
|
}
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const stats = await stat(pathValue);
|
|
114
|
-
return stats.isDirectory();
|
|
115
|
-
} catch {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
102
|
+
const isDirectory = checkIsDirectory;
|
|
119
103
|
|
|
120
|
-
|
|
121
|
-
if (!SUPPORTED_EXTENSIONS.has(extname(pathValue).toLowerCase())) {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
const stats = await stat(pathValue);
|
|
126
|
-
if (!stats.isFile()) {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
await access(pathValue, fsConstants.R_OK);
|
|
130
|
-
return true;
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function listAudioFiles(directory: string): Promise<string[]> {
|
|
137
|
-
if (!(await isDirectory(directory))) {
|
|
138
|
-
return [];
|
|
139
|
-
}
|
|
104
|
+
const isReadableAudioFile = checkIsReadableAudioFile;
|
|
140
105
|
|
|
141
|
-
|
|
142
|
-
const files = entries
|
|
143
|
-
.filter((entry) => entry.isFile())
|
|
144
|
-
.map((entry) => join(directory, entry.name))
|
|
145
|
-
.filter((filePath) => SUPPORTED_EXTENSIONS.has(extname(filePath).toLowerCase()))
|
|
146
|
-
.sort((left, right) => left.localeCompare(right));
|
|
147
|
-
|
|
148
|
-
const validFiles: string[] = [];
|
|
149
|
-
for (const filePath of files) {
|
|
150
|
-
if (await isReadableAudioFile(filePath)) {
|
|
151
|
-
validFiles.push(filePath);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return validFiles;
|
|
155
|
-
}
|
|
106
|
+
const listAudioFiles = listProjectAudioFiles;
|
|
156
107
|
|
|
157
108
|
async function resolveSoundReference(reference: string, searchDirectories: string[]): Promise<string | null> {
|
|
158
109
|
const trimmed = reference.trim();
|
|
@@ -192,7 +143,7 @@ async function loadManifest(themeDirectory: string): Promise<ThemeManifest | nul
|
|
|
192
143
|
const manifestCandidates = ["theme.json", "sound-theme.json"].map((fileName) => join(themeDirectory, fileName));
|
|
193
144
|
|
|
194
145
|
for (const manifestPath of manifestCandidates) {
|
|
195
|
-
if (!(await
|
|
146
|
+
if (!(await isReadable(manifestPath))) {
|
|
196
147
|
continue;
|
|
197
148
|
}
|
|
198
149
|
try {
|
|
@@ -213,7 +164,7 @@ async function loadConfigFromFile(configPath: string | undefined): Promise<Sound
|
|
|
213
164
|
if (!configPath) {
|
|
214
165
|
return {};
|
|
215
166
|
}
|
|
216
|
-
if (!(await
|
|
167
|
+
if (!(await isReadable(configPath))) {
|
|
217
168
|
return {};
|
|
218
169
|
}
|
|
219
170
|
|
|
@@ -299,7 +250,7 @@ async function resolveThemeDirectories(config: SoundThemeConfig, assetsDirectory
|
|
|
299
250
|
}
|
|
300
251
|
directories.push(assetsDirectory);
|
|
301
252
|
|
|
302
|
-
const uniqueDirectories =
|
|
253
|
+
const uniqueDirectories = uniquePaths(directories);
|
|
303
254
|
const existingDirectories: string[] = [];
|
|
304
255
|
for (const directory of uniqueDirectories) {
|
|
305
256
|
if (await isDirectory(directory)) {
|
|
@@ -414,7 +365,7 @@ export class SoundThemeService {
|
|
|
414
365
|
const fromCategoryDirectory = await listAudioFiles(categoryDir);
|
|
415
366
|
categoryCandidates.push(...fromCategoryDirectory);
|
|
416
367
|
|
|
417
|
-
for (const extension of
|
|
368
|
+
for (const extension of AUDIO_EXTENSIONS) {
|
|
418
369
|
const directFile = join(directory, `${category}${extension}`);
|
|
419
370
|
if (await isReadableAudioFile(directFile)) {
|
|
420
371
|
categoryCandidates.push(directFile);
|
|
@@ -422,7 +373,7 @@ export class SoundThemeService {
|
|
|
422
373
|
}
|
|
423
374
|
}
|
|
424
375
|
|
|
425
|
-
const uniqueCategoryCandidates =
|
|
376
|
+
const uniqueCategoryCandidates = uniquePaths(categoryCandidates);
|
|
426
377
|
if (uniqueCategoryCandidates.length > 0) {
|
|
427
378
|
soundsByCategory[category] = uniqueCategoryCandidates;
|
|
428
379
|
continue;
|
package/src/tts.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
7
|
import { runAbortableCommand } from "./abortable-command.ts";
|
|
8
|
+
import { normalizeFloat } from "./config-store.ts";
|
|
8
9
|
import { getErrorMessage } from "./logging.ts";
|
|
9
10
|
import type {
|
|
10
11
|
ConcreteTTSEngine,
|
|
@@ -71,13 +72,6 @@ function normalizeRate(value: number, fallback: number, min: number, max: number
|
|
|
71
72
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
|
|
75
|
-
if (!Number.isFinite(value)) {
|
|
76
|
-
return fallback;
|
|
77
|
-
}
|
|
78
|
-
return Math.min(max, Math.max(min, value));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
75
|
function mergeConfig(base: TTSConfig, overrides: Partial<TTSConfig>): TTSConfig {
|
|
82
76
|
return {
|
|
83
77
|
...base,
|