"""Orchestrates the pet's mic -> wake word -> STT -> server -> TTS pipeline. Runs on a background QThread (see ui/app.py) so the Qt event loop / window painting is never blocked by audio I/O or network calls. Talks to the UI only through Qt signals (state_changed / said / log / action / napping), which Qt marshals safely across threads — this class never touches a QWidget directly. Beyond the core loop it owns the side channels that let the pet act on its own: the heartbeat (proactive announcements), the desktop notification bridge, quiet hours, barge-in, and the live wake-word threshold. """ from __future__ import annotations import threading import time from collections import deque from typing import Optional from PySide6.QtCore import QObject, Signal from . import ( config, dialogue as dialogue_mod, file_delivery, file_ops, history as history_mod, intents as intents_mod, monitors as monitors_mod, notifications, pet_actions, quiet, screen_context, screen_text, self_restart, server_client, speech_text, updater, ) from . import __version__ from .audio import barge_in, mic, stt, tts, wake_word from .state import PetState, PetStateMachine # How often to re-check whether the pet should be napping. The fullscreen # probe shells out to xprop, so this deliberately isn't every heartbeat tick. _NAP_CHECK_INTERVAL_SECONDS = 10.0 class PetController(QObject): state_changed = Signal(str) # PetState.value said = Signal(str) # text now showing in the speech bubble log = Signal(str) action = Signal(dict) # parsed petctl action for the UI to perform napping = Signal(bool) # quiet hours / fullscreen do-not-disturb voice_changed = Signal(str) # name of the server-picked voice ("" = default) restart_requested = Signal(str) # version we just updated to finished = Signal() def __init__(self): super().__init__() self._running = True self._muted = False self._talk_now = threading.Event() self._stream = None self._last_heartbeat = 0.0 self._state = PetStateMachine(on_change=self._handle_state_change) # Conversation scrollback, shared read-only with the UI's History # window. Append-only from this thread; the UI only ever snapshots it. self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT) # The screen layout, as published by the UI (see set_monitors). Held # here rather than probed, so "monitor 2" means the same thing to the # controller and to the window that has to jump there — see # monitors.py for why that matters. self._monitors: list[monitors_mod.Monitor] = [] self._pet_monitor: Optional[int] = None # Wake-word sensitivity is live-tunable (tray tuner), so it's read # through a callable on every frame rather than captured per listen. self._wake_threshold = config.WAKE_WORD_THRESHOLD self._near_misses = wake_word.NearMissLog() # The voice the server last picked for us with `speak_as` ("" = the # configured default). Held here rather than passed straight through # to one tts.speak() call because it's sticky by default — see # _apply_voice for why. self._voice_id = "" self._voice_name = "" self._barge_in: Optional[barge_in.BargeInDetector] = None self._napping = False self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule self._last_nap_check = 0.0 # When a reply ends on a question the pet keeps listening for the # answer. _follow_ups counts how many have chained without you # re-triggering, so a server that ends every reply with "?" can't # loop forever off mic noise. self._pending_follow_up = False self._follow_ups = 0 self._last_update_check = 0.0 self._update_pending = False # applied on disk, waiting for the restart # Armed by `petctl self_restart`, fired after the turn it was asked in # (see _arm_self_restart for why it can't happen inline). self._restart_context = None self._notification_watcher: Optional[notifications.NotificationWatcher] = None self._notification_gate = notifications.NotificationGate( config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS ) # Bounded, and stamped on arrival: the drain only runs from the # heartbeat, which doesn't run while napping, so this fills up # overnight. maxlen drops the oldest rather than growing without limit, # and the stamp lets the drain discard a backlog nobody wants read out # at 8am (see _drain_notifications). self._pending_notifications: deque[tuple[float, notifications.Notification]] = deque( maxlen=max(1, config.NOTIFICATION_QUEUE_LIMIT) ) self._notification_lock = threading.Lock() # ── external controls (safe to call from the Qt/UI thread) ───────── def request_talk_now(self) -> None: self._talk_now.set() def toggle_mute(self) -> bool: self._muted = not self._muted self.log.emit("Muted." if self._muted else "Unmuted.") return self._muted def set_napping(self, napping: Optional[bool]) -> None: """Force the nap state on/off, or pass None to hand control back to the quiet-hours schedule.""" self._nap_forced = napping if napping is not None: self._apply_nap_state(napping) def wake_threshold(self) -> float: return self._wake_threshold def set_wake_threshold(self, value: float) -> None: self._wake_threshold = min(max(float(value), 0.01), 0.99) def wake_stats(self) -> dict: return {"peak": self._near_misses.peak, "near_misses": self._near_misses.entries()} def reset_wake_stats(self) -> None: self._near_misses.clear() def current_voice(self) -> str: """Name (or id) of the server-picked voice in use, "" for the default.""" return self._voice_name or self._voice_id def reset_voice(self) -> None: """Drop a server-picked voice and go back to Bolt's own. The tray's way out of a voice you didn't want to keep — the server has no way to ask for the default back, since it never learns what it is.""" if not self._voice_id: return self._voice_id = self._voice_name = "" self.log.emit("Voice: back to the default.") self.voice_changed.emit("") def stop(self) -> None: self._running = False self._talk_now.set() # wake up anything blocked waiting on it if self._notification_watcher is not None: self._notification_watcher.stop() # ── internal ───────────────────────────────────────────────────────── def _handle_state_change(self, _old: PetState, new: PetState) -> None: self.state_changed.emit(new.value) def _should_continue(self) -> bool: return self._running def run(self) -> None: """Thread entry point (connected to QThread.started).""" missing = config.missing_config() if missing: self.log.emit(f"Missing config: {', '.join(missing)} — set them in .env and restart.") self.finished.emit() return try: self._stream = mic.open_input_stream() except Exception as exc: self.log.emit(f"Could not open microphone: {exc}") self.finished.emit() return if config.BARGE_IN: # In wake mode the detector shares the idle listener's model and # its live threshold, so the tray tuner's slider applies to # interrupting as well as waking (unless BARGE_IN_WAKE_THRESHOLD # pins it to a fixed, stricter number). self._barge_in = barge_in.make_detector( self._stream, wake_threshold=( config.BARGE_IN_WAKE_THRESHOLD if config.BARGE_IN_WAKE_THRESHOLD is not None else self.wake_threshold ), ) self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.") # Everything past here is in try/finally because `finished` is what # ui/app.py waits on to quit the QThread and to run a pending # os.execv. An exception escaping _loop used to skip it, leaving the # thread wedged with the mic still open and no restart — so the failure # mode of any bug below was "the pet goes deaf and the tray won't quit" # rather than "one turn failed". try: with self._stream: try: health = server_client.check_health() self.log.emit(f"Connected to server: {health}") except Exception as exc: self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.") self._start_notification_bridge() self._guarded(self._report_self_restart, "restart report") self._loop() except Exception as exc: self.log.emit(f"Pipeline stopped unexpectedly: {exc!r}") finally: if self._notification_watcher is not None: self._notification_watcher.stop() self.finished.emit() def _guarded(self, work, label: str) -> bool: """Run *work*, absorbing anything it raises. The pipeline is one thread driving a state machine that raises on an illegal transition (deliberately — see state.py), plus a dozen best-effort subsystems that shell out, hit the network, or touch the filesystem. Any one of them raising something unforeseen used to end the whole session. Here, it costs a log line and a forced return to IDLE, which is the only state it's always safe to resume from. Returns True if *work* completed without raising.""" try: work() return True except Exception as exc: self.log.emit(f"Recovered from a {label} failure: {exc!r}") self._state.force(PetState.IDLE) return False def _loop(self) -> None: while self._running: if self._muted: triggered = self._talk_now.wait(timeout=0.5) if not self._running: return if not triggered: continue self._talk_now.clear() else: if not self._wait_for_wake_or_click(): if not self._running: return continue self._guarded(self._handle_conversation_turn, "conversation turn") def _wait_for_wake_or_click(self) -> bool: """True once either the wake phrase was heard or a click-to-talk request came in; False on a spurious wakeup (loop again).""" def should_continue() -> bool: return self._running and not self._talk_now.is_set() detected = wake_word.listen_for_wake_word( self._stream, should_continue=should_continue, threshold=self.wake_threshold, # callable: the tuner slider is live # Guarded: on_tick is the one place control returns to us during a # listen that can block for minutes, and everything it drives # (update check, nap probe, notification forwarding) touches the # network or shells out. Unguarded, any of them raising would unwind # the listen loop and end the session. on_tick=lambda: self._guarded(self._maybe_heartbeat, "heartbeat"), on_score=self._observe_wake_score, ) if not self._running: return False if detected: self._talk_now.clear() # in case both fired around the same time return True if self._talk_now.is_set(): self._talk_now.clear() return True return False def _observe_wake_score(self, score: float, threshold: float) -> None: self._near_misses.observe(score, threshold, time.time()) def _handle_conversation_turn(self) -> None: # A turn you started yourself ends any follow-up chain in progress. following_up, self._pending_follow_up = self._pending_follow_up, False if not following_up: self._follow_ups = 0 self._state.transition(PetState.LISTENING) pcm = mic.record_utterance( self._stream, should_continue=self._should_continue, # Answering a question deserves longer than saying the wake word # on purpose does — you were just asked something. grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None, ) if pcm is None: self._follow_ups = 0 # silence ends the chain self._state.transition(PetState.IDLE) return self._state.transition(PetState.THINKING) try: text = stt.transcribe(pcm) except stt.SttError as exc: self.log.emit(f"STT failed: {exc}") self._state.transition(PetState.ERROR) self._state.transition(PetState.IDLE) return if not text: self._state.transition(PetState.IDLE) return self.log.emit(f"You: {text}") self.history.add(history_mod.USER, text, time.time()) # "stop", "come here", "say that again" — answered here, without the # round trip. Never on a follow-up turn: Bolt asked you something and # the answer is his, even if it happens to look like a body command. if not following_up and self._handle_local_intent(text): self._state.transition(PetState.IDLE) return try: # What's focused right now rides along, so "what's this error?" # has a referent without you having to describe the window. reply = server_client.converse( self._with_context(text), on_command=self._handle_command ) except server_client.ServerError as exc: self.log.emit(f"Server error: {exc}") self._state.transition(PetState.ERROR) self._state.transition(PetState.IDLE) return self._check_deliveries() self._apply_voice(reply) self._speak(reply.text) self._state.transition(PetState.IDLE) self._maybe_self_restart() def _handle_local_intent(self, text: str) -> bool: """Answer *text* locally if it's one of the closed set of body commands in intents.py. Returns True if it was handled (no server call). The effects live here rather than in intents.py for the same reason pet_actions splits parse from describe: recognising the phrase is pure and testable, doing the thing needs the controller's state, the tray's nap override and a Qt signal to the window.""" if not config.LOCAL_INTENTS: return False intent = intents_mod.recognize(text) if intent is None: return False self.log.emit(f"Local intent: {intent.name} (answered without the server)") if intent.name == "stop": # Nothing to say and nothing to do: silence is the acknowledgement. # Also ends any follow-up chain — "never mind" means the # conversation is over, not that we should keep the mic open. self._follow_ups = 0 self._pending_follow_up = False self._talk_now.clear() return True if intent.name == "repeat": last = self.history.last(history_mod.PET) if last is None: self._speak("I haven't said anything yet.") else: # remember=False: replaying a line isn't a new turn. Appending it # would make "say that again" twice over read back as a # conversation where Bolt volunteered the same thing three times. self._speak(last.text, remember=False) return True if intent.name == "voice_reset": had_voice = bool(self._voice_id) self.reset_voice() self._speak(intent.speak if had_voice else "That is my normal voice.") return True action = intent.action if action is not None: if action.get("action") == "nap": # Through set_napping, not just the signal, so a spoken "go to # sleep" overrides the quiet-hours schedule exactly like the # tray's Nap entry and `petctl nap` do — otherwise the next # schedule check would undo it within ten seconds. self.set_napping(bool(action["enabled"])) self.action.emit(dict(action)) if intent.speak: self._speak(intent.speak) return True def _with_context(self, text: str) -> str: """Everything the server gets alongside what you actually said: the focused window title, and a one-line note about the screen layout so Bolt knows how many monitors there are and where he's standing without having to ask. Only the *layout* rides along for free — the text on those screens costs an OCR pass, so it stays behind `petctl read`.""" text = screen_context.context_for(text) if config.MONITOR_CONTEXT: text = monitors_mod.annotate(text, self._monitors, self._pet_monitor) return text # ── screen layout, published by the UI ─────────────────────────────── def set_monitors(self, monitors: list) -> None: """Slot: the window telling us what screens exist (queued signal).""" self._monitors = list(monitors) self.log.emit( "Screens: " + (monitors_mod.summary(self._monitors) or "none reported") ) def set_pet_monitor(self, index: int) -> None: """Slot: the window telling us which screen the pet is standing on.""" self._pet_monitor = int(index) def _handle_command(self, command: str) -> str: """Server-relayed command, with the guarantee the relay depends on: this always returns a string. The server is blocked on `/desk/tool_result` while this runs. If it raises instead of answering, the relay never posts, the turn dies mid-flight, and the server sits out its own timeout on a conversation it can't finish — the worst available failure mode, because it's silent on both ends. Handing the exception back as command output instead means Bolt can read what went wrong and say so, or try something else, inside the same turn.""" try: return self._dispatch_command(command) except Exception as exc: self.log.emit(f"Command handler failed: {exc!r}") return f"[error] the pet couldn't run that: {exc}" def _dispatch_command(self, command: str) -> str: """`petctl ...` drives the pet's body, `dialoguectl ...` plays a scene and `filectl ...` does local file read/write/edit — none of them ever reach a shell; everything else is a real command, exactly as before (see the security notes in the README).""" try: action = pet_actions.parse(command) except pet_actions.ActionError as exc: self.log.emit(f"petctl: {exc}") return f"[pet] {exc}" if action is not None: self.log.emit(f"Pet action: {action}") # Queries answer from here rather than from pet_actions.describe(): # their output *is* the useful part, and it's what the server reads # back off the tool-result relay. kind = action["action"] if kind == "monitors": return monitors_mod.describe(self._monitors, self._pet_monitor) if kind == "self_restart": return self._arm_self_restart(action.get("reason") or "") if kind == "voice": # Answered here, not by describe(): the UI has no part in it, # and the server needs to hear whether there was anything to # drop — it can't see which voice we're using. previous = self.current_voice() self.reset_voice() return ( f"[pet] back to your own voice (was {previous})" if previous else "[pet] already using your own voice" ) if kind == "read": return self._read_screen(action["target"]) if kind == "jump": try: target = monitors_mod.resolve( self._monitors, action["target"], self._pet_monitor ) except ValueError as exc: self.log.emit(f"petctl jump: {exc}") return f"[pet] {exc}" # Hand the window a resolved index, so it can't re-resolve the # spec against a different screen ordering. self.action.emit({"action": "jump", "monitor": target.index}) return f"[pet] jumped to monitor {target.label}" if kind == "nap": self.set_napping(bool(action["enabled"])) self.action.emit(action) return pet_actions.describe(action) try: scene = dialogue_mod.parse(command) except dialogue_mod.DialogueError as exc: self.log.emit(f"dialoguectl: {exc}") return f"[dialogue] {exc}" if scene is not None: return self._play_dialogue(scene) try: file_action = file_ops.parse(command) except file_ops.FileOpError as exc: self.log.emit(f"filectl: {exc}") return f"[filectl] {exc}" if file_action is not None: self.log.emit(file_ops.describe(file_action)) try: return file_ops.execute(file_action) except file_ops.FileOpError as exc: self.log.emit(f"filectl: {exc}") return f"[filectl] {exc}" return server_client.run_local_command(command) def _read_screen(self, target: str) -> str: """`petctl read` — OCR a screen and hand the text back to the server.""" if not config.SCREEN_TEXT: return "[pet] screen reading is disabled (set SCREEN_TEXT=true in .env)" if not self._monitors: return "[pet] no monitor information available" limit = config.SCREEN_TEXT_MAX_CHARS if target in ("all", "everything", "*"): self.log.emit(f"Reading all {len(self._monitors)} screens…") return screen_text.read_monitors(self._monitors, limit) if target in ("here", "", "this", "current"): index = self._pet_monitor if self._pet_monitor is not None else 0 monitor = self._monitors[min(index, len(self._monitors) - 1)] else: try: monitor = monitors_mod.resolve( self._monitors, target, self._pet_monitor ) except ValueError as exc: return f"[pet] {exc}" self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…") return screen_text.read_monitor(monitor, limit) def _arm_self_restart(self, reason: str) -> str: """`petctl self_restart` — check the code, then arm a restart. Nothing restarts here. The tool result has to get back up the relay before this process can die (otherwise the server waits out its timeout on a turn that will never finish), so the restart is armed and `_maybe_self_restart` fires it once the turn has been spoken. The preflight import runs *now*, in this turn, so a syntax error Bolt just introduced comes back as something he can read and fix rather than as a pet that never comes back.""" if not config.SELF_RESTART: return "[pet] self-restart is disabled on this device (SELF_RESTART=false)" if self._restart_context is not None: return "[pet] a restart is already armed for the end of this turn" try: self_restart.check_loop_guard(self_restart.load()) self.log.emit("Self-restart requested — checking the code imports first…") self_restart.preflight() except self_restart.RestartError as exc: self.log.emit(f"Self-restart refused: {exc}") return f"[pet] restart refused — {exc}" recent = [entry.text[:120] for entry in self.history.entries()[-4:]] self._restart_context = self_restart.arm( reason or "no reason given", verify=reason, version=__version__, session=config.SESSION_ID, recent=recent, ) self.log.emit("Self-restart armed; it happens after this turn.") return ( "[pet] code imports cleanly; restarting as soon as this turn finishes. " "I'll come back and tell you what version I'm on and what I found — " "wrap up your reply now, the next thing you hear from me is the report." ) def _maybe_self_restart(self) -> bool: """Fire an armed restart, once the turn is over and the reply spoken. Returns True if a restart was requested, so the caller can stop driving the pipeline — the process is on its way out.""" if self._restart_context is None: return False self._update_pending = True # same latch the updater uses: no double restart self.log.emit("Restarting now.") self._state.force(PetState.IDLE) self.restart_requested.emit(f"self-restart: {self._restart_context.reason[:60]}") return True def _report_self_restart(self) -> None: """On the way up: tell the server we're back, and why we left. Runs once, before the listen loop starts, and only when a context file was left behind. The report goes through the ordinary conversation path, so Bolt's answer is spoken out loud like any other turn — which is what makes "restart and check the sprites load" finish as a sentence instead of a silence.""" context = self_restart.load() if context is None: return self_restart.clear() message = self_restart.report(context, version=__version__) self.log.emit(f"Back from a self-restart ({context.reason[:80]}).") self.history.add(history_mod.SYSTEM, message, time.time()) try: reply = server_client.converse(message, on_command=self._handle_command) except server_client.ServerError as exc: # The restart still worked; only the report failed. Say so locally # rather than pretending nothing happened. self.log.emit(f"Couldn't report the restart to the server: {exc}") return self._apply_voice(reply) if reply.text.strip() and not self._napping: self._speak(reply.text) self._state.force(PetState.IDLE) def _play_dialogue(self, scene: dict) -> str: """Play a `dialoguectl` scene and report back up the relay. This runs *mid-turn* (the server is still waiting on the tool result), so the pet has to look like it's talking and then go back to waiting — hence the TALKING → THINKING leg rather than the usual return to IDLE. Everything a normal reply gets, a scene gets too: the bubble, the transcript, and barge-in, so a long scene can be talked over exactly like a long answer.""" if not config.DIALOGUE: return "[dialogue] disabled on this device (DIALOGUE=false)" try: inputs = dialogue_mod.resolve( scene, voices=dialogue_mod.parse_voice_map(config.DIALOGUE_VOICES), self_voice=self._voice_id or config.ELEVENLABS_VOICE_ID, ) except dialogue_mod.DialogueError as exc: self.log.emit(f"dialoguectl: {exc}") return f"[dialogue] {exc}" text = dialogue_mod.spoken_text(scene) self.log.emit(f"Dialogue ({len(inputs)} lines): {text[:120]}") try: pcm, sample_rate = tts.synthesize_dialogue( inputs, model_id=scene.get("model"), stability=scene.get("stability") ) except tts.TtsError as exc: self.log.emit(f"Dialogue failed: {exc}") # Reported, not raised: the server can read this, shorten the # scene or fix the voice, and try again inside the same turn. return f"[dialogue] couldn't synthesize it: {exc}" resume = self._state.state self._state.transition(PetState.TALKING) self.said.emit(speech_text.for_display(text)) self.history.add(history_mod.PET, text, time.time()) should_stop = None if self._barge_in is not None: self._barge_in.reset() should_stop = self._barge_in.check completed = tts.play_pcm(pcm, sample_rate, should_stop=should_stop) if self._barge_in is not None: self._barge_in.reset() # the pet's own voices are in the wake window if resume in (PetState.THINKING, PetState.IDLE): self._state.transition(resume) if not completed: return dialogue_mod.describe(scene) + " (interrupted — they talked over it)" return dialogue_mod.describe(scene) def _apply_voice(self, reply) -> None: """Adopt (or drop) the voice the server tagged this reply with. The server's `speak_as` marker names an ElevenLabs voice it just picked — and it adds a Voice Library pick to the account first, so by the time the id gets here it's usable for TTS. It tags *one* reply, but the voice sticks by default: the server strips the marker before storing the turn, so it can't recall the id later, and "keep talking like that" would otherwise send it searching for a voice all over again. `VOICE_STICKY=false` makes each pick last exactly one reply. Untagged replies never *change* the voice — with stickiness on they just keep whatever's in use, which is what makes the rest of the conversation stay in the requested voice.""" voice_id = getattr(reply, "voice_id", "") if voice_id: if voice_id != self._voice_id: self._voice_id = voice_id self._voice_name = getattr(reply, "voice_name", "") or "" self.log.emit(f"Voice: {self.current_voice()}") self.voice_changed.emit(self.current_voice()) elif not config.VOICE_STICKY: self.reset_voice() def _speak(self, text: str, remember: bool = True) -> None: self._state.transition(PetState.TALKING) # Bubble gets the markdown stripped but emoji kept (it can't render # **bold** but draws emoji fine); tts.speak() does its own, stricter # sanitizing for the voice. self.said.emit(speech_text.for_display(text)) self.log.emit(f"Bolt: {text}") if remember: self.history.add(history_mod.PET, text, time.time()) should_stop = None if self._barge_in is not None: self._barge_in.reset() should_stop = self._barge_in.check completed = tts.speak( text, on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"), should_stop=should_stop, voice_id=self._voice_id or None, ) # Read the scoring history *before* resetting, or the log reports the # blank counters instead of what actually fired. detail = self._barge_in_detail() if self._barge_in is not None: # Playback fed the pet's own voice into the wake model's rolling # window. Clear it before the idle listener starts scoring again, # or Bolt's last sentence is still in there being re-scored. self._barge_in.reset() if not completed: # You talked over it — take that as the start of the next turn # rather than making you say the wake word again. self.log.emit(f"Interrupted — listening. {detail}") self._follow_ups = 0 # you're clearly engaged; start the count over self._talk_now.set() elif self._should_follow_up(text): self._follow_ups += 1 cap = config.FOLLOW_UP_MAX_TURNS self.log.emit( f"Asked a question — listening for your answer " f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})." ) # The tail of the reply we just played is still in the mic's ring # buffer, and we're about to start recording with a VAD that will # take it for the start of your answer — Bolt's own last words, # transcribed and sent back to him as if you'd said them. Nothing you # said can be in there: playback ran to completion, so if you had # spoken, barge-in would have cut it and taken the other branch. dropped = mic.flush(self._stream) if dropped: self.log.emit(f"Dropped {dropped} buffered frames of my own voice.") self._pending_follow_up = True self._talk_now.set() def _should_follow_up(self, text: str) -> bool: """Whether *text* leaves the pet waiting on an answer. Muted is excluded because mute means "don't listen to me" — an automatic turn would walk straight past it. Napping isn't: quiet hours suppress the pet *starting* something, and a question is only ever asked in reply to you.""" if not config.FOLLOW_UP_LISTEN or self._muted: return False if not speech_text.is_question(text): return False # Only worth mentioning the cap on a reply that would otherwise have # kept listening, or it fires on every statement the pet makes. if config.FOLLOW_UP_MAX_TURNS > 0 and self._follow_ups >= config.FOLLOW_UP_MAX_TURNS: self.log.emit("Follow-up limit reached — say the wake word to keep going.") return False return True def _barge_in_detail(self) -> str: """Why the interruption fired, for the log. How far into playback it happened is the tell: frame 1 means the detector was still holding audio from before this reply started, whereas a hit several seconds in is something the mic actually heard.""" detector = self._barge_in if isinstance(detector, barge_in.WakeWordBargeIn): return ( f"(wake score {detector.last_score:.3f} >= {detector.last_threshold:.2f}, " f"peak {detector.peak_score:.3f}, at frame {detector.frames_checked} / " f"{detector.seconds_checked:.1f}s into playback)" ) if isinstance(detector, barge_in.BargeInDetector): return f"(loud frames {detector.loud_frames}, threshold {config.BARGE_IN_RMS_THRESHOLD})" return "" # ── quiet hours / do-not-disturb ───────────────────────────────────── def _apply_nap_state(self, napping: bool) -> None: if napping == self._napping: return self._napping = napping self.log.emit("Napping — no proactive noise." if napping else "Awake.") self.napping.emit(napping) def _refresh_nap_state(self) -> None: if self._nap_forced is not None: self._apply_nap_state(self._nap_forced) return now = time.monotonic() if now - self._last_nap_check < _NAP_CHECK_INTERVAL_SECONDS: return self._last_nap_check = now napping = quiet.is_quiet( config.QUIET_HOURS, on_error=lambda exc: self.log.emit(f"QUIET_HOURS is malformed ({exc}) — ignoring it."), ) if not napping and config.DND_ON_FULLSCREEN: napping = screen_context.is_fullscreen_active() self._apply_nap_state(napping) # ── desktop notification bridge ────────────────────────────────────── def _start_notification_bridge(self) -> None: if not config.NOTIFICATION_BRIDGE: return watcher = notifications.NotificationWatcher(self._queue_notification) problem = watcher.start() if problem: self.log.emit(problem) return self._notification_watcher = watcher self.log.emit("Notification bridge on.") def _queue_notification(self, notification: notifications.Notification) -> None: """Called on the watcher thread — just queue it; forwarding happens on the pipeline thread where it can't collide with a live conversation.""" now = time.monotonic() if not self._notification_gate.should_forward(notification, now): return with self._notification_lock: if len(self._pending_notifications) == self._pending_notifications.maxlen: # Say so rather than dropping in silence: a full queue means the # bridge is matching more than the pet can plausibly speak, and # the filter is what wants tightening. self.log.emit("Notification queue full — dropping the oldest.") self._pending_notifications.append((now, notification)) def _drain_notifications(self) -> None: with self._notification_lock: pending = list(self._pending_notifications) self._pending_notifications.clear() now = time.monotonic() max_age = config.NOTIFICATION_MAX_AGE_SECONDS if max_age > 0: fresh = [entry for entry in pending if now - entry[0] <= max_age] if len(fresh) != len(pending): self.log.emit( f"Skipping {len(pending) - len(fresh)} notification(s) older than " f"{int(max_age)}s." ) pending = fresh for index, (_stamped, notification) in enumerate(pending): if not self._running or self._napping: # Put back what we haven't forwarded — the old code swapped the # queue out and then returned, silently dropping the remainder # the moment a nap started mid-drain. self._requeue_notifications(pending[index:]) return self.log.emit(f"Notification: {notification.as_text()}") self.history.add(history_mod.SYSTEM, notification.as_text(), time.time()) try: reply = server_client.converse( f"[desktop notification] {notification.as_text()}", on_command=self._handle_command, ) except server_client.ServerError as exc: # Keep this one and everything behind it for the next heartbeat: # the server being briefly down shouldn't silently eat the # backlog. The age limit is what stops that retrying forever. self.log.emit(f"Couldn't forward notification: {exc}") self._requeue_notifications(pending[index:]) return self._check_deliveries() self._apply_voice(reply) if reply.text.strip(): self._speak(reply.text) self._state.transition(PetState.IDLE) def _requeue_notifications(self, entries: list) -> None: """Push undelivered notifications back on the front, oldest first, so a retry keeps their original order (and their original timestamps, so a retry loop can't keep a stale one alive indefinitely).""" if not entries: return with self._notification_lock: self._pending_notifications.extendleft(reversed(entries)) # ── file delivery ──────────────────────────────────────────────────── def _check_deliveries(self) -> None: """Download anything the server has queued via deliver_files — called right after a conversation/notification turn (the common case: "send me that file") and once per heartbeat for anything queued out-of-band. Best-effort: a failure here is logged, not raised, so it can't sour a turn that already got its spoken reply.""" if not config.RECEIVE_FILES: return try: queued = server_client.list_outbox_files() except server_client.ServerError as exc: self.log.emit(f"Couldn't check for delivered files: {exc}") return for entry in queued: file_id = entry.get("id") name = entry.get("name") or file_id if not file_id: continue try: data = server_client.download_outbox_file(file_id) except server_client.ServerError as exc: self.log.emit(f"Couldn't download {name}: {exc}") continue path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data) self.log.emit(f"Received file: {path}") # ── auto-update ────────────────────────────────────────────────────── def _maybe_update(self) -> None: """Poll the Gitea releases page and, if there's a newer tag, apply it and ask the UI to restart. Only ever runs from the wake-listener's tick, so the pet is IDLE and between turns by construction — an update can't land mid-sentence. Failures are logged and the interval resets, so a server that's down (or a release that rolls back) costs one log line an hour, not a retry storm.""" if not config.AUTO_UPDATE or self._update_pending: return now = time.monotonic() if now - self._last_update_check < config.UPDATE_CHECK_INTERVAL_SECONDS: return self._last_update_check = now try: release = updater.check_for_update() except updater.UpdateError as exc: self.log.emit(f"Update check failed: {exc}") return if release is None: return self.log.emit(f"Update available: {release.tag} — applying.") try: previous = updater.apply_update(release.tag, on_log=self.log.emit) except updater.UpdateError as exc: self.log.emit(f"Update to {release.tag} failed: {exc}") return self._update_pending = True self.log.emit(f"Updated {previous} -> {release.tag}; restarting.") if not self._napping: # Napping means no proactive noise, so a silent restart it is. self._speak(f"Updating to {release.tag}. Back in a second.") self._state.transition(PetState.IDLE) self.restart_requested.emit(release.tag) # ── heartbeat ──────────────────────────────────────────────────────── def _maybe_heartbeat(self) -> None: self._refresh_nap_state() self._maybe_update() if self._update_pending: return # on the way out — don't start a conversation now now = time.monotonic() if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS: return self._last_heartbeat = now if self._state.state != PetState.IDLE: return if self._napping: return # quiet hours: still answers when spoken to, just doesn't start self._check_deliveries() self._drain_notifications() if self._state.state != PetState.IDLE: return try: announcement = server_client.report_status() except server_client.ServerError as exc: self.log.emit(f"Heartbeat failed: {exc}") return if announcement: self._speak(announcement) self._state.transition(PetState.IDLE)