@moxxy/plugin-voice-admin 0.26.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
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.
@@ -0,0 +1,24 @@
1
+ import type { Session } from '@moxxy/core';
2
+ import { type Plugin } from '@moxxy/sdk';
3
+ /**
4
+ * Voice/TTS control plugin — lets the agent switch which text-to-speech backend
5
+ * read-aloud surfaces (the desktop's speaker button) use, without a settings UI.
6
+ * `set_voice` activates a registered synthesizer by name, or 'system' to
7
+ * deactivate (fall back to the OS voice). `list_voices` reports what's available
8
+ * + which is active. A synthesizer authored via self-update auto-activates on
9
+ * load, so this is for switching afterwards.
10
+ *
11
+ * The synthesizer registry lives on the live Session, so this is built via a
12
+ * factory closing over it (mirroring `buildViewPlugin` / `buildSubagentsPlugin`)
13
+ * and wired into the cli's builtin `entries` list.
14
+ */
15
+ export declare function buildVoiceAdminPlugin(session: Session): Plugin;
16
+ /**
17
+ * Discovery-loadable default export: resolves the synthesizer registry from the
18
+ * inter-plugin service registry in `onInit` (the host publishes `'synthesizers'`),
19
+ * so the tools control the live session's TTS backend without a host-injected
20
+ * `session` closure.
21
+ */
22
+ export declare const voiceAdminPlugin: Plugin;
23
+ export default voiceAdminPlugin;
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAuB,MAAM,aAAa,CAAC;AAChE,OAAO,EAAoD,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAE3F;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAE9D;AAED;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAe3B,CAAC;AA+EL,eAAe,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ import { definePlugin, defineTool, z } from '@moxxy/sdk';
2
+ /**
3
+ * Voice/TTS control plugin — lets the agent switch which text-to-speech backend
4
+ * read-aloud surfaces (the desktop's speaker button) use, without a settings UI.
5
+ * `set_voice` activates a registered synthesizer by name, or 'system' to
6
+ * deactivate (fall back to the OS voice). `list_voices` reports what's available
7
+ * + which is active. A synthesizer authored via self-update auto-activates on
8
+ * load, so this is for switching afterwards.
9
+ *
10
+ * The synthesizer registry lives on the live Session, so this is built via a
11
+ * factory closing over it (mirroring `buildViewPlugin` / `buildSubagentsPlugin`)
12
+ * and wired into the cli's builtin `entries` list.
13
+ */
14
+ export function buildVoiceAdminPlugin(session) {
15
+ return makeVoiceAdminPlugin(() => session.synthesizers);
16
+ }
17
+ /**
18
+ * Discovery-loadable default export: resolves the synthesizer registry from the
19
+ * inter-plugin service registry in `onInit` (the host publishes `'synthesizers'`),
20
+ * so the tools control the live session's TTS backend without a host-injected
21
+ * `session` closure.
22
+ */
23
+ export const voiceAdminPlugin = (() => {
24
+ let reg = null;
25
+ const hooks = {
26
+ onInit: (ctx) => {
27
+ reg = ctx.services.get('synthesizers') ?? null;
28
+ },
29
+ };
30
+ return makeVoiceAdminPlugin(() => {
31
+ if (!reg) {
32
+ throw new Error('@moxxy/plugin-voice-admin: the "synthesizers" registry is unavailable — the host must publish it');
33
+ }
34
+ return reg;
35
+ }, hooks);
36
+ })();
37
+ function makeVoiceAdminPlugin(getSynths, hooks) {
38
+ // 'system' (the OS voice) plus every registered synthesizer — the one
39
+ // list both tools speak in terms of (list_voices' result and
40
+ // set_voice's "unknown name" hint). The synthesizer namespace is
41
+ // attacker-influenced (any plugin / a self-update-authored backend can
42
+ // register a name), so de-dupe in case one is literally named 'system' —
43
+ // otherwise it would be listed twice.
44
+ const voiceNames = () => [
45
+ 'system',
46
+ ...getSynths()
47
+ .list()
48
+ .map((s) => s.name)
49
+ .filter((n) => n !== 'system'),
50
+ ];
51
+ return definePlugin({
52
+ name: '@moxxy/plugin-voice-admin',
53
+ version: '0.0.1',
54
+ ...(hooks ? { hooks } : {}),
55
+ tools: [
56
+ defineTool({
57
+ name: 'list_voices',
58
+ description: 'List the text-to-speech (synthesizer) backends registered on this ' +
59
+ 'session and which one is active. "system" means the OS voice (no ' +
60
+ 'plugin synthesizer active).',
61
+ inputSchema: z.object({}),
62
+ permission: { action: 'allow' },
63
+ handler: () => ({
64
+ active: getSynths().getActiveName() ?? 'system',
65
+ available: voiceNames(),
66
+ }),
67
+ }),
68
+ defineTool({
69
+ name: 'set_voice',
70
+ description: 'Choose which text-to-speech backend read-aloud uses. Pass a ' +
71
+ 'registered synthesizer name (see list_voices) to activate it, or ' +
72
+ '"system" to fall back to the OS voice. Use this to switch between ' +
73
+ 'an installed TTS plugin (e.g. ElevenLabs) and the built-in voice.',
74
+ inputSchema: z.object({
75
+ synthesizer: z
76
+ .string()
77
+ .min(1)
78
+ .describe('Synthesizer name to activate, or "system" for the OS voice.'),
79
+ }),
80
+ // Auto-allowed because switching the active TTS backend has a low blast
81
+ // radius: it only flips the registry's active slot. The synthesizer is
82
+ // not built here (the registry is buildOnRead), so no secret read or
83
+ // network call happens at set_voice time — the first read-aloud builds
84
+ // it under the same vault/network gates a registered plugin already has.
85
+ permission: { action: 'allow' },
86
+ handler: ({ synthesizer }) => {
87
+ // Treat 'system' as the OS-voice sentinel only when no real
88
+ // synthesizer claims that name. A backend literally named 'system'
89
+ // (registrations are attacker-influenced) is otherwise permanently
90
+ // unreachable; fall through to activate it.
91
+ if (synthesizer === 'system' && !getSynths().has('system')) {
92
+ getSynths().clearActive();
93
+ return { active: 'system' };
94
+ }
95
+ if (!getSynths().has(synthesizer)) {
96
+ throw new Error(`No synthesizer named "${synthesizer}". Available: ${voiceNames().join(', ')}.`);
97
+ }
98
+ getSynths().setActive(synthesizer);
99
+ return { active: synthesizer };
100
+ },
101
+ }),
102
+ ],
103
+ });
104
+ }
105
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
106
+ export default voiceAdminPlugin;
107
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@moxxy/plugin-voice-admin",
3
+ "version": "0.26.0",
4
+ "description": "Voice/TTS control for moxxy. set_voice / list_voices let the agent switch which text-to-speech backend read-aloud surfaces use (a registered synthesizer, or 'system' for the OS voice) without a settings UI.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "voice",
9
+ "tts",
10
+ "tools"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-voice-admin"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "tools"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "zod": "^3.24.0",
47
+ "@moxxy/sdk": "0.26.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^2.1.8",
53
+ "@moxxy/tsconfig": "0.0.0",
54
+ "@moxxy/vitest-preset": "0.0.0",
55
+ "@moxxy/core": "0.26.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.json",
59
+ "typecheck": "tsc -p tsconfig.json --noEmit",
60
+ "test": "vitest run",
61
+ "clean": "rm -rf dist .turbo"
62
+ }
63
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ServiceRegistry } from '@moxxy/sdk';
3
+ import { voiceAdminPlugin } from './index.js';
4
+
5
+ function fakeSynths(active: string | null, names: string[]) {
6
+ let cur = active;
7
+ return {
8
+ list: () => names.map((name) => ({ name })),
9
+ has: (n: string) => names.includes(n),
10
+ getActiveName: () => cur,
11
+ setActive: (n: string) => {
12
+ cur = n;
13
+ },
14
+ clearActive: () => {
15
+ cur = null;
16
+ },
17
+ };
18
+ }
19
+
20
+ /**
21
+ * The discovery-loadable default export resolves the synthesizer registry from
22
+ * the inter-plugin service registry in onInit instead of a `build*(session)`
23
+ * closure.
24
+ */
25
+ describe('voiceAdminPlugin (discovery-loadable)', () => {
26
+ it('exposes list_voices/set_voice + an onInit hook', () => {
27
+ expect(voiceAdminPlugin.tools?.map((t) => t.name).sort()).toEqual(['list_voices', 'set_voice']);
28
+ expect(typeof voiceAdminPlugin.hooks?.onInit).toBe('function');
29
+ });
30
+
31
+ it('onInit wires the synthesizers registry; the tools read it live', async () => {
32
+ const synths = fakeSynths('eleven', ['eleven', 'openai']);
33
+ const services = {
34
+ get: () => synths,
35
+ require: () => synths,
36
+ has: () => true,
37
+ register: () => {},
38
+ } as unknown as ServiceRegistry;
39
+ voiceAdminPlugin.hooks!.onInit!({ services } as never);
40
+
41
+ const listVoices = voiceAdminPlugin.tools!.find((t) => t.name === 'list_voices')!;
42
+ const listed = await listVoices.handler({} as never, {} as never);
43
+ expect(listed).toEqual({ active: 'eleven', available: ['system', 'eleven', 'openai'] });
44
+
45
+ const setVoice = voiceAdminPlugin.tools!.find((t) => t.name === 'set_voice')!;
46
+ const set = await setVoice.handler({ synthesizer: 'openai' } as never, {} as never);
47
+ expect(set).toEqual({ active: 'openai' });
48
+ expect(synths.getActiveName()).toBe('openai');
49
+ });
50
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Session, silentLogger } from '@moxxy/core';
3
+ import { buildVoiceAdminPlugin } from './index.js';
4
+
5
+ /**
6
+ * Focused unit test for the voice-admin tools. Drives the tool handlers
7
+ * directly against a bare Session's synthesizer registry.
8
+ */
9
+ function tools(session: Session) {
10
+ const plugin = buildVoiceAdminPlugin(session);
11
+ const byName = new Map((plugin.tools ?? []).map((t) => [t.name, t]));
12
+ return {
13
+ listVoices: byName.get('list_voices')!,
14
+ setVoice: byName.get('set_voice')!,
15
+ };
16
+ }
17
+
18
+ const ctx = { sessionId: 's', turnId: 't' } as never;
19
+
20
+ /** Minimal registrable synthesizer def (create() is never invoked here — the
21
+ * registry is buildOnRead, so set_voice only flips the active slot). */
22
+ function fakeSynth(name: string) {
23
+ return {
24
+ name,
25
+ create: () => ({
26
+ name,
27
+ synthesize: async () => ({ audio: new Uint8Array(), mimeType: 'audio/mpeg' }),
28
+ }),
29
+ };
30
+ }
31
+
32
+ describe('voice-admin plugin', () => {
33
+ it('registers list_voices and set_voice', () => {
34
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
35
+ const plugin = buildVoiceAdminPlugin(session);
36
+ expect(plugin.name).toBe('@moxxy/plugin-voice-admin');
37
+ expect((plugin.tools ?? []).map((t) => t.name).sort()).toEqual(['list_voices', 'set_voice']);
38
+ });
39
+
40
+ it('list_voices reports "system" active by default and lists registered synthesizers', async () => {
41
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
42
+ const { listVoices } = tools(session);
43
+ const result = (await listVoices.handler({}, ctx)) as { active: string; available: string[] };
44
+ expect(result.active).toBe('system');
45
+ expect(result.available).toContain('system');
46
+ });
47
+
48
+ it('set_voice "system" clears the active synthesizer', async () => {
49
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
50
+ const { setVoice } = tools(session);
51
+ const result = (await setVoice.handler({ synthesizer: 'system' }, ctx)) as { active: string };
52
+ expect(result.active).toBe('system');
53
+ expect(session.synthesizers.getActiveName() ?? 'system').toBe('system');
54
+ });
55
+
56
+ it('set_voice throws on an unknown synthesizer name', async () => {
57
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
58
+ const { setVoice } = tools(session);
59
+ // The handler is synchronous, so it throws synchronously.
60
+ expect(() => setVoice.handler({ synthesizer: 'nope' }, ctx)).toThrow(/No synthesizer named "nope"/);
61
+ });
62
+
63
+ it('set_voice activates a registered synthesizer and list_voices reflects it', async () => {
64
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
65
+ session.synthesizers.register(fakeSynth('elevenlabs'));
66
+ const { setVoice, listVoices } = tools(session);
67
+
68
+ const result = (await setVoice.handler({ synthesizer: 'elevenlabs' }, ctx)) as { active: string };
69
+ expect(result.active).toBe('elevenlabs');
70
+ expect(session.synthesizers.getActiveName()).toBe('elevenlabs');
71
+
72
+ const listed = (await listVoices.handler({}, ctx)) as { active: string; available: string[] };
73
+ expect(listed.active).toBe('elevenlabs');
74
+ expect(listed.available).toContain('elevenlabs');
75
+ });
76
+
77
+ it('a synthesizer literally named "system" is reachable and not double-listed', async () => {
78
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
79
+ session.synthesizers.register(fakeSynth('system'));
80
+ const { setVoice, listVoices } = tools(session);
81
+
82
+ // "system" no longer always clears — it activates the real backend.
83
+ const result = (await setVoice.handler({ synthesizer: 'system' }, ctx)) as { active: string };
84
+ expect(result.active).toBe('system');
85
+ expect(session.synthesizers.getActiveName()).toBe('system');
86
+
87
+ // ...and it appears exactly once in the listing (sentinel de-duped).
88
+ const listed = (await listVoices.handler({}, ctx)) as { active: string; available: string[] };
89
+ expect(listed.available.filter((n) => n === 'system')).toHaveLength(1);
90
+ });
91
+
92
+ // --- failure-path / hostile-input regressions ---------------------------
93
+
94
+ it('the input schema (the production gate) rejects empty / missing synthesizer', () => {
95
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
96
+ const { setVoice } = tools(session);
97
+ // The framework parses tool input against `inputSchema` BEFORE the handler
98
+ // runs (see defineTool). An empty string or a missing field must be rejected
99
+ // at that gate, never reaching the handler.
100
+ expect(setVoice.inputSchema.safeParse({ synthesizer: '' }).success).toBe(false);
101
+ expect(setVoice.inputSchema.safeParse({}).success).toBe(false);
102
+ expect(setVoice.inputSchema.safeParse({ synthesizer: 42 }).success).toBe(false);
103
+ expect(setVoice.inputSchema.safeParse({ synthesizer: 'elevenlabs' }).success).toBe(true);
104
+ });
105
+
106
+ it('set_voice degrades (does not crash) on whitespace-only and space-padded names', () => {
107
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
108
+ session.synthesizers.register(fakeSynth('elevenlabs'));
109
+ const { setVoice } = tools(session);
110
+ // A single space passes the schema's min(1) but is not a real synthesizer
111
+ // and must NOT be coerced into the 'system' sentinel — it throws cleanly.
112
+ expect(() => setVoice.handler({ synthesizer: ' ' }, ctx)).toThrow(/No synthesizer named " "/);
113
+ // A padded form of a real name does not match (no trimming/normalization):
114
+ // hostile/typo'd input is rejected, the active backend is untouched.
115
+ expect(() => setVoice.handler({ synthesizer: ' elevenlabs ' }, ctx)).toThrow(
116
+ /No synthesizer named/,
117
+ );
118
+ // The pre-existing active backend (auto-adopted on register) survives the
119
+ // rejected switches — hostile input never silently flips or clears it.
120
+ expect(session.synthesizers.getActiveName()).toBe('elevenlabs');
121
+ });
122
+
123
+ it('the unknown-name error hint lists available voices without crashing', () => {
124
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
125
+ session.synthesizers.register(fakeSynth('elevenlabs'));
126
+ const { setVoice } = tools(session);
127
+ // The error message is the model's recovery hint — it must enumerate the
128
+ // real options (including the 'system' sentinel) and not throw building it.
129
+ expect(() => setVoice.handler({ synthesizer: 'nope' }, ctx)).toThrow(
130
+ /Available: system, elevenlabs\./,
131
+ );
132
+ });
133
+
134
+ it('after activating "system" (the real backend), switching to another name then back to the sentinel still works', async () => {
135
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
136
+ session.synthesizers.register(fakeSynth('system'));
137
+ session.synthesizers.register(fakeSynth('elevenlabs'));
138
+ const { setVoice } = tools(session);
139
+
140
+ // Activate the real 'system' backend (collision branch).
141
+ await setVoice.handler({ synthesizer: 'system' }, ctx);
142
+ expect(session.synthesizers.getActiveName()).toBe('system');
143
+ // Switch away to another registered backend.
144
+ await setVoice.handler({ synthesizer: 'elevenlabs' }, ctx);
145
+ expect(session.synthesizers.getActiveName()).toBe('elevenlabs');
146
+ // Switch back to the 'system'-named backend — still resolves the real def,
147
+ // never the clear-active sentinel, because `has('system')` is true.
148
+ await setVoice.handler({ synthesizer: 'system' }, ctx);
149
+ expect(session.synthesizers.getActiveName()).toBe('system');
150
+ });
151
+
152
+ it('list_voices puts the "system" sentinel first and is deterministic', async () => {
153
+ const session = new Session({ cwd: process.cwd(), logger: silentLogger });
154
+ session.synthesizers.register(fakeSynth('zeta'));
155
+ session.synthesizers.register(fakeSynth('alpha'));
156
+ const { listVoices } = tools(session);
157
+ const listed = (await listVoices.handler({}, ctx)) as { active: string; available: string[] };
158
+ // Stable, registration-order output with the sentinel always leading — the
159
+ // agent gets a predictable list to reason over.
160
+ expect(listed.available).toEqual(['system', 'zeta', 'alpha']);
161
+ });
162
+ });
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { Session, SynthesizerRegistry } from '@moxxy/core';
2
+ import { definePlugin, defineTool, z, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
3
+
4
+ /**
5
+ * Voice/TTS control plugin — lets the agent switch which text-to-speech backend
6
+ * read-aloud surfaces (the desktop's speaker button) use, without a settings UI.
7
+ * `set_voice` activates a registered synthesizer by name, or 'system' to
8
+ * deactivate (fall back to the OS voice). `list_voices` reports what's available
9
+ * + which is active. A synthesizer authored via self-update auto-activates on
10
+ * load, so this is for switching afterwards.
11
+ *
12
+ * The synthesizer registry lives on the live Session, so this is built via a
13
+ * factory closing over it (mirroring `buildViewPlugin` / `buildSubagentsPlugin`)
14
+ * and wired into the cli's builtin `entries` list.
15
+ */
16
+ export function buildVoiceAdminPlugin(session: Session): Plugin {
17
+ return makeVoiceAdminPlugin(() => session.synthesizers);
18
+ }
19
+
20
+ /**
21
+ * Discovery-loadable default export: resolves the synthesizer registry from the
22
+ * inter-plugin service registry in `onInit` (the host publishes `'synthesizers'`),
23
+ * so the tools control the live session's TTS backend without a host-injected
24
+ * `session` closure.
25
+ */
26
+ export const voiceAdminPlugin: Plugin = (() => {
27
+ let reg: SynthesizerRegistry | null = null;
28
+ const hooks: LifecycleHooks = {
29
+ onInit: (ctx) => {
30
+ reg = ctx.services.get<SynthesizerRegistry>('synthesizers') ?? null;
31
+ },
32
+ };
33
+ return makeVoiceAdminPlugin(() => {
34
+ if (!reg) {
35
+ throw new Error(
36
+ '@moxxy/plugin-voice-admin: the "synthesizers" registry is unavailable — the host must publish it',
37
+ );
38
+ }
39
+ return reg;
40
+ }, hooks);
41
+ })();
42
+
43
+ function makeVoiceAdminPlugin(
44
+ getSynths: () => SynthesizerRegistry,
45
+ hooks?: LifecycleHooks,
46
+ ): Plugin {
47
+ // 'system' (the OS voice) plus every registered synthesizer — the one
48
+ // list both tools speak in terms of (list_voices' result and
49
+ // set_voice's "unknown name" hint). The synthesizer namespace is
50
+ // attacker-influenced (any plugin / a self-update-authored backend can
51
+ // register a name), so de-dupe in case one is literally named 'system' —
52
+ // otherwise it would be listed twice.
53
+ const voiceNames = (): string[] => [
54
+ 'system',
55
+ ...getSynths()
56
+ .list()
57
+ .map((s) => s.name)
58
+ .filter((n) => n !== 'system'),
59
+ ];
60
+ return definePlugin({
61
+ name: '@moxxy/plugin-voice-admin',
62
+ version: '0.0.1',
63
+ ...(hooks ? { hooks } : {}),
64
+ tools: [
65
+ defineTool({
66
+ name: 'list_voices',
67
+ description:
68
+ 'List the text-to-speech (synthesizer) backends registered on this ' +
69
+ 'session and which one is active. "system" means the OS voice (no ' +
70
+ 'plugin synthesizer active).',
71
+ inputSchema: z.object({}),
72
+ permission: { action: 'allow' },
73
+ handler: () => ({
74
+ active: getSynths().getActiveName() ?? 'system',
75
+ available: voiceNames(),
76
+ }),
77
+ }),
78
+ defineTool({
79
+ name: 'set_voice',
80
+ description:
81
+ 'Choose which text-to-speech backend read-aloud uses. Pass a ' +
82
+ 'registered synthesizer name (see list_voices) to activate it, or ' +
83
+ '"system" to fall back to the OS voice. Use this to switch between ' +
84
+ 'an installed TTS plugin (e.g. ElevenLabs) and the built-in voice.',
85
+ inputSchema: z.object({
86
+ synthesizer: z
87
+ .string()
88
+ .min(1)
89
+ .describe('Synthesizer name to activate, or "system" for the OS voice.'),
90
+ }),
91
+ // Auto-allowed because switching the active TTS backend has a low blast
92
+ // radius: it only flips the registry's active slot. The synthesizer is
93
+ // not built here (the registry is buildOnRead), so no secret read or
94
+ // network call happens at set_voice time — the first read-aloud builds
95
+ // it under the same vault/network gates a registered plugin already has.
96
+ permission: { action: 'allow' },
97
+ handler: ({ synthesizer }) => {
98
+ // Treat 'system' as the OS-voice sentinel only when no real
99
+ // synthesizer claims that name. A backend literally named 'system'
100
+ // (registrations are attacker-influenced) is otherwise permanently
101
+ // unreachable; fall through to activate it.
102
+ if (synthesizer === 'system' && !getSynths().has('system')) {
103
+ getSynths().clearActive();
104
+ return { active: 'system' };
105
+ }
106
+ if (!getSynths().has(synthesizer)) {
107
+ throw new Error(
108
+ `No synthesizer named "${synthesizer}". Available: ${voiceNames().join(', ')}.`,
109
+ );
110
+ }
111
+ getSynths().setActive(synthesizer);
112
+ return { active: synthesizer };
113
+ },
114
+ }),
115
+ ],
116
+ });
117
+ }
118
+
119
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
120
+ export default voiceAdminPlugin;