mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(tui): /wake on|off|status slash command
TUI-local handler over the wake.start/stop/status RPCs with friendly transcript one-liners (phrase, provider, foreign-owner note, refusal reasons, unavailability hints). /wake off sets a session-scoped opt-out the gateway.ready auto-arm respects, so the listener stays off across reconnects until /wake on. cli_only already means CLI+TUI (verified: messaging menus exclude it); zero Python changes needed.
This commit is contained in:
parent
8177457cd1
commit
71a2feeade
6 changed files with 358 additions and 1 deletions
190
ui-tui/src/__tests__/wakeCommand.test.ts
Normal file
190
ui-tui/src/__tests__/wakeCommand.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { wakeCommands } from '../app/slash/commands/wake.js'
|
||||
import { isWakeUserDisabled, setWakeUserDisabled } from '../app/wakeState.js'
|
||||
|
||||
const wakeCommand = wakeCommands.find(cmd => cmd.name === 'wake')!
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
|
||||
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
wakeCommand.run(arg, ctx as any, `/wake${arg ? ` ${arg}` : ''}`)
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
describe('/wake slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setWakeUserDisabled(false)
|
||||
})
|
||||
|
||||
it('registers with usage metadata', () => {
|
||||
expect(wakeCommand).toBeDefined()
|
||||
expect(wakeCommand.usage).toBe('/wake [on|off|status]')
|
||||
})
|
||||
|
||||
it('/wake on calls wake.start with surface tui and reports listening', async () => {
|
||||
const { rpc, run, sys } = buildCtx({
|
||||
'wake.start': { phrase: 'hey hermes', provider: 'openwakeword', started: true }
|
||||
})
|
||||
|
||||
await run('on')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' })
|
||||
expect(printed(sys)).toContain('listening')
|
||||
expect(printed(sys)).toContain('hey hermes')
|
||||
expect(printed(sys)).toContain('openwakeword')
|
||||
})
|
||||
|
||||
it('/wake on clears the session opt-out flag', async () => {
|
||||
setWakeUserDisabled(true)
|
||||
|
||||
const { run } = buildCtx({ 'wake.start': { started: true } })
|
||||
|
||||
await run('on')
|
||||
|
||||
expect(isWakeUserDisabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('/wake on prints the reason when the gateway refuses', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'wake.start': { owner_surface: 'gui', reason: 'owned', started: false }
|
||||
})
|
||||
|
||||
await run('on')
|
||||
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('not started')
|
||||
expect(out).toContain('another surface owns the listener')
|
||||
expect(out).toContain('gui')
|
||||
})
|
||||
|
||||
it('/wake on surfaces the hint when unavailable', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'wake.start': { hint: 'pip install openwakeword', reason: 'unavailable', started: false }
|
||||
})
|
||||
|
||||
await run('on')
|
||||
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('unavailable')
|
||||
expect(out).toContain('pip install openwakeword')
|
||||
})
|
||||
|
||||
it('/wake off calls wake.stop, remembers the opt-out, and reports', async () => {
|
||||
const { rpc, run, sys } = buildCtx({ 'wake.stop': { stopped: true } })
|
||||
|
||||
await run('off')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('wake.stop', {})
|
||||
expect(isWakeUserDisabled()).toBe(true)
|
||||
expect(printed(sys)).toContain('listener off')
|
||||
})
|
||||
|
||||
it('/wake off explains a not_owner refusal but still records the opt-out', async () => {
|
||||
const { run, sys } = buildCtx({ 'wake.stop': { reason: 'not_owner', stopped: false } })
|
||||
|
||||
await run('off')
|
||||
|
||||
expect(isWakeUserDisabled()).toBe(true)
|
||||
expect(printed(sys)).toContain('nothing to stop')
|
||||
expect(printed(sys)).toContain('doesn’t own the listener')
|
||||
})
|
||||
|
||||
it('/wake status prints a listening one-liner', async () => {
|
||||
const { rpc, run, sys } = buildCtx({
|
||||
'wake.status': {
|
||||
available: true,
|
||||
listening: true,
|
||||
owned_by_caller: true,
|
||||
owner_surface: 'tui',
|
||||
phrase: 'hey hermes',
|
||||
provider: 'openwakeword'
|
||||
}
|
||||
})
|
||||
|
||||
await run('status')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('wake.status', {})
|
||||
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('listening')
|
||||
expect(out).toContain('hey hermes')
|
||||
expect(out).toContain('openwakeword')
|
||||
})
|
||||
|
||||
it('bare /wake behaves like /wake status', async () => {
|
||||
const { rpc, run } = buildCtx({ 'wake.status': { available: true, listening: false } })
|
||||
|
||||
await run('')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('wake.status', {})
|
||||
})
|
||||
|
||||
it('status reports another surface owning the listener', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'wake.status': {
|
||||
available: true,
|
||||
listening: false,
|
||||
owned_by_caller: false,
|
||||
owner_surface: 'gui',
|
||||
phrase: 'hey hermes'
|
||||
}
|
||||
})
|
||||
|
||||
await run('status')
|
||||
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('off here')
|
||||
expect(out).toContain('gui')
|
||||
})
|
||||
|
||||
it('status surfaces the hint when the wake word is unavailable', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'wake.status': { available: false, hint: 'no microphone detected', listening: false }
|
||||
})
|
||||
|
||||
await run('status')
|
||||
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('unavailable')
|
||||
expect(out).toContain('no microphone detected')
|
||||
})
|
||||
|
||||
it('rejects unknown subcommands with usage text', async () => {
|
||||
const { rpc, run, sys } = buildCtx({})
|
||||
|
||||
await run('banana')
|
||||
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
expect(printed(sys)).toContain('usage: /wake [on|off|status]')
|
||||
})
|
||||
})
|
||||
|
|
@ -32,6 +32,7 @@ import { flashGoodVibes, flashPet } from './petFlashStore.js'
|
|||
import { turnController } from './turnController.js'
|
||||
import { getTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
import { isWakeUserDisabled } from './wakeState.js'
|
||||
|
||||
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
|
||||
|
||||
|
|
@ -623,7 +624,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
// Arm "Hey Hermes" if this surface owns it (server gates on config).
|
||||
// Fire-and-forget + idempotent server-side, so reconnects are harmless.
|
||||
void rpc('wake.start', { surface: 'tui' }).catch(() => undefined)
|
||||
// Skipped when the user explicitly ran `/wake off` this session — an
|
||||
// explicit opt-out must survive gateway reconnects (see wakeState.ts).
|
||||
if (!isWakeUserDisabled()) {
|
||||
void rpc('wake.start', { surface: 'tui' }).catch(() => undefined)
|
||||
}
|
||||
|
||||
rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(r => {
|
||||
|
|
|
|||
119
ui-tui/src/app/slash/commands/wake.ts
Normal file
119
ui-tui/src/app/slash/commands/wake.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js'
|
||||
import { setWakeUserDisabled } from '../../wakeState.js'
|
||||
import type { SlashCommand, SlashRunCtx } from '../types.js'
|
||||
|
||||
const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const
|
||||
|
||||
type WakeSub = (typeof WAKE_SUBCOMMANDS)[number]
|
||||
|
||||
const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value)
|
||||
|
||||
// Friendly text for the gateway's wake.start refusal codes. Unknown codes
|
||||
// fall through to the raw reason so new server-side codes stay visible.
|
||||
const START_REASON_TEXT: Record<string, string> = {
|
||||
disabled_for_surface: 'disabled for this surface (config wake_word.enabled / wake_word.surface)',
|
||||
not_owner: 'another surface owns the listener',
|
||||
owned: 'another surface owns the listener',
|
||||
unavailable: 'unavailable'
|
||||
}
|
||||
|
||||
const startFailureLine = (r: WakeStartResponse): string => {
|
||||
const reason = r.reason ?? 'unknown'
|
||||
const base = START_REASON_TEXT[reason] ?? reason
|
||||
const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : ''
|
||||
const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : ''
|
||||
|
||||
return `wake: not started — ${base}${owner}${hint}`
|
||||
}
|
||||
|
||||
const statusLine = (r: WakeStatusResponse): string => {
|
||||
const phrase = r.phrase ? ` for “${r.phrase}”` : ''
|
||||
const provider = r.provider ? ` · ${r.provider}` : ''
|
||||
|
||||
if (r.listening) {
|
||||
return `wake: listening${phrase}${provider}`
|
||||
}
|
||||
|
||||
if (r.owner_surface && !r.owned_by_caller) {
|
||||
return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}`
|
||||
}
|
||||
|
||||
if (r.available === false) {
|
||||
const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : ''
|
||||
|
||||
return `wake: unavailable${hint}`
|
||||
}
|
||||
|
||||
return `wake: off${phrase}${provider} · /wake on to arm`
|
||||
}
|
||||
|
||||
const runOn = (ctx: SlashRunCtx): void => {
|
||||
setWakeUserDisabled(false)
|
||||
|
||||
ctx.gateway
|
||||
.rpc<WakeStartResponse>('wake.start', { surface: 'tui' })
|
||||
.then(
|
||||
ctx.guarded<WakeStartResponse>(r => {
|
||||
if (!r.started) {
|
||||
return ctx.transcript.sys(startFailureLine(r))
|
||||
}
|
||||
|
||||
const phrase = r.phrase ? ` for “${r.phrase}”` : ''
|
||||
const provider = r.provider ? ` · ${r.provider}` : ''
|
||||
|
||||
ctx.transcript.sys(`wake: listening${phrase}${provider}`)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
const runOff = (ctx: SlashRunCtx): void => {
|
||||
// Remember the explicit opt-out so gateway reconnects don't re-arm the
|
||||
// listener behind the user's back (see wakeState.ts).
|
||||
setWakeUserDisabled(true)
|
||||
|
||||
ctx.gateway
|
||||
.rpc<WakeStopResponse>('wake.stop', {})
|
||||
.then(
|
||||
ctx.guarded<WakeStopResponse>(r => {
|
||||
if (r.stopped) {
|
||||
return ctx.transcript.sys('wake: listener off (won’t re-arm this session)')
|
||||
}
|
||||
|
||||
const reason = r.reason === 'not_owner' ? 'this surface doesn’t own the listener' : (r.reason ?? 'not running')
|
||||
|
||||
ctx.transcript.sys(`wake: nothing to stop — ${reason}`)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
const runStatus = (ctx: SlashRunCtx): void => {
|
||||
ctx.gateway
|
||||
.rpc<WakeStatusResponse>('wake.status', {})
|
||||
.then(ctx.guarded<WakeStatusResponse>(r => ctx.transcript.sys(statusLine(r))))
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
const WAKE_RUNNERS: Record<WakeSub, (ctx: SlashRunCtx) => void> = {
|
||||
off: runOff,
|
||||
on: runOn,
|
||||
status: runStatus
|
||||
}
|
||||
|
||||
export const wakeCommands: SlashCommand[] = [
|
||||
{
|
||||
help: "toggle the 'Hey Hermes' wake word listener [on|off|status]",
|
||||
name: 'wake',
|
||||
usage: '/wake [on|off|status]',
|
||||
run: (arg, ctx) => {
|
||||
const sub = arg.trim().toLowerCase()
|
||||
|
||||
if (sub && !isWakeSub(sub)) {
|
||||
return ctx.transcript.sys('usage: /wake [on|off|status]')
|
||||
}
|
||||
|
||||
WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -5,6 +5,7 @@ import { sessionCommands } from './commands/session.js'
|
|||
import { setupCommands } from './commands/setup.js'
|
||||
import { subscriptionCommands } from './commands/subscription.js'
|
||||
import { topupCommands } from './commands/topup.js'
|
||||
import { wakeCommands } from './commands/wake.js'
|
||||
import type { SlashCommand } from './types.js'
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
|
|
@ -13,6 +14,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|||
...sessionCommands,
|
||||
...subscriptionCommands,
|
||||
...opsCommands,
|
||||
...wakeCommands,
|
||||
...setupCommands,
|
||||
...debugCommands
|
||||
]
|
||||
|
|
|
|||
15
ui-tui/src/app/wakeState.ts
Normal file
15
ui-tui/src/app/wakeState.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Session-scoped memory of an explicit `/wake off`.
|
||||
//
|
||||
// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready`
|
||||
// (see createGatewayEventHandler.ts). When the user explicitly disables the
|
||||
// listener with `/wake off`, a reconnect must NOT silently re-arm it — this
|
||||
// module-level flag records that intent for the lifetime of the process.
|
||||
// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`)
|
||||
// remains the durable on/off switch; this is only per-session steering.
|
||||
let wakeUserDisabled = false
|
||||
|
||||
export const isWakeUserDisabled = (): boolean => wakeUserDisabled
|
||||
|
||||
export const setWakeUserDisabled = (disabled: boolean): void => {
|
||||
wakeUserDisabled = disabled
|
||||
}
|
||||
|
|
@ -400,6 +400,32 @@ export interface VoiceRecordResponse {
|
|||
text?: string
|
||||
}
|
||||
|
||||
// ── Wake word ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WakeStartResponse {
|
||||
hint?: string
|
||||
owner_surface?: null | string
|
||||
phrase?: string
|
||||
provider?: string
|
||||
reason?: string
|
||||
started?: boolean
|
||||
}
|
||||
|
||||
export interface WakeStopResponse {
|
||||
reason?: null | string
|
||||
stopped?: boolean
|
||||
}
|
||||
|
||||
export interface WakeStatusResponse {
|
||||
available?: boolean
|
||||
hint?: string
|
||||
listening?: boolean
|
||||
owned_by_caller?: boolean
|
||||
owner_surface?: null | string
|
||||
phrase?: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
// ── Tools (TS keeps configure since it resets local history) ─────────
|
||||
|
||||
export interface ToolsConfigureResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue