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:
Hermes Agent 2026-07-24 08:39:19 -07:00 committed by Teknium
parent 8177457cd1
commit 71a2feeade
No known key found for this signature in database
6 changed files with 358 additions and 1 deletions

View 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('doesnt 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]')
})
})

View file

@ -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 => {

View 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 (wont re-arm this session)')
}
const reason = r.reason === 'not_owner' ? 'this surface doesnt 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)
}
}
]

View file

@ -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
]

View 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
}

View file

@ -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 {