@moxxy/channel-kit 0.27.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/frame-pump.d.ts +77 -0
  3. package/dist/frame-pump.d.ts.map +1 -0
  4. package/dist/frame-pump.js +118 -0
  5. package/dist/frame-pump.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +19 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/ingest/dedupe.d.ts +31 -0
  11. package/dist/ingest/dedupe.d.ts.map +1 -0
  12. package/dist/ingest/dedupe.js +66 -0
  13. package/dist/ingest/dedupe.js.map +1 -0
  14. package/dist/ingest/http-server.d.ts +76 -0
  15. package/dist/ingest/http-server.d.ts.map +1 -0
  16. package/dist/ingest/http-server.js +101 -0
  17. package/dist/ingest/http-server.js.map +1 -0
  18. package/dist/pairing/host-code.d.ts +94 -0
  19. package/dist/pairing/host-code.d.ts.map +1 -0
  20. package/dist/pairing/host-code.js +107 -0
  21. package/dist/pairing/host-code.js.map +1 -0
  22. package/dist/pairing/tofu.d.ts +33 -0
  23. package/dist/pairing/tofu.d.ts.map +1 -0
  24. package/dist/pairing/tofu.js +41 -0
  25. package/dist/pairing/tofu.js.map +1 -0
  26. package/dist/permission.d.ts +28 -0
  27. package/dist/permission.d.ts.map +1 -0
  28. package/dist/permission.js +17 -0
  29. package/dist/permission.js.map +1 -0
  30. package/dist/plain-turn-renderer.d.ts +17 -0
  31. package/dist/plain-turn-renderer.d.ts.map +1 -0
  32. package/dist/plain-turn-renderer.js +28 -0
  33. package/dist/plain-turn-renderer.js.map +1 -0
  34. package/dist/secrets.d.ts +18 -0
  35. package/dist/secrets.d.ts.map +1 -0
  36. package/dist/secrets.js +16 -0
  37. package/dist/secrets.js.map +1 -0
  38. package/dist/turn.d.ts +96 -0
  39. package/dist/turn.d.ts.map +1 -0
  40. package/dist/turn.js +106 -0
  41. package/dist/turn.js.map +1 -0
  42. package/dist/voice-reply.d.ts +139 -0
  43. package/dist/voice-reply.d.ts.map +1 -0
  44. package/dist/voice-reply.js +333 -0
  45. package/dist/voice-reply.js.map +1 -0
  46. package/package.json +54 -0
  47. package/src/frame-pump.test.ts +209 -0
  48. package/src/frame-pump.ts +154 -0
  49. package/src/index.ts +66 -0
  50. package/src/ingest/dedupe.test.ts +58 -0
  51. package/src/ingest/dedupe.ts +66 -0
  52. package/src/ingest/http-server.test.ts +120 -0
  53. package/src/ingest/http-server.ts +173 -0
  54. package/src/pairing/host-code.test.ts +121 -0
  55. package/src/pairing/host-code.ts +159 -0
  56. package/src/pairing/tofu.test.ts +62 -0
  57. package/src/pairing/tofu.ts +60 -0
  58. package/src/permission.test.ts +69 -0
  59. package/src/permission.ts +46 -0
  60. package/src/plain-turn-renderer.ts +31 -0
  61. package/src/secrets.test.ts +59 -0
  62. package/src/secrets.ts +26 -0
  63. package/src/turn.test.ts +165 -0
  64. package/src/turn.ts +162 -0
  65. package/src/voice-reply.test.ts +316 -0
  66. package/src/voice-reply.ts +449 -0
@@ -0,0 +1,449 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { spawn } from 'node:child_process';
3
+ import type { SynthesizeOptions, Synthesizer } from '@moxxy/sdk';
4
+
5
+ /**
6
+ * Voice-reply machinery shared by messaging channels that can speak the final
7
+ * assistant reply through the session's active {@link Synthesizer}
8
+ * (text-to-speech). Everything here is transport-agnostic — turning text into
9
+ * audio, cleaning markdown for speech, transcoding to OGG/Opus, and picking a
10
+ * filename. The messenger-specific delivery (grammy `sendVoice`, a discord.js
11
+ * `AttachmentBuilder`) stays in each channel plugin behind the
12
+ * {@link VoiceReplySink}.
13
+ *
14
+ * The load-bearing guarantee is that NOTHING here throws into the turn path: a
15
+ * missing synthesizer, a TTS failure, a missing ffmpeg, or a transport error
16
+ * all resolve to a typed result. Callers send the text reply first and treat a
17
+ * voice reply as best-effort, so a synth/transcode/delivery failure never
18
+ * breaks the (already-sent) text answer.
19
+ */
20
+
21
+ /** The slice of a session the voice-reply path needs: the synthesizer view. */
22
+ export interface SynthesizerSource {
23
+ readonly synthesizers: { tryGetActive(): Synthesizer | null };
24
+ }
25
+
26
+ /**
27
+ * Strip markdown down to what should be *spoken*. Code fences become a short
28
+ * "(code omitted)" placeholder (reading a diff aloud is noise), links keep only
29
+ * their label, and the inline emphasis/heading/quote/bullet marks are removed
30
+ * so a TTS engine doesn't voice the punctuation. Whitespace is collapsed.
31
+ */
32
+ export function toSpeech(markdown: string): string {
33
+ let t = markdown ?? '';
34
+ // Fenced code blocks (closed, then any unterminated trailing fence) → a short
35
+ // spoken placeholder. Must run BEFORE inline-code stripping (fences contain
36
+ // backticks).
37
+ t = t.replace(/```[\s\S]*?```/g, ' (code omitted) ');
38
+ t = t.replace(/```[\s\S]*$/g, ' (code omitted) ');
39
+ // Images `![alt](url)` → alt; links `[label](url)` → label.
40
+ t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
41
+ t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
42
+ // Inline code `x` → x.
43
+ t = t.replace(/`([^`]+)`/g, '$1');
44
+ // Emphasis / strikethrough marks.
45
+ t = t.replace(/(\*\*|__|~~|\*|_)/g, '');
46
+ // Leading heading / blockquote / list markers (line-anchored).
47
+ t = t.replace(/^\s{0,3}#{1,6}\s*/gm, '');
48
+ t = t.replace(/^\s{0,3}>\s?/gm, '');
49
+ t = t.replace(/^\s{0,3}[-*+]\s+/gm, '');
50
+ // Collapse runs of spaces/tabs and excess blank lines.
51
+ t = t.replace(/[ \t]+/g, ' ');
52
+ t = t.replace(/\n{3,}/g, '\n\n');
53
+ return t.trim();
54
+ }
55
+
56
+ /** Default ceiling on the characters we hand a TTS backend for one reply. Long
57
+ * answers are truncated at a sentence/word boundary so synthesis stays fast
58
+ * and cheap (a spoken 30-page essay helps no one). */
59
+ const DEFAULT_MAX_SPEECH_CHARS = 1_200;
60
+
61
+ function truncateForSpeech(text: string, max: number): string {
62
+ if (text.length <= max) return text;
63
+ const slice = text.slice(0, max);
64
+ const sentence = Math.max(
65
+ slice.lastIndexOf('. '),
66
+ slice.lastIndexOf('! '),
67
+ slice.lastIndexOf('? '),
68
+ slice.lastIndexOf('\n'),
69
+ );
70
+ const cut = sentence > max * 0.5 ? sentence + 1 : slice.lastIndexOf(' ');
71
+ const head = (cut > 0 ? slice.slice(0, cut) : slice).trim();
72
+ return `${head}…`;
73
+ }
74
+
75
+ export interface SynthesizeReplyOptions extends SynthesizeOptions {
76
+ /** Max characters of speech text handed to the backend. Default 1200. */
77
+ readonly maxChars?: number;
78
+ }
79
+
80
+ export type SynthesizeReplyResult =
81
+ | { readonly ok: true; readonly audio: Uint8Array; readonly mimeType: string }
82
+ // `no-synthesizer`: nothing active (caller may nudge the user to install one).
83
+ // `empty`: the reply had no speakable text (e.g. a tool-only turn).
84
+ // `failed`: the backend threw or returned no audio.
85
+ | { readonly ok: false; readonly reason: 'no-synthesizer' | 'empty' | 'failed'; readonly error?: string };
86
+
87
+ /**
88
+ * Synthesize a reply through the session's active synthesizer. Cleans markdown
89
+ * for speech ({@link toSpeech}), truncates sensibly, and NEVER throws — every
90
+ * failure mode is a typed `ok:false` result so the turn path stays intact.
91
+ */
92
+ export async function synthesizeReply(
93
+ session: SynthesizerSource,
94
+ text: string,
95
+ opts: SynthesizeReplyOptions = {},
96
+ ): Promise<SynthesizeReplyResult> {
97
+ let synth: Synthesizer | null;
98
+ try {
99
+ synth = session.synthesizers.tryGetActive();
100
+ } catch (err) {
101
+ return { ok: false, reason: 'failed', error: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ if (!synth) return { ok: false, reason: 'no-synthesizer' };
104
+
105
+ const speech = truncateForSpeech(toSpeech(text), opts.maxChars ?? DEFAULT_MAX_SPEECH_CHARS);
106
+ if (!speech) return { ok: false, reason: 'empty' };
107
+
108
+ const synthOpts: SynthesizeOptions = {};
109
+ if (opts.voice !== undefined) (synthOpts as { voice?: string }).voice = opts.voice;
110
+ if (opts.language !== undefined) (synthOpts as { language?: string }).language = opts.language;
111
+ if (opts.rate !== undefined) (synthOpts as { rate?: number }).rate = opts.rate;
112
+ if (opts.signal !== undefined) (synthOpts as { signal?: AbortSignal }).signal = opts.signal;
113
+
114
+ try {
115
+ const result = await synth.synthesize(speech, synthOpts);
116
+ if (!result?.audio || result.audio.byteLength === 0) {
117
+ return { ok: false, reason: 'failed', error: 'synthesizer returned no audio' };
118
+ }
119
+ return { ok: true, audio: result.audio, mimeType: result.mimeType };
120
+ } catch (err) {
121
+ return { ok: false, reason: 'failed', error: err instanceof Error ? err.message : String(err) };
122
+ }
123
+ }
124
+
125
+ /** True for a mime type Telegram accepts as a native voice note (OGG/Opus). */
126
+ function isOggOpusMime(mimeType: string): boolean {
127
+ const m = mimeType.toLowerCase();
128
+ return m.includes('opus') || m.includes('ogg');
129
+ }
130
+
131
+ /** Map an audio mime type to a sensible file extension (for the attachment name
132
+ * a messenger uses to sniff the format). Falls back to `audio` when unknown. */
133
+ export function audioExtForMime(mimeType: string): string {
134
+ const m = (mimeType || '').toLowerCase();
135
+ if (m.includes('opus') || m.includes('ogg')) return 'ogg';
136
+ if (m.includes('mpeg') || m.includes('mp3')) return 'mp3';
137
+ if (m.includes('wav')) return 'wav';
138
+ if (m.includes('webm')) return 'webm';
139
+ if (m.includes('aac') || m.includes('m4a') || m.includes('mp4')) return 'm4a';
140
+ if (m.includes('flac')) return 'flac';
141
+ return 'audio';
142
+ }
143
+
144
+ export interface EnsureOggOpusOptions {
145
+ /** Injectable `spawn` for tests. When set, the ffmpeg presence probe is NOT
146
+ * process-cached (so each test drives a deterministic outcome). */
147
+ readonly spawnImpl?: typeof spawn;
148
+ }
149
+
150
+ export interface EnsureOggOpusResult {
151
+ readonly audio: Uint8Array;
152
+ readonly mimeType: string;
153
+ /** True when ffmpeg ran to produce these bytes. */
154
+ readonly transcoded: boolean;
155
+ /** True when `audio` is OGG/Opus (safe to send as a native voice note); false
156
+ * when we returned the ORIGINAL bytes because ffmpeg was unavailable — the
157
+ * caller should then send plain audio instead of a voice note. */
158
+ readonly isOpus: boolean;
159
+ }
160
+
161
+ const TRANSCODE_ARGS = [
162
+ '-hide_banner',
163
+ '-loglevel',
164
+ 'error',
165
+ '-i',
166
+ 'pipe:0',
167
+ '-f',
168
+ 'ogg',
169
+ '-c:a',
170
+ 'libopus',
171
+ '-b:a',
172
+ '32k',
173
+ '-ac',
174
+ '1',
175
+ 'pipe:1',
176
+ ];
177
+
178
+ /** Ceiling on transcoded output we buffer (a runaway/adversarial input can't
179
+ * grow memory without bound). 25MB is well past any spoken reply. */
180
+ const MAX_TRANSCODE_OUTPUT_BYTES = 25 * 1024 * 1024;
181
+
182
+ /**
183
+ * Ensure audio is OGG/Opus for a native voice note. Passthrough when it already
184
+ * is; otherwise transcode via ffmpeg (stdin→stdout). Gated on an ffmpeg
185
+ * presence probe (mirrors `checkVoiceCaptureAvailable`, cached per process). On
186
+ * no-ffmpeg — or any transcode failure — returns the ORIGINAL bytes with
187
+ * `isOpus:false` so the caller falls back to a plain audio message. Never throws.
188
+ */
189
+ export async function ensureOggOpus(
190
+ audio: Uint8Array,
191
+ mimeType: string,
192
+ opts: EnsureOggOpusOptions = {},
193
+ ): Promise<EnsureOggOpusResult> {
194
+ if (isOggOpusMime(mimeType)) {
195
+ return { audio, mimeType: 'audio/ogg', transcoded: false, isOpus: true };
196
+ }
197
+ const available = await probeFfmpeg(opts.spawnImpl);
198
+ if (!available) return { audio, mimeType, transcoded: false, isOpus: false };
199
+ try {
200
+ const out = await transcodeToOggOpus(audio, opts.spawnImpl ?? spawn);
201
+ if (out.byteLength === 0) return { audio, mimeType, transcoded: false, isOpus: false };
202
+ return { audio: out, mimeType: 'audio/ogg', transcoded: true, isOpus: true };
203
+ } catch {
204
+ return { audio, mimeType, transcoded: false, isOpus: false };
205
+ }
206
+ }
207
+
208
+ // Process-cached ffmpeg availability (the probe spawns a subprocess; caching
209
+ // keeps a chatty channel from re-probing on every reply). Tests inject a
210
+ // `spawnImpl`, which bypasses the cache.
211
+ let ffmpegAvailable: Promise<boolean> | null = null;
212
+
213
+ async function probeFfmpeg(spawnImpl?: typeof spawn): Promise<boolean> {
214
+ if (spawnImpl) return runFfmpegProbe(spawnImpl);
215
+ ffmpegAvailable ??= runFfmpegProbe(spawn);
216
+ return ffmpegAvailable;
217
+ }
218
+
219
+ /** Reset the cached ffmpeg probe (tests only). */
220
+ export function __resetFfmpegProbeForTest(): void {
221
+ ffmpegAvailable = null;
222
+ }
223
+
224
+ function runFfmpegProbe(
225
+ spawnImpl: typeof spawn,
226
+ command = 'ffmpeg',
227
+ timeoutMs = 1_500,
228
+ ): Promise<boolean> {
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+ const done = (v: boolean): void => {
232
+ if (settled) return;
233
+ settled = true;
234
+ clearTimeout(timer);
235
+ resolve(v);
236
+ };
237
+ let child: ReturnType<typeof spawn>;
238
+ try {
239
+ child = spawnImpl(command, ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] });
240
+ } catch {
241
+ resolve(false);
242
+ return;
243
+ }
244
+ const timer = setTimeout(() => {
245
+ try {
246
+ if (!child.killed) child.kill('SIGKILL');
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ done(false);
251
+ }, timeoutMs);
252
+ timer.unref?.();
253
+ child.once('error', () => done(false));
254
+ child.once('close', (code) => done(code === 0));
255
+ });
256
+ }
257
+
258
+ function transcodeToOggOpus(
259
+ audio: Uint8Array,
260
+ spawnImpl: typeof spawn,
261
+ command = 'ffmpeg',
262
+ timeoutMs = 15_000,
263
+ ): Promise<Uint8Array> {
264
+ return new Promise((resolve, reject) => {
265
+ let child: ReturnType<typeof spawn>;
266
+ try {
267
+ child = spawnImpl(command, TRANSCODE_ARGS, { stdio: ['pipe', 'pipe', 'pipe'] });
268
+ } catch (err) {
269
+ reject(err instanceof Error ? err : new Error(String(err)));
270
+ return;
271
+ }
272
+ const out: Buffer[] = [];
273
+ const errChunks: Buffer[] = [];
274
+ let outBytes = 0;
275
+ let over = false;
276
+ let settled = false;
277
+ const finish = (fn: () => void): void => {
278
+ if (settled) return;
279
+ settled = true;
280
+ clearTimeout(timer);
281
+ fn();
282
+ };
283
+ const timer = setTimeout(() => {
284
+ try {
285
+ if (!child.killed) child.kill('SIGKILL');
286
+ } catch {
287
+ /* ignore */
288
+ }
289
+ finish(() => reject(new Error('ffmpeg transcode timed out')));
290
+ }, timeoutMs);
291
+ timer.unref?.();
292
+
293
+ child.stdout?.on('data', (c: Buffer) => {
294
+ if (over) return;
295
+ if (outBytes + c.byteLength > MAX_TRANSCODE_OUTPUT_BYTES) {
296
+ over = true;
297
+ try {
298
+ child.kill('SIGKILL');
299
+ } catch {
300
+ /* ignore */
301
+ }
302
+ return;
303
+ }
304
+ outBytes += c.byteLength;
305
+ out.push(Buffer.from(c));
306
+ });
307
+ child.stderr?.on('data', (c: Buffer) => {
308
+ errChunks.push(Buffer.from(c));
309
+ while (Buffer.concat(errChunks).byteLength > 4_096) errChunks.shift();
310
+ });
311
+ child.once('error', (err) =>
312
+ finish(() => reject(err instanceof Error ? err : new Error(String(err)))),
313
+ );
314
+ child.once('close', (code) =>
315
+ finish(() => {
316
+ if (code === 0 && out.length > 0) {
317
+ resolve(new Uint8Array(Buffer.concat(out)));
318
+ } else if (code === 0) {
319
+ reject(new Error('ffmpeg produced no output'));
320
+ } else {
321
+ reject(new Error(`ffmpeg exited ${code}: ${Buffer.concat(errChunks).toString('utf8').trim()}`));
322
+ }
323
+ }),
324
+ );
325
+
326
+ const stdin = child.stdin;
327
+ if (stdin) {
328
+ stdin.on('error', () => {
329
+ // EPIPE if ffmpeg died before consuming stdin — the close/error handler
330
+ // reports the real failure; swallow this to avoid an unhandled 'error'.
331
+ });
332
+ try {
333
+ stdin.end(Buffer.from(audio));
334
+ } catch {
335
+ /* the close/error path reports it */
336
+ }
337
+ }
338
+ });
339
+ }
340
+
341
+ /** The transport boundary: a channel implements this to actually deliver the
342
+ * synthesized audio (grammy `sendVoice`/`sendAudio`, discord.js attachment). */
343
+ export interface VoiceReplySink {
344
+ send(
345
+ audio: Uint8Array,
346
+ meta: {
347
+ readonly mimeType: string;
348
+ /** Suggested attachment filename, e.g. `reply.ogg` / `reply.mp3`. */
349
+ readonly filename: string;
350
+ /** True when the bytes are OGG/Opus (send as a native voice note); false
351
+ * when they are plain audio (no ffmpeg to transcode). */
352
+ readonly isVoiceNote: boolean;
353
+ },
354
+ ): Promise<void>;
355
+ }
356
+
357
+ export type VoiceReplyOutcome =
358
+ | { readonly status: 'sent'; readonly transcoded: boolean; readonly isVoiceNote: boolean }
359
+ | { readonly status: 'skipped'; readonly reason: 'no-synthesizer' | 'empty' }
360
+ | { readonly status: 'failed'; readonly reason: 'synth' | 'delivery'; readonly error?: string };
361
+
362
+ export type DeliverVoiceReplyOptions = SynthesizeReplyOptions & EnsureOggOpusOptions;
363
+
364
+ /**
365
+ * End-to-end best-effort voice reply: synthesize → ensure OGG/Opus → deliver
366
+ * through the channel's {@link VoiceReplySink}. NEVER throws — every failure is
367
+ * a typed outcome — so a caller can invoke it AFTER sending the text reply and
368
+ * be sure the (already-sent) text is never broken by a voice failure.
369
+ */
370
+ export async function deliverVoiceReply(
371
+ session: SynthesizerSource,
372
+ text: string,
373
+ sink: VoiceReplySink,
374
+ opts: DeliverVoiceReplyOptions = {},
375
+ ): Promise<VoiceReplyOutcome> {
376
+ const synth = await synthesizeReply(session, text, opts);
377
+ if (!synth.ok) {
378
+ if (synth.reason === 'failed') {
379
+ return synth.error !== undefined
380
+ ? { status: 'failed', reason: 'synth', error: synth.error }
381
+ : { status: 'failed', reason: 'synth' };
382
+ }
383
+ return { status: 'skipped', reason: synth.reason };
384
+ }
385
+
386
+ const prepared = await ensureOggOpus(synth.audio, synth.mimeType, opts);
387
+ const filename = prepared.isOpus ? 'reply.ogg' : `reply.${audioExtForMime(prepared.mimeType)}`;
388
+ try {
389
+ await sink.send(prepared.audio, {
390
+ mimeType: prepared.mimeType,
391
+ filename,
392
+ isVoiceNote: prepared.isOpus,
393
+ });
394
+ } catch (err) {
395
+ return { status: 'failed', reason: 'delivery', error: err instanceof Error ? err.message : String(err) };
396
+ }
397
+ return { status: 'sent', transcoded: prepared.transcoded, isVoiceNote: prepared.isOpus };
398
+ }
399
+
400
+ export interface VoiceToggleInput {
401
+ /** The `/voice` argument (`''`, `on`, `off`, `status`, or anything → toggle). */
402
+ readonly arg: string;
403
+ /** Current persisted state. */
404
+ readonly enabled: boolean;
405
+ /** Whether a synthesizer is active right now. */
406
+ readonly hasSynthesizer: boolean;
407
+ /** How this channel delivers audio, e.g. `a voice note` / `an audio file`. */
408
+ readonly delivery: string;
409
+ /** Install-guidance line appended when enabling with no active synthesizer. */
410
+ readonly noSynthesizerHint: string;
411
+ }
412
+
413
+ export interface VoiceToggleResult {
414
+ /** Desired enabled state after the command. */
415
+ readonly enabled: boolean;
416
+ /** Whether the caller should persist `enabled` (false for `status`). */
417
+ readonly persist: boolean;
418
+ /** User-facing reply text. */
419
+ readonly reply: string;
420
+ }
421
+
422
+ /**
423
+ * Resolve a channel-agnostic `/voice` toggle command into the new state + the
424
+ * reply to show. `on`/`off` are explicit, `status` just reports, anything else
425
+ * flips. Enabling with no active synthesizer still turns the preference on (so
426
+ * replies start speaking once a backend is installed) but appends install
427
+ * guidance. Wording is parameterized (`delivery`, `noSynthesizerHint`) so each
428
+ * messenger keeps its own phrasing.
429
+ */
430
+ export function resolveVoiceToggle(input: VoiceToggleInput): VoiceToggleResult {
431
+ const arg = input.arg.trim().toLowerCase();
432
+ const hint = input.hasSynthesizer ? '' : `\n\n${input.noSynthesizerHint}`;
433
+
434
+ if (arg === 'status') {
435
+ const state = input.enabled ? 'ON' : 'OFF';
436
+ const note = input.enabled ? hint : '';
437
+ return { enabled: input.enabled, persist: false, reply: `Voice replies are ${state}.${note}` };
438
+ }
439
+
440
+ const desired = arg === 'on' ? true : arg === 'off' ? false : !input.enabled;
441
+ if (!desired) {
442
+ return { enabled: false, persist: true, reply: '🔇 Voice replies OFF.' };
443
+ }
444
+ return {
445
+ enabled: true,
446
+ persist: true,
447
+ reply: `🔊 Voice replies ON — I'll speak my final reply as ${input.delivery}.${hint}`,
448
+ };
449
+ }