/ voice / voiceModeEnabled.ts
voiceModeEnabled.ts
 1  import { feature } from 'bun:bundle'
 2  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
 3  import {
 4    getClaudeAIOAuthTokens,
 5    isAnthropicAuthEnabled,
 6  } from '../utils/auth.js'
 7  
 8  /**
 9   * Kill-switch check for voice mode. Returns true unless the
10   * `tengu_amber_quartz_disabled` GrowthBook flag is flipped on (emergency
11   * off). Default `false` means a missing/stale disk cache reads as "not
12   * killed" — so fresh installs get voice working immediately without
13   * waiting for GrowthBook init. Use this for deciding whether voice mode
14   * should be *visible* (e.g., command registration, config UI).
15   */
16  export function isVoiceGrowthBookEnabled(): boolean {
17    // Positive ternary pattern — see docs/feature-gating.md.
18    // Negative pattern (if (!feature(...)) return) does not eliminate
19    // inline string literals from external builds.
20    return feature('VOICE_MODE')
21      ? !getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)
22      : false
23  }
24  
25  /**
26   * Auth-only check for voice mode. Returns true when the user has a valid
27   * Anthropic OAuth token. Backed by the memoized getClaudeAIOAuthTokens —
28   * first call spawns `security` on macOS (~20-50ms), subsequent calls are
29   * cache hits. The memoize clears on token refresh (~once/hour), so one
30   * cold spawn per refresh is expected. Cheap enough for usage-time checks.
31   */
32  export function hasVoiceAuth(): boolean {
33    // Voice mode requires Anthropic OAuth — it uses the voice_stream
34    // endpoint on claude.ai which is not available with API keys,
35    // Bedrock, Vertex, or Foundry.
36    if (!isAnthropicAuthEnabled()) {
37      return false
38    }
39    // isAnthropicAuthEnabled only checks the auth *provider*, not whether
40    // a token exists. Without this check, the voice UI renders but
41    // connectVoiceStream fails silently when the user isn't logged in.
42    const tokens = getClaudeAIOAuthTokens()
43    return Boolean(tokens?.accessToken)
44  }
45  
46  /**
47   * Full runtime check: auth + GrowthBook kill-switch. Callers: `/voice`
48   * (voice.ts, voice/index.ts), ConfigTool, VoiceModeNotice — command-time
49   * paths where a fresh keychain read is acceptable. For React render
50   * paths use useVoiceEnabled() instead (memoizes the auth half).
51   */
52  export function isVoiceModeEnabled(): boolean {
53    return hasVoiceAuth() && isVoiceGrowthBookEnabled()
54  }