const STATES = ['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']; const COMMANDS = new Map([ ['cancel', 'cancel'], ['stop', 'cancel'], ['never mind', 'cancel'], ['hands off', 'cancel'], ['stop clicking', 'cancel'], ["that's enough", 'cancel'], ['go to sleep', 'sleep'], ['privacy mode', 'sleep'], ]); export class VoiceStateMachine { constructor({ now = () => Date.now(), idleMs = 30 * 60 * 1000 } = {}) { this.now = now; this.idleMs = idleMs; this.state = 'ARMED'; this.lastActivity = now(); } transition(next, reason = 'unspecified') { if (!STATES.includes(next)) throw new Error(`unknown Jarvis state: ${next}`); const allowed = { ARMED: ['LISTENING', 'SLEEPING'], LISTENING: ['THINKING', 'ARMED', 'SLEEPING', 'SPEAKING'], THINKING: ['SPEAKING', 'ARMED', 'LISTENING'], SPEAKING: ['LISTENING', 'ARMED', 'THINKING'], SLEEPING: ['LISTENING', 'ARMED'], }; if (next !== this.state && !allowed[this.state].includes(next)) { throw new Error(`invalid state transition ${this.state} -> ${next}`); } const previous = this.state; this.state = next; this.lastActivity = this.now(); return { previous, state: next, reason }; } wake() { return this.transition('LISTENING', 'wake'); } utterance() { return this.transition('THINKING', 'utterance'); } typedUtterance() { if (this.state === 'ARMED' || this.state === 'SLEEPING') this.wake(); return this.utterance(); } speak() { return this.transition('SPEAKING', 'reply'); } finishSpeaking() { return this.transition('LISTENING', 'playback-finished'); } cancel() { return this.transition('ARMED', 'cancel'); } sleep() { return this.transition('SLEEPING', 'idle-or-privacy'); } command(text) { const command = COMMANDS.get(String(text || '').trim().toLowerCase()); if (command === 'cancel') this.cancel(); if (command === 'sleep') this.sleep(); return command || null; } expireIdle() { if (['ARMED', 'LISTENING'].includes(this.state) && this.now() - this.lastActivity >= this.idleMs) this.sleep(); return this.state; } } export { STATES };