aicodeman 1.16.3 → 1.16.4

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 (119) hide show
  1. package/README.md +9 -9
  2. package/dist/claude-credentials.d.ts +50 -0
  3. package/dist/claude-credentials.d.ts.map +1 -0
  4. package/dist/claude-credentials.js +102 -0
  5. package/dist/claude-credentials.js.map +1 -0
  6. package/dist/config/voice.d.ts +43 -0
  7. package/dist/config/voice.d.ts.map +1 -0
  8. package/dist/config/voice.js +48 -0
  9. package/dist/config/voice.js.map +1 -0
  10. package/dist/web/ports/config-port.d.ts +2 -0
  11. package/dist/web/ports/config-port.d.ts.map +1 -1
  12. package/dist/web/public/admin-ui.js.gz +0 -0
  13. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  14. package/dist/web/public/app.f9c6aadd.js.gz +0 -0
  15. package/dist/web/public/approvals-ui.js.gz +0 -0
  16. package/dist/web/public/constants.53dc0fa1.js.gz +0 -0
  17. package/dist/web/public/cron-ui.js.gz +0 -0
  18. package/dist/web/public/entrance-animations.js.gz +0 -0
  19. package/dist/web/public/home-sessions.js +15 -4
  20. package/dist/web/public/home-sessions.js.br +0 -0
  21. package/dist/web/public/home-sessions.js.gz +0 -0
  22. package/dist/web/public/i18n.89097ff5.js +1 -0
  23. package/dist/web/public/i18n.89097ff5.js.br +0 -0
  24. package/dist/web/public/i18n.89097ff5.js.gz +0 -0
  25. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  26. package/dist/web/public/index.html +657 -402
  27. package/dist/web/public/index.html.br +0 -0
  28. package/dist/web/public/index.html.gz +0 -0
  29. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  30. package/dist/web/public/keyboard-accessory.0f524637.js.gz +0 -0
  31. package/dist/web/public/mobile-handlers.32fdd57f.js.gz +0 -0
  32. package/dist/web/public/mobile-overview.js.gz +0 -0
  33. package/dist/web/public/mobile.4f875962.css +1 -0
  34. package/dist/web/public/mobile.4f875962.css.br +0 -0
  35. package/dist/web/public/mobile.4f875962.css.gz +0 -0
  36. package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
  37. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  38. package/dist/web/public/panels-ui.54b0d004.js.gz +0 -0
  39. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  40. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  41. package/dist/web/public/readmymind-ui.js +20 -4
  42. package/dist/web/public/readmymind-ui.js.br +0 -0
  43. package/dist/web/public/readmymind-ui.js.gz +0 -0
  44. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  45. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  46. package/dist/web/public/{session-ui.f4fed187.js → session-ui.46a0993c.js} +5 -5
  47. package/dist/web/public/session-ui.46a0993c.js.br +0 -0
  48. package/dist/web/public/session-ui.46a0993c.js.gz +0 -0
  49. package/dist/web/public/settings-ui.63be4178.js +67 -0
  50. package/dist/web/public/settings-ui.63be4178.js.br +0 -0
  51. package/dist/web/public/settings-ui.63be4178.js.gz +0 -0
  52. package/dist/web/public/styles.9e04378d.css +1 -0
  53. package/dist/web/public/styles.9e04378d.css.br +0 -0
  54. package/dist/web/public/styles.9e04378d.css.gz +0 -0
  55. package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
  56. package/dist/web/public/sw.js.gz +0 -0
  57. package/dist/web/public/terminal-ui.ae776405.js +4 -0
  58. package/dist/web/public/terminal-ui.ae776405.js.br +0 -0
  59. package/dist/web/public/terminal-ui.ae776405.js.gz +0 -0
  60. package/dist/web/public/ultracode-panel.js.gz +0 -0
  61. package/dist/web/public/ultracode-windows.js.gz +0 -0
  62. package/dist/web/public/upload.html.gz +0 -0
  63. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  64. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  65. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  66. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  67. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  68. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  69. package/dist/web/public/vendor/xterm-predictive-echo.6cec0fba.js.gz +0 -0
  70. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  71. package/dist/web/public/vendor/xterm.css.gz +0 -0
  72. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  73. package/dist/web/public/{voice-input.e5461859.js → voice-input.4ec670b0.js} +421 -13
  74. package/dist/web/public/voice-input.4ec670b0.js.br +0 -0
  75. package/dist/web/public/voice-input.4ec670b0.js.gz +0 -0
  76. package/dist/web/public/voice-pcm-worklet.js +57 -0
  77. package/dist/web/public/voice-pcm-worklet.js.br +0 -0
  78. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  79. package/dist/web/public/webview-tabs.js.gz +0 -0
  80. package/dist/web/routes/index.d.ts +1 -0
  81. package/dist/web/routes/index.d.ts.map +1 -1
  82. package/dist/web/routes/index.js +1 -0
  83. package/dist/web/routes/index.js.map +1 -1
  84. package/dist/web/routes/voice-routes.d.ts +45 -0
  85. package/dist/web/routes/voice-routes.d.ts.map +1 -0
  86. package/dist/web/routes/voice-routes.js +172 -0
  87. package/dist/web/routes/voice-routes.js.map +1 -0
  88. package/dist/web/schemas.d.ts +2 -0
  89. package/dist/web/schemas.d.ts.map +1 -1
  90. package/dist/web/schemas.js +12 -0
  91. package/dist/web/schemas.js.map +1 -1
  92. package/dist/web/server.d.ts +1 -0
  93. package/dist/web/server.d.ts.map +1 -1
  94. package/dist/web/server.js +11 -1
  95. package/dist/web/server.js.map +1 -1
  96. package/dist/web/voice-stream.d.ts +96 -0
  97. package/dist/web/voice-stream.d.ts.map +1 -0
  98. package/dist/web/voice-stream.js +268 -0
  99. package/dist/web/voice-stream.js.map +1 -0
  100. package/package.json +1 -1
  101. package/dist/web/public/i18n.1a8b3412.js +0 -1
  102. package/dist/web/public/i18n.1a8b3412.js.br +0 -0
  103. package/dist/web/public/i18n.1a8b3412.js.gz +0 -0
  104. package/dist/web/public/mobile.52b30b35.css +0 -1
  105. package/dist/web/public/mobile.52b30b35.css.br +0 -0
  106. package/dist/web/public/mobile.52b30b35.css.gz +0 -0
  107. package/dist/web/public/session-ui.f4fed187.js.br +0 -0
  108. package/dist/web/public/session-ui.f4fed187.js.gz +0 -0
  109. package/dist/web/public/settings-ui.c674f367.js +0 -67
  110. package/dist/web/public/settings-ui.c674f367.js.br +0 -0
  111. package/dist/web/public/settings-ui.c674f367.js.gz +0 -0
  112. package/dist/web/public/styles.2eb23e0f.css +0 -1
  113. package/dist/web/public/styles.2eb23e0f.css.br +0 -0
  114. package/dist/web/public/styles.2eb23e0f.css.gz +0 -0
  115. package/dist/web/public/terminal-ui.61c7829d.js +0 -4
  116. package/dist/web/public/terminal-ui.61c7829d.js.br +0 -0
  117. package/dist/web/public/terminal-ui.61c7829d.js.gz +0 -0
  118. package/dist/web/public/voice-input.e5461859.js.br +0 -0
  119. package/dist/web/public/voice-input.e5461859.js.gz +0 -0
@@ -1,7 +1,13 @@
1
1
  /**
2
- * @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
2
+ * @fileoverview Voice input with three providers: Claude (this server's Claude Code
3
+ * login), Deepgram Nova-3, and the Web Speech API.
3
4
  *
4
- * Defines two singleton objects:
5
+ * Defines three singleton objects:
6
+ *
7
+ * - ClaudeVoiceProvider — Dictation through Codeman's own `/ws/voice/stream`, which
8
+ * relays to the speech-to-text service Claude Code's `/voice` mode uses. No API key:
9
+ * the server holds the OAuth token, the browser only sends PCM16 @16 kHz (AudioWorklet,
10
+ * since MediaRecorder cannot emit raw PCM) and receives text. See docs/claude-voice-plan.md.
5
11
  *
6
12
  * - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
7
13
  * Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
@@ -14,6 +20,7 @@
14
20
  * Includes a temporary green Send button that replaces the settings gear icon after voice input.
15
21
  * Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
16
22
  *
23
+ * @globals {object} ClaudeVoiceProvider
17
24
  * @globals {object} DeepgramProvider
18
25
  * @globals {object} VoiceInput
19
26
  *
@@ -22,9 +29,13 @@
22
29
  * @loadorder 3 of 15 — loaded after mobile-handlers.js, before notification-manager.js
23
30
  */
24
31
 
25
- // Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
32
+ // Codeman — Voice input with Claude, Deepgram Nova-3 and Web Speech API
26
33
  // Loaded after mobile-handlers.js, before app.js
27
34
 
35
+ /** Dev vocabulary sent to the recognizer as a hint. Shared by every provider and the settings form. */
36
+ const DEFAULT_VOICE_KEYTERMS =
37
+ 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
38
+
28
39
  // ═══════════════════════════════════════════════════════════════
29
40
  // Voice Input (Deepgram Nova-3 + Web Speech API fallback)
30
41
  // ═══════════════════════════════════════════════════════════════
@@ -245,7 +256,282 @@ const DeepgramProvider = {
245
256
  };
246
257
 
247
258
  /**
248
- * VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback).
259
+ * ClaudeVoiceProvider - Speech-to-text through this Codeman server's Claude Code
260
+ * login, i.e. the same service the CLI's own `/voice` mode uses. No API key.
261
+ *
262
+ * Audio goes browser -> Codeman -> Anthropic: the OAuth token never leaves the
263
+ * server, so the browser only ever sends PCM and receives text
264
+ * (docs/claude-voice-plan.md).
265
+ *
266
+ * ⚠️ The upstream endpoint is opened as linear16 / 16 kHz / mono, so capture MUST
267
+ * be raw PCM at that rate. MediaRecorder cannot emit raw PCM (container formats
268
+ * only), which is why this path uses an AudioWorklet rather than reusing
269
+ * DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
270
+ * browser does the resampling.
271
+ *
272
+ * ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
273
+ * must replace, never concatenate.
274
+ */
275
+ const ClaudeVoiceProvider = {
276
+ _ws: null,
277
+ _stream: null,
278
+ _audioContext: null,
279
+ _workletNode: null,
280
+ _sourceNode: null,
281
+ _scriptNode: null,
282
+ _silenceTimeout: null,
283
+ _onResult: null,
284
+ _onError: null,
285
+ _onEnd: null,
286
+ _finalized: false,
287
+
288
+ /** How long without any transcript before the recording gives up on its own. */
289
+ SILENCE_MS: 6000,
290
+
291
+ /**
292
+ * Start streaming.
293
+ * @param {object} opts - { language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
294
+ */
295
+ async start(opts) {
296
+ this._onResult = opts.onResult;
297
+ this._onError = opts.onError;
298
+ this._onEnd = opts.onEnd;
299
+ this._finalized = false;
300
+
301
+ if (!navigator.mediaDevices?.getUserMedia) {
302
+ this._onError?.('Microphone requires a secure context (HTTPS). Use --https flag or access via localhost.');
303
+ this._cleanup();
304
+ return;
305
+ }
306
+ try {
307
+ this._stream = await navigator.mediaDevices.getUserMedia({
308
+ audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
309
+ });
310
+ } catch (err) {
311
+ const msg = err.name === 'NotAllowedError'
312
+ ? 'Microphone access denied. Check browser settings.'
313
+ : 'Microphone error: ' + err.message;
314
+ this._onError?.(msg);
315
+ this._cleanup();
316
+ return;
317
+ }
318
+ opts.onStream?.(this._stream);
319
+
320
+ const params = new URLSearchParams();
321
+ if (opts.language) params.set('language', opts.language);
322
+ if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
323
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
324
+ try {
325
+ this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
326
+ } catch (err) {
327
+ this._onError?.('Failed to open voice stream: ' + err.message);
328
+ this._cleanup();
329
+ return;
330
+ }
331
+ this._ws.binaryType = 'arraybuffer';
332
+
333
+ this._ws.onopen = () => {
334
+ // Capture starts only once the socket is up: PCM buffered before that would
335
+ // be the oldest audio, and dropping it keeps the transcript aligned with what
336
+ // the user hears themselves saying.
337
+ this._startCapture().catch((err) => {
338
+ this._onError?.('Microphone capture failed: ' + err.message);
339
+ this.stop();
340
+ });
341
+ this._resetSilenceTimeout();
342
+ };
343
+
344
+ this._ws.onmessage = (event) => {
345
+ let msg;
346
+ try {
347
+ msg = JSON.parse(event.data);
348
+ } catch (_e) {
349
+ return;
350
+ }
351
+ if (msg.t === 'transcript' && msg.text) {
352
+ this._resetSilenceTimeout();
353
+ this._onResult?.(msg.text, msg.final === true);
354
+ } else if (msg.t === 'error') {
355
+ this._onError?.(msg.message || 'Voice transcription failed');
356
+ }
357
+ };
358
+
359
+ this._ws.onerror = () => {
360
+ // onclose carries the actionable detail (close code); nothing useful here.
361
+ };
362
+
363
+ this._ws.onclose = (event) => {
364
+ if (event.code === 4004) {
365
+ this._onError?.(this._unavailableMessage(event.reason));
366
+ } else if (event.code === 4008) {
367
+ this._onError?.('Too many voice streams are already running on this server.');
368
+ } else if (event.code === 4003) {
369
+ this._onError?.('Voice stream refused (origin not allowed).');
370
+ } else if (event.code !== 1000 && !this._finalized) {
371
+ this._onError?.('Voice stream closed: ' + (event.reason || `code ${event.code}`));
372
+ }
373
+ this._stopCapture();
374
+ const onEnd = this._onEnd;
375
+ this._onEnd = null;
376
+ onEnd?.();
377
+ };
378
+ },
379
+
380
+ /** Map the server's close reason onto something a user can act on. */
381
+ _unavailableMessage(reason) {
382
+ if (reason === 'expired') return 'Claude login expired. Run a Claude session to refresh it, then try again.';
383
+ if (reason === 'disabled') return 'Claude voice is off. Enable it in Settings > Voice.';
384
+ return 'No Claude Code login found on the server. Sign in with `claude` there, or use Deepgram.';
385
+ },
386
+
387
+ /** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
388
+ async _startCapture() {
389
+ const Ctx = window.AudioContext || window.webkitAudioContext;
390
+ // Ask for 16 kHz directly so the browser resamples; Safari may hand back its
391
+ // own rate, which _pcmFromFloat32 then downsamples to match.
392
+ this._audioContext = new Ctx({ sampleRate: 16000 });
393
+ if (this._audioContext.state === 'suspended') await this._audioContext.resume();
394
+ this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
395
+
396
+ if (this._audioContext.audioWorklet) {
397
+ await this._audioContext.audioWorklet.addModule(this._workletUrl());
398
+ this._workletNode = new AudioWorkletNode(this._audioContext, 'pcm-frame-processor');
399
+ this._workletNode.port.onmessage = (event) => this._sendAudio(event.data);
400
+ this._sourceNode.connect(this._workletNode);
401
+ // A worklet with no destination is not pulled in some engines; a zero-gain
402
+ // sink keeps the graph running without echoing the mic to the speakers.
403
+ const sink = this._audioContext.createGain();
404
+ sink.gain.value = 0;
405
+ this._workletNode.connect(sink).connect(this._audioContext.destination);
406
+ return;
407
+ }
408
+
409
+ // Fallback for engines without AudioWorklet (older Safari): deprecated, but
410
+ // it is this or no dictation at all there.
411
+ this._scriptNode = this._audioContext.createScriptProcessor(4096, 1, 1);
412
+ this._scriptNode.onaudioprocess = (event) => {
413
+ this._sendAudio(this._pcmFromFloat32(event.inputBuffer.getChannelData(0), this._audioContext.sampleRate));
414
+ };
415
+ this._sourceNode.connect(this._scriptNode);
416
+ this._scriptNode.connect(this._audioContext.destination);
417
+ },
418
+
419
+ /**
420
+ * Worklet URL carrying this page's cache-bust token.
421
+ *
422
+ * ⚠️ Static assets are served `immutable` for a year, and `cacheBustAssets`
423
+ * only rewrites `.js` refs in `<script>`/`<link>` tags — a URL built here in JS
424
+ * is invisible to it. So the token is borrowed from voice-input.js's own script
425
+ * tag, which the server DID rewrite. Consequence: **edit the worklet and this
426
+ * file together**, or the browser keeps serving the old worklet.
427
+ */
428
+ _workletUrl() {
429
+ const src = document.querySelector('script[src*="voice-input.js"]')?.getAttribute('src') || '';
430
+ const q = src.indexOf('?');
431
+ return 'voice-pcm-worklet.js' + (q === -1 ? '' : src.slice(q));
432
+ },
433
+
434
+ /** Float32 [-1,1] at any rate -> Int16 PCM at 16 kHz (nearest-neighbour decimation). */
435
+ _pcmFromFloat32(input, sampleRate) {
436
+ const ratio = sampleRate / 16000;
437
+ const outLength = Math.floor(input.length / ratio);
438
+ const out = new Int16Array(outLength);
439
+ for (let i = 0; i < outLength; i++) {
440
+ const sample = Math.max(-1, Math.min(1, input[Math.floor(i * ratio)]));
441
+ out[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
442
+ }
443
+ return out.buffer;
444
+ },
445
+
446
+ _sendAudio(arrayBuffer) {
447
+ if (this._finalized) return;
448
+ if (this._ws?.readyState !== WebSocket.OPEN) return;
449
+ try {
450
+ this._ws.send(arrayBuffer);
451
+ } catch (_e) {
452
+ /* socket died mid-frame */
453
+ }
454
+ },
455
+
456
+ _resetSilenceTimeout() {
457
+ clearTimeout(this._silenceTimeout);
458
+ this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
459
+ },
460
+
461
+ /**
462
+ * Ask for the final transcript and let the server close the socket. Capture stops
463
+ * immediately, but the WebSocket stays open: the last (and usually best) transcript
464
+ * arrives AFTER the audio does, so closing here would throw away the utterance.
465
+ */
466
+ stop() {
467
+ clearTimeout(this._silenceTimeout);
468
+ this._silenceTimeout = null;
469
+ if (this._finalized) return;
470
+ this._finalized = true;
471
+ this._stopCapture();
472
+ if (this._ws?.readyState === WebSocket.OPEN) {
473
+ try {
474
+ this._ws.send(JSON.stringify({ t: 'finalize' }));
475
+ } catch (_e) {
476
+ /* ignore */
477
+ }
478
+ } else {
479
+ const onEnd = this._onEnd;
480
+ this._onEnd = null;
481
+ onEnd?.();
482
+ }
483
+ },
484
+
485
+ /** Tear down the audio graph and release the mic. Idempotent. */
486
+ _stopCapture() {
487
+ if (this._workletNode) {
488
+ this._workletNode.port.onmessage = null;
489
+ try { this._workletNode.disconnect(); } catch (_e) { /* ignore */ }
490
+ this._workletNode = null;
491
+ }
492
+ if (this._scriptNode) {
493
+ this._scriptNode.onaudioprocess = null;
494
+ try { this._scriptNode.disconnect(); } catch (_e) { /* ignore */ }
495
+ this._scriptNode = null;
496
+ }
497
+ if (this._sourceNode) {
498
+ try { this._sourceNode.disconnect(); } catch (_e) { /* ignore */ }
499
+ this._sourceNode = null;
500
+ }
501
+ if (this._audioContext) {
502
+ try { this._audioContext.close(); } catch (_e) { /* ignore */ }
503
+ this._audioContext = null;
504
+ }
505
+ if (this._stream) {
506
+ this._stream.getTracks().forEach(t => t.stop());
507
+ this._stream = null;
508
+ }
509
+ },
510
+
511
+ /** Hard stop: drop the socket without waiting for a final transcript. */
512
+ _cleanup() {
513
+ this._finalized = true;
514
+ clearTimeout(this._silenceTimeout);
515
+ this._silenceTimeout = null;
516
+ this._stopCapture();
517
+ if (this._ws) {
518
+ this._ws.onclose = null;
519
+ this._ws.onmessage = null;
520
+ this._ws.onerror = null;
521
+ if (this._ws.readyState === WebSocket.OPEN) {
522
+ try { this._ws.close(1000); } catch (_e) { /* ignore */ }
523
+ }
524
+ this._ws = null;
525
+ }
526
+ this._onResult = null;
527
+ this._onError = null;
528
+ this._onEnd = null;
529
+ }
530
+ };
531
+
532
+ /**
533
+ * VoiceInput - Speech-to-text with Claude (this server's Claude Code login),
534
+ * Deepgram Nova-3, or the Web Speech API.
249
535
  * Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
250
536
  * Shows interim transcription in a floating preview overlay.
251
537
  * Inserts final text into the active session (user presses Enter to submit).
@@ -273,6 +559,29 @@ const VoiceInput = {
273
559
  this._initRecognition();
274
560
  // Always show buttons — if unsupported, toggle() shows a toast
275
561
  this._showButtons();
562
+ // Probe the server's Claude voice availability in the background. `auto`
563
+ // resolution reads the cached answer, so the first mic press does not wait
564
+ // on a round trip; a miss just falls through to the next provider.
565
+ this.refreshClaudeStatus();
566
+ },
567
+
568
+ /** Last /api/voice/status answer, or null before the first probe resolves. */
569
+ _claudeStatus: null,
570
+
571
+ /**
572
+ * Re-probe whether this server can transcribe with its Claude Code login.
573
+ * Called at init and whenever App Settings opens (the setting is server-side,
574
+ * so another device could have flipped it).
575
+ */
576
+ async refreshClaudeStatus() {
577
+ try {
578
+ const res = await fetch('/api/voice/status');
579
+ const json = await res.json();
580
+ this._claudeStatus = json?.success ? json.data : { available: false, reason: 'disabled' };
581
+ } catch (_e) {
582
+ this._claudeStatus = { available: false, reason: 'disabled' };
583
+ }
584
+ return this._claudeStatus;
276
585
  },
277
586
 
278
587
  // --- Deepgram config (localStorage only, never sent to server) ---
@@ -294,11 +603,37 @@ const VoiceInput = {
294
603
  return !!(cfg.apiKey && cfg.apiKey.trim());
295
604
  },
296
605
 
606
+ _claudeAvailable() {
607
+ return this._claudeStatus?.available === true;
608
+ },
609
+
610
+ /**
611
+ * Which provider a press of the mic would use.
612
+ *
613
+ * An explicit pick always wins, even when it cannot run — the resulting error
614
+ * ("Claude voice is off", "no Deepgram key") is more useful than silently
615
+ * transcribing somewhere the user did not choose. `auto` prefers Claude because
616
+ * it needs no key and no per-word billing, then the configured Deepgram key,
617
+ * then the browser's own engine.
618
+ */
619
+ _resolveProvider() {
620
+ const pinned = this._getDeepgramConfig().provider;
621
+ if (pinned === 'claude' || pinned === 'deepgram' || pinned === 'webspeech') return pinned;
622
+ if (this._claudeAvailable()) return 'claude';
623
+ if (this._shouldUseDeepgram()) return 'deepgram';
624
+ return 'webspeech';
625
+ },
626
+
297
627
  /** Get the active provider name for display */
298
628
  getActiveProviderName() {
299
- if (this._shouldUseDeepgram()) return 'Deepgram Nova-3';
300
- if (this.supported) return 'Web Speech API';
301
- return 'None';
629
+ switch (this._resolveProvider()) {
630
+ case 'claude':
631
+ return this._claudeAvailable() ? 'Claude (this server’s login)' : 'Claude (unavailable)';
632
+ case 'deepgram':
633
+ return this._shouldUseDeepgram() ? 'Deepgram Nova-3' : 'Deepgram (no API key)';
634
+ default:
635
+ return this.supported ? 'Web Speech API' : 'None';
636
+ }
302
637
  },
303
638
 
304
639
  /** Try to create a SpeechRecognition instance */
@@ -334,13 +669,81 @@ const VoiceInput = {
334
669
  }
335
670
  this._retryCount = 0;
336
671
 
337
- if (this._shouldUseDeepgram()) {
672
+ const provider = this._resolveProvider();
673
+ if (provider === 'claude') {
674
+ this._startClaude();
675
+ } else if (provider === 'deepgram') {
338
676
  this._startDeepgram();
339
677
  } else {
340
678
  this._startWebSpeech();
341
679
  }
342
680
  },
343
681
 
682
+ _startClaude() {
683
+ if (!this._claudeAvailable()) {
684
+ const reason = this._claudeStatus?.reason;
685
+ app.showToast(
686
+ reason === 'expired'
687
+ ? 'Claude login expired on the server. Run a Claude session to refresh it.'
688
+ : reason === 'no-credentials'
689
+ ? 'No Claude Code login found on the server. Sign in there with `claude`.'
690
+ : 'Claude voice is off. Enable it in Settings > Voice.',
691
+ 'warning'
692
+ );
693
+ // Re-probe so a setting flipped on another device is picked up by the next press.
694
+ this.refreshClaudeStatus();
695
+ return;
696
+ }
697
+
698
+ const cfg = this._getDeepgramConfig();
699
+ this.isRecording = true;
700
+ this._activeProvider = 'claude';
701
+ this._accumulatedFinal = '';
702
+ this._lastTranscript = '';
703
+ this._hasReceivedResult = false;
704
+ this._recordingStartedAt = Date.now();
705
+ this._updateButtons('recording');
706
+ this._showPreview('Listening...', 'claude');
707
+ this._startDurationTimer();
708
+
709
+ const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
710
+ .split(',').map(t => t.trim()).filter(Boolean);
711
+
712
+ ClaudeVoiceProvider.start({
713
+ // The upstream endpoint wants a bare language tag; the Deepgram picker's
714
+ // 'en-US' style narrows to its base, and 'multi' means auto-detect.
715
+ language: (cfg.language || 'en-US').split('-')[0],
716
+ keyterms,
717
+ onStream: (stream) => this._startLevelMeter(stream),
718
+ onResult: (text, isFinal) => {
719
+ if (!this.isRecording) return;
720
+ this._hasReceivedResult = true;
721
+ // Each frame is the WHOLE running transcript, so replace rather than append.
722
+ this._accumulatedFinal = text;
723
+ if (isFinal) {
724
+ this._hidePreview();
725
+ this._insertText(text);
726
+ this.stop();
727
+ } else {
728
+ this._showPreview(text, 'claude');
729
+ }
730
+ },
731
+ onError: (msg) => {
732
+ const wasRecording = this.isRecording;
733
+ this.stop();
734
+ if (wasRecording) app.showToast(msg, 'error');
735
+ },
736
+ onEnd: () => {
737
+ if (this.isRecording) {
738
+ if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
739
+ this.stop();
740
+ }
741
+ }
742
+ });
743
+
744
+ if (navigator.vibrate) navigator.vibrate(50);
745
+ },
746
+
344
747
  _startDeepgram() {
345
748
  const cfg = this._getDeepgramConfig();
346
749
  this.isRecording = true;
@@ -353,7 +756,7 @@ const VoiceInput = {
353
756
  this._showPreview('Listening...', 'deepgram');
354
757
  this._startDurationTimer();
355
758
 
356
- const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com')
759
+ const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
357
760
  .split(',').map(t => t.trim()).filter(Boolean);
358
761
 
359
762
  DeepgramProvider.start({
@@ -452,7 +855,10 @@ const VoiceInput = {
452
855
  this._updateButtons('idle');
453
856
  this._hidePreview();
454
857
 
455
- if (this._activeProvider === 'deepgram') {
858
+ if (this._activeProvider === 'claude') {
859
+ // Finalize, don't hang up: the last transcript arrives after the audio does.
860
+ ClaudeVoiceProvider.stop();
861
+ } else if (this._activeProvider === 'deepgram') {
456
862
  DeepgramProvider.stop();
457
863
  } else if (this._activeProvider === 'webspeech') {
458
864
  try {
@@ -803,11 +1209,12 @@ const VoiceInput = {
803
1209
  timerEl.textContent = '0:00';
804
1210
  indicator.appendChild(timerEl);
805
1211
  this.previewEl.appendChild(indicator);
806
- // Provider badge for Deepgram
807
- if (provider === 'deepgram') {
1212
+ // Provider badge (Web Speech gets none — it is the fallback, not a choice)
1213
+ const badgeText = provider === 'deepgram' ? 'DG' : provider === 'claude' ? 'CLAUDE' : '';
1214
+ if (badgeText) {
808
1215
  const badge = document.createElement('span');
809
1216
  badge.className = 'voice-preview-badge';
810
- badge.textContent = 'DG';
1217
+ badge.textContent = badgeText;
811
1218
  this.previewEl.appendChild(badge);
812
1219
  this.previewEl.appendChild(document.createTextNode(' '));
813
1220
  }
@@ -861,6 +1268,7 @@ const VoiceInput = {
861
1268
  if (this.isRecording) this.stop();
862
1269
  this._hideVoiceSendBtn();
863
1270
  DeepgramProvider._cleanup();
1271
+ ClaudeVoiceProvider._cleanup();
864
1272
  this.recognition = null;
865
1273
  this._activeProvider = null;
866
1274
  this._stopDurationTimer();
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @fileoverview AudioWorklet that turns microphone audio into the PCM frames the
3
+ * Claude voice endpoint expects.
4
+ *
5
+ * The endpoint is opened as `encoding=linear16, sample_rate=16000, channels=1`,
6
+ * i.e. raw signed 16-bit little-endian mono. MediaRecorder cannot produce that
7
+ * (it only emits container formats — webm/opus, mp4), which is why the Deepgram
8
+ * path's capture code cannot be reused here: Deepgram sniffs the container,
9
+ * Anthropic's endpoint does not.
10
+ *
11
+ * Sample rate is handled by the AudioContext, constructed at 16000 Hz so the
12
+ * browser resamples the mic for us. This processor only converts Float32 [-1,1]
13
+ * to Int16 and batches, because a raw 128-sample render quantum is a ~4 ms
14
+ * WebSocket frame — 250 frames a second of pure overhead.
15
+ *
16
+ * Loaded via `audioWorklet.addModule()` from voice-input.js. Runs on the audio
17
+ * thread: no DOM, no globals from the page.
18
+ *
19
+ * ⚠️ Edit this file and voice-input.js together. Static assets are served
20
+ * `immutable` for a year and this one is fetched from JS, so it inherits its
21
+ * cache-bust token from voice-input.js's script tag (see `_workletUrl()`); a
22
+ * change here alone would keep serving the old copy to every returning browser.
23
+ */
24
+
25
+ /** ~256 ms at 16 kHz. Big enough to keep frame overhead down, small enough that interim transcripts stay live. */
26
+ const FRAME_SAMPLES = 4096;
27
+
28
+ class PcmFrameProcessor extends AudioWorkletProcessor {
29
+ constructor() {
30
+ super();
31
+ this._buffer = new Int16Array(FRAME_SAMPLES);
32
+ this._offset = 0;
33
+ }
34
+
35
+ process(inputs) {
36
+ const channel = inputs[0]?.[0];
37
+ // No input yet (mic still warming) — keep the processor alive.
38
+ if (!channel) return true;
39
+
40
+ for (let i = 0; i < channel.length; i++) {
41
+ // Clamp before scaling: values slightly outside [-1,1] are legal in Web Audio
42
+ // and would wrap around to the opposite sign as Int16, which sounds like a click.
43
+ const sample = Math.max(-1, Math.min(1, channel[i]));
44
+ this._buffer[this._offset++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
45
+
46
+ if (this._offset === FRAME_SAMPLES) {
47
+ // Transfer a copy: the worklet keeps reusing its own buffer.
48
+ const frame = new Int16Array(this._buffer);
49
+ this.port.postMessage(frame.buffer, [frame.buffer]);
50
+ this._offset = 0;
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+ }
56
+
57
+ registerProcessor('pcm-frame-processor', PcmFrameProcessor);
Binary file
@@ -23,5 +23,6 @@ export { registerSearchRoutes } from './search-routes.js';
23
23
  export { registerMeRoutes } from './me-routes.js';
24
24
  export { registerAdminRoutes } from './admin-routes.js';
25
25
  export { registerWsRoutes } from './ws-routes.js';
26
+ export { registerVoiceRoutes } from './voice-routes.js';
26
27
  export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
27
28
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -23,5 +23,6 @@ export { registerSearchRoutes } from './search-routes.js';
23
23
  export { registerMeRoutes } from './me-routes.js';
24
24
  export { registerAdminRoutes } from './admin-routes.js';
25
25
  export { registerWsRoutes } from './ws-routes.js';
26
+ export { registerVoiceRoutes } from './voice-routes.js';
26
27
  export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
27
28
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @fileoverview Claude voice dictation routes.
3
+ *
4
+ * - `GET /api/voice/status` — can this server transcribe? (settings gate + credential state)
5
+ * - `GET /ws/voice/stream` — one dictation: PCM16 audio up, transcripts down
6
+ *
7
+ * Design and the upstream protocol: `docs/claude-voice-plan.md`. The relay itself
8
+ * lives in `../voice-stream.ts`; this file is the auth, gating and lifetime shell
9
+ * around it.
10
+ *
11
+ * ⚠️ `/api/voice/status` reports STATE, never the token: `{ available, reason,
12
+ * subscriptionType?, expiresAt? }`. The Claude OAuth access token stays inside the
13
+ * server process — the browser sends audio and receives text, nothing else.
14
+ *
15
+ * ⚠️ The WebSocket carries the same upgrade guard as `/ws/sessions/:id/terminal`
16
+ * (allowed Host + same-site Origin, on top of the global auth hook that already ran
17
+ * on the handshake). Without it a cross-site page could open a dictation stream on
18
+ * the user's credentials and bill their subscription.
19
+ *
20
+ * ⚠️ The feature is OFF unless `claudeVoiceEnabled` is set: turning it on spends the
21
+ * server owner's Claude subscription on transcription for anyone who can reach the
22
+ * UI, which is a decision for the operator rather than a default.
23
+ */
24
+ import { FastifyInstance } from 'fastify';
25
+ import { type HostPolicy } from '../network-auth-policy.js';
26
+ import type { ConfigPort } from '../ports/index.js';
27
+ /** Why voice is unavailable, in a form the frontend can branch on. */
28
+ export type VoiceUnavailableReason = 'disabled' | 'no-credentials' | 'expired' | 'malformed';
29
+ export interface VoiceStatus {
30
+ available: boolean;
31
+ reason?: VoiceUnavailableReason;
32
+ /** Display-only ('max', 'pro'); present when the credential store reported one. */
33
+ subscriptionType?: string;
34
+ expiresAt?: number;
35
+ }
36
+ /**
37
+ * Resolve the server's dictation readiness. Split out and exported so the status
38
+ * endpoint and the WebSocket upgrade cannot drift apart: the socket must never
39
+ * accept a stream the status endpoint calls unavailable.
40
+ */
41
+ export declare function resolveVoiceStatus(enabled: boolean): Promise<VoiceStatus>;
42
+ /** Test seam: the cap is process-wide state, so suites must be able to reset it. */
43
+ export declare function _resetVoiceStreamCountForTesting(): void;
44
+ export declare function registerVoiceRoutes(app: FastifyInstance, ctx: ConfigPort, getHostPolicy: () => HostPolicy): void;
45
+ //# sourceMappingURL=voice-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/voice-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG1C,OAAO,EAAgD,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAI1G,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAKpD,sEAAsE;AACtE,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAE7F,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAChC,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAa/E;AAKD,oFAAoF;AACpF,wBAAgB,gCAAgC,IAAI,IAAI,CAEvD;AAYD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,GAAG,IAAI,CA2GhH"}