Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
setSessions
} from '@/store/session'
import { dropSessionState, publishSessionState } from '@/store/session-states'
import { $wakeWord, resetWakeWordState } from '@/store/wake-word'
import type { SessionInfo } from '@/types/hermes'

import type { SubmitTextOptions } from './utils'
Expand Down Expand Up @@ -427,6 +428,110 @@ describe('usePromptActions slash session targeting', () => {
})
})

describe('usePromptActions /wake', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
resetWakeWordState()
})

afterEach(() => {
cleanup()
resetWakeWordState()
vi.restoreAllMocks()
})

it('starts the GUI-owned listener through wake.start and never spawns the slash worker', async () => {
const seeds: Record<string, unknown>[] = []

const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'wake.start') {
return {
owner_surface: 'gui',
phrase: 'hey hermes',
provider: 'openwakeword',
started: true
} as never
}

if (method === 'wake.status') {
return {
available: true,
configured_surface: 'gui',
enabled: true,
input_device: {
hostapi: 'Windows WASAPI',
name: 'Microphone Array',
selector: 'Microphone Array'
},
listening: true,
owner_surface: 'gui',
phrase: 'hey hermes',
provider: 'openwakeword'
} as never
}

return {} as never
})

let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => seeds.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)

await handle!.submitText('/wake on')

expect(requestGateway).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'gui' }, 180_000)
expect(requestGateway).toHaveBeenCalledWith('wake.status', {})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
expect($wakeWord.get()).toMatchObject({ available: true, enabled: true, listening: true })
expect(renderedSeedTexts(seeds).join('\n')).toContain('Input: Microphone Array (Windows WASAPI)')
})

it('uses gateway truth for a bare toggle and stops through wake.stop', async () => {
let statusCalls = 0

const requestGateway = vi.fn(async (method: string) => {
if (method === 'wake.status') {
statusCalls += 1

return {
available: true,
enabled: statusCalls === 1,
listening: statusCalls === 1,
owner_surface: statusCalls === 1 ? 'gui' : null,
phrase: 'hey hermes',
provider: 'openwakeword'
} as never
}

if (method === 'wake.stop') {
return { disabled_persisted: true, stopped: true } as never
}

return {} as never
})

let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

await handle!.submitText('/wake')

expect(requestGateway.mock.calls.map(([method]) => method)).toEqual(['wake.status', 'wake.stop', 'wake.status'])
expect(requestGateway).toHaveBeenCalledWith('wake.stop', { persist: true })
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
expect($wakeWord.get()).toMatchObject({ enabled: false, listening: false })
})
})

describe('usePromptActions /compress', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
Expand Down
107 changes: 107 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ import {
setYoloActive
} from '@/store/session'
import { $sessionStates } from '@/store/session-states'
import {
applyWakeStartResult,
applyWakeStatus,
applyWakeStopResult,
type WakeInputDeviceStatus,
type WakeStartResponse,
type WakeStatusResponse,
type WakeStopResponse
} from '@/store/wake-word'

import type {
BrowserManageResponse,
Expand All @@ -60,6 +69,43 @@ import {
// default WS request timeout on large sessions β€” give it the TUI client's
// 120s RPC budget (HERMES_TUI_RPC_TIMEOUT_MS default) instead.
const SESSION_COMPRESS_TIMEOUT_MS = 120_000
const WAKE_START_TIMEOUT_MS = 180_000

const wakeDeviceLabel = (device?: WakeInputDeviceStatus): string => {
if (!device) {
return 'system default'
}

const selector = device.selector
const name = device.name?.trim() || (selector == null ? 'system default' : String(selector))

return device.hostapi?.trim() ? `${name} (${device.hostapi.trim()})` : name
}

const renderWakeStatus = (status: WakeStatusResponse): string => {
const lines = [
'Wake Word Status',
`State: ${status.listening ? 'LISTENING' : 'OFF'}`,
`Phrase: "${status.phrase?.trim() || 'hey hermes'}"`,
`Provider: ${status.provider?.trim() || 'unknown'}`,
`Surface: ${status.owner_surface?.trim() || status.configured_surface?.trim() || 'auto'}`,
`Input: ${wakeDeviceLabel(status.input_device)}`
]

if (status.audio_silent) {
lines.push('Audio: silent')
}

if (status.input_device?.error?.trim()) {
lines.push(`Input error: ${status.input_device.error.trim()}`)
}

if (status.hint?.trim()) {
lines.push(`Hint: ${status.hint.trim()}`)
}

return lines.join('\n')
}

/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
Expand Down Expand Up @@ -592,6 +638,67 @@ export function useSlashCommand(deps: SlashCommandDeps) {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
},
// /wake must stay in the gateway process that owns the Desktop wake
// lease. Sending it through slash.exec creates a separate HermesCLI in
// the slash worker, which can claim the machine-wide microphone lock
// while the Desktop UI still reports the GUI listener as off.
wake: async ctx => {
const resolved = await withSlashOutput(ctx)

if (!resolved) {
return
}

const { render: renderSlashOutput } = resolved
const requested = ctx.arg.trim().toLowerCase()

if (requested && !['on', 'off', 'status'].includes(requested)) {
renderSlashOutput('usage: /wake [on|off|status]')

return
}

const status = async (): Promise<WakeStatusResponse> => {
const current = await requestGateway<WakeStatusResponse>('wake.status', {})
applyWakeStatus(current)

return current
}

try {
let action = requested

// Bare /wake is an authoritative toggle. Query the gateway instead
// of trusting a potentially stale renderer cache.
if (!action) {
action = (await status()).listening ? 'off' : 'on'
}

if (action === 'on') {
const started = await requestGateway<WakeStartResponse>(
'wake.start',
{ persist: true, surface: 'gui' },
WAKE_START_TIMEOUT_MS
)

applyWakeStartResult(started)

if (!started?.started) {
renderSlashOutput(
`Failed to start wake word: ${started?.hint?.trim() || started?.reason?.trim() || 'unknown error'}`
)

return
}
} else if (action === 'off') {
applyWakeStopResult(await requestGateway<WakeStopResponse>('wake.stop', { persist: true }))
}

renderSlashOutput(renderWakeStatus(await status()))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /handoff hands this session to a messaging platform. The platform is
// completed inline in the slash popover (backend _handoff_completions),
// so there is no overlay: `/handoff <platform>` runs the desktop's own
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashCommand('/pets')).toBe(false)
})

it('routes /wake through the desktop wake action instead of the slash worker', () => {
expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' })
expect(desktopSlashCommandArgumentMode('/wake')).toBe('options')
expect(isDesktopSlashSuggestion('/wake')).toBe(true)
expect(isDesktopSlashCommand('/wake')).toBe(true)
expect(desktopSlashUnavailableMessage('/wake')).toBeNull()
})

it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type DesktopActionId =
| 'profile'
| 'skin'
| 'title'
| 'wake'
| 'yolo'

/** A command fulfilled by opening a desktop overlay picker. */
Expand Down Expand Up @@ -168,6 +169,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
surface: action('branch')
},
{ name: '/yolo', description: 'Toggle YOLO β€” auto-approve dangerous commands', surface: action('yolo') },
{
name: '/wake',
description: 'Control the desktop wake-word listener [on|off|status]',
surface: action('wake'),
argumentMode: 'options'
},
{
name: '/handoff',
description: 'Hand off this session to a messaging platform',
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/src/store/wake-word.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ const INITIAL_WAKE_WORD_STATE: WakeWordState = {
export const $wakeWord = atom<WakeWordState>(INITIAL_WAKE_WORD_STATE)

export interface WakeStatusResponse {
/** Armed but the mic delivers only silence (macOS backend-permission gap). */
/** Armed but the selected backend input delivers only silence. */
audio_silent?: boolean
available?: boolean
configured_surface?: string
/** Config truth (wake_word.enabled) β€” drives post-voice re-arm. */
enabled?: boolean
hint?: string
input_device?: WakeInputDeviceStatus
listening?: boolean
owned_by_caller?: boolean
owner_surface?: string | null
Expand All @@ -63,6 +65,16 @@ export interface WakeStopResponse {
stopped?: boolean
}

export interface WakeInputDeviceStatus {
default_samplerate?: number
error?: string
hostapi?: string
hostapi_index?: number
max_input_channels?: number
name?: string
selector?: number | string | null
}

/** Minimal requester shape β€” satisfied by both `useGatewayRequest`'s
* `requestGateway` and the `$gateway` instance wrapper below. */
export type WakeRequester = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
Expand Down Expand Up @@ -111,8 +123,8 @@ const noticeFrom = (result: { hint?: string; reason?: string | null } | null | u
export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void {
const current = $wakeWord.get()
const listening = Boolean(status?.listening)
// "Armed but deaf" (macOS backend without mic permission) keeps its hint
// visible in the tooltip even though the toggle shows listening.
// "Armed but deaf" keeps its input-device hint visible in the tooltip even
// though the toggle shows listening.
const silent = Boolean(status?.audio_silent)

$wakeWord.set({
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,7 @@
"wake_word": {
"enabled": False,
"surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui"
"input_device": None, # PortAudio input device index/name; null uses the process default
"provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY)
"phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label β€” detection is keyed by the model/keyword below
"sensitivity": 0.6, # 0.0-1.0 detection threshold, consistent across engines (higher = stricter, fewer false triggers)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,63 @@ def fake_persist(enabled):
server._wake_owner_surface = ""


def test_wake_status_reports_configured_input_device_and_windows_silence_hint(monkeypatch):
from tools import wake_word

config = {
"enabled": True,
"phrase": "hey hermes",
"provider": "openwakeword",
"surface": "gui",
"input_device": "Microphone Array",
}
device = {
"selector": "Microphone Array",
"name": "Microphone Array",
"hostapi": "Windows WASAPI",
"default_samplerate": 48000.0,
}
transport = types.SimpleNamespace(_closed=False)

monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: config)
monkeypatch.setattr(
wake_word,
"check_wake_word_requirements",
lambda cfg: {
"available": True,
"hint": "",
"phrase": "hey hermes",
"provider": "openwakeword",
},
)
monkeypatch.setattr(wake_word, "get_input_device_status", lambda cfg: device)
monkeypatch.setattr(wake_word, "owns_listener", lambda owner: owner is transport)
monkeypatch.setattr(wake_word, "is_listening", lambda: True)
monkeypatch.setattr(wake_word, "audio_is_silent", lambda: True)
monkeypatch.setattr(
wake_word,
"silent_audio_hint",
lambda details: f"silent input: {details['name']} ({details['hostapi']})",
)

server._wake_owner_transport = transport
server._wake_owner_surface = "gui"
try:
response = server.dispatch(
{"id": "wake-status", "method": "wake.status", "params": {}},
transport=transport,
)
assert response["result"]["configured_surface"] == "gui"
assert response["result"]["input_device"] == device
assert response["result"]["audio_silent"] is True
assert response["result"]["hint"] == (
"silent input: Microphone Array (Windows WASAPI)"
)
finally:
server._wake_owner_transport = None
server._wake_owner_surface = ""


def test_voice_record_start_forwards_max_recording_seconds(monkeypatch):
"""voice.max_recording_seconds must reach start_continuous from the TUI.

Expand Down
Loading
Loading