mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
fix(wake): route desktop control and select input devices
This commit is contained in:
parent
70411a6152
commit
94d1dff50d
11 changed files with 523 additions and 26 deletions
|
|
@ -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'
|
||||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export type DesktopActionId =
|
|||
| 'profile'
|
||||
| 'skin'
|
||||
| 'title'
|
||||
| 'wake'
|
||||
| 'yolo'
|
||||
|
||||
/** A command fulfilled by opening a desktop overlay picker. */
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue