264 lines
16 KiB
Python
264 lines
16 KiB
Python
"""Environment configuration for the Bolt desktop pet.
|
||
|
||
Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py
|
||
in the main tmn-api repo, so this project can be copied anywhere (it does not
|
||
import anything from that repo) and configured the same way.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import platform
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/)
|
||
|
||
|
||
def _load_env() -> None:
|
||
env_path = HERE / ".env"
|
||
if not env_path.exists():
|
||
return
|
||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, _, value = line.partition("=")
|
||
os.environ.setdefault(key.strip(), value.strip())
|
||
|
||
|
||
_load_env()
|
||
|
||
|
||
def _node_name() -> str:
|
||
try:
|
||
return platform.node() or "desktop"
|
||
except Exception:
|
||
return "desktop"
|
||
|
||
|
||
# ── server / identity ───────────────────────────────────────────────────────
|
||
|
||
SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/")
|
||
API_KEY = os.environ.get("DESK_API_KEY", "")
|
||
SESSION_ID = os.environ.get("DESK_SESSION_ID", "pet-" + _node_name())
|
||
|
||
# ── wake word ────────────────────────────────────────────────────────────────
|
||
# openWakeWord model trained specifically for "thunderbolt" — same pattern as
|
||
# desk_client/bolt_desk.py's WAKE_MODEL_FILE (bolt.onnx / "hey bolt") in the
|
||
# main repo, resolved relative to the project root so it sits next to .env.
|
||
|
||
WAKE_MODEL_PATH = str(HERE / os.environ.get("WAKE_MODEL_FILE", "thunderbolt.onnx"))
|
||
WAKE_WORD_THRESHOLD = float(os.environ.get("WAKE_WORD_THRESHOLD", "0.5"))
|
||
# How often (independent of prediction, which runs every frame) the wake
|
||
# listener yields control back to its caller via on_tick — e.g. the
|
||
# heartbeat poll in controller.py during quiet stretches with no wake word.
|
||
WAKE_CHECK_INTERVAL_SECONDS = float(os.environ.get("WAKE_CHECK_INTERVAL_SECONDS", "1.2"))
|
||
|
||
# ── STT (Deepgram, same as bolt_desk.py) ────────────────────────────────────
|
||
|
||
DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY", "")
|
||
DEEPGRAM_MODEL = os.environ.get("DEEPGRAM_MODEL", "nova-3")
|
||
|
||
# ── TTS (ElevenLabs, requested as raw PCM so playback needs no external
|
||
# player binary — cross-platform via sounddevice instead of shelling out to
|
||
# mpv/ffplay like bolt_desk.py does on Linux) ───────────────────────────────
|
||
|
||
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
|
||
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
|
||
ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||
# ElevenLabs PCM output formats are named pcm_<sample_rate>.
|
||
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
|
||
|
||
# ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
|
||
|
||
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
|
||
SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None
|
||
|
||
RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
||
SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2"))
|
||
MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15"))
|
||
MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
|
||
# How long to wait for you to *start* talking before giving up on a turn.
|
||
GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
|
||
|
||
# ── follow-up listening ─────────────────────────────────────────────────────
|
||
# When a reply ends on a question, the pet keeps listening for the answer
|
||
# instead of dropping back to idle and making you say the wake word again.
|
||
# The grace period is longer than a normal turn's because you were asked
|
||
# something and may need a beat to think. FOLLOW_UP_MAX_TURNS caps how many
|
||
# question-and-answer rounds can chain without you re-triggering it — a stop
|
||
# on runaway loops if the server ends every reply with a question and the mic
|
||
# keeps feeding it noise. 0 means no cap.
|
||
|
||
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
|
||
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "3"))
|
||
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
||
|
||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||
|
||
# ── sudo password prompts ───────────────────────────────────────────────────
|
||
# The pet has no terminal, so a relayed `sudo` would block on a tty nobody is
|
||
# watching. With this on, bare `sudo` is rewritten to `sudo -A` and the
|
||
# password is collected in a desktop dialog (a real askpass binary if one is
|
||
# installed, otherwise a generated zenity/kdialog wrapper). Turn it off and
|
||
# sudo commands simply fail, which is the safer default if you'd rather Bolt
|
||
# never be able to ask for root at all.
|
||
SUDO_ASKPASS_PROMPT = os.environ.get("SUDO_ASKPASS_PROMPT", "true").lower() in ("1", "true", "yes", "on")
|
||
SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-detect
|
||
# Longer than COMMAND_TIMEOUT_SECONDS because a person has to notice the
|
||
# dialog, read it, and type — 30s is nowhere near enough for that.
|
||
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
|
||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||
|
||
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
||
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
|
||
# as an interruption:
|
||
# wake — only the wake word cuts playback (default). Immune to coughs,
|
||
# doors, and the TV, at the cost of ~a word of extra latency.
|
||
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster,
|
||
# but interrupts on background noise. That threshold is well above
|
||
# VAD_RMS_THRESHOLD because the mic also hears the pet's own voice
|
||
# through the speakers — raise it further if playback keeps
|
||
# interrupting itself.
|
||
# Set BARGE_IN=false to make playback uninterruptible either way.
|
||
|
||
BARGE_IN = os.environ.get("BARGE_IN", "true").lower() in ("1", "true", "yes", "on")
|
||
BARGE_IN_MODE = os.environ.get("BARGE_IN_MODE", "wake")
|
||
BARGE_IN_RMS_THRESHOLD = int(os.environ.get("BARGE_IN_RMS_THRESHOLD", str(RMS_THRESHOLD * 4)))
|
||
BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive loud frames (80ms each)
|
||
# Wake-mode sensitivity. Blank means "track the live WAKE_WORD_THRESHOLD from
|
||
# the tray tuner"; set a number to make interrupting deliberately harder than
|
||
# waking the pet from idle (useful if Bolt's own voice trips the model).
|
||
BARGE_IN_WAKE_THRESHOLD = float(os.environ.get("BARGE_IN_WAKE_THRESHOLD") or 0) or None
|
||
|
||
# ── streaming TTS ───────────────────────────────────────────────────────────
|
||
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
|
||
# after the first PCM chunk instead of after the whole clip is synthesized.
|
||
|
||
TTS_STREAMING = os.environ.get("TTS_STREAMING", "true").lower() in ("1", "true", "yes", "on")
|
||
|
||
# ── screen context ──────────────────────────────────────────────────────────
|
||
# Appends the active window's title to what you say, so "what's this error?"
|
||
# has a referent. The desk API takes text only, so this is a text annotation
|
||
# (no screenshot upload).
|
||
|
||
SCREEN_CONTEXT = os.environ.get("SCREEN_CONTEXT", "true").lower() in ("1", "true", "yes", "on")
|
||
|
||
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
|
||
# Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While
|
||
# napping the pet dims, stops wandering, and makes no proactive noise —
|
||
# wake word and click-to-talk still work.
|
||
|
||
QUIET_HOURS = os.environ.get("QUIET_HOURS", "")
|
||
DND_ON_FULLSCREEN = os.environ.get("DND_ON_FULLSCREEN", "true").lower() in ("1", "true", "yes", "on")
|
||
|
||
# ── desktop notification bridge ─────────────────────────────────────────────
|
||
# Mirrors desktop notifications to the server so Bolt can react to them.
|
||
# Off by default: every forwarded notification costs a converse() round trip.
|
||
|
||
NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in ("1", "true", "yes", "on")
|
||
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
||
|
||
# ── file delivery ────────────────────────────────────────────────────────
|
||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||
# queues workspace files on this session — e.g. "send me that report" — for
|
||
# the client to fetch via GET /desk/files. Downloading a file dequeues it
|
||
# server-side, so each one lands here exactly once.
|
||
|
||
RECEIVE_FILES = os.environ.get("RECEIVE_FILES", "true").lower() in ("1", "true", "yes", "on")
|
||
DELIVERED_FILES_DIR = Path(
|
||
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
|
||
).expanduser()
|
||
|
||
# ── conversation history ────────────────────────────────────────────────────
|
||
|
||
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))
|
||
|
||
# ── push-to-talk ────────────────────────────────────────────────────────────
|
||
# Global hotkey (needs `pynput`; unavailable on most Wayland sessions, in
|
||
# which case it logs once and the wake word / tray still work). Empty to
|
||
# disable.
|
||
|
||
PUSH_TO_TALK_HOTKEY = os.environ.get("PUSH_TO_TALK_HOTKEY", "ctrl+alt+space")
|
||
|
||
# ── wake-word tuning ────────────────────────────────────────────────────────
|
||
# Scores this far below the threshold are recorded as "near misses" and shown
|
||
# in the tray's wake-word tuner, so the threshold can be set from evidence.
|
||
|
||
WAKE_NEAR_MISS_MARGIN = float(os.environ.get("WAKE_NEAR_MISS_MARGIN", "0.2"))
|
||
WAKE_NEAR_MISS_LIMIT = int(os.environ.get("WAKE_NEAR_MISS_LIMIT", "40"))
|
||
|
||
# ── auto-update ─────────────────────────────────────────────────────────────
|
||
# Watches the Gitea releases API for a tag newer than bolt_pet.__version__,
|
||
# then `git checkout`s it in place and restarts (see updater.py). The install
|
||
# has to be a git clone with a clean working tree — a dirty tree is skipped
|
||
# rather than stashed, so local edits are never thrown away. Any failure
|
||
# after checkout rolls back to the ref that was checked out before.
|
||
|
||
AUTO_UPDATE = os.environ.get("AUTO_UPDATE", "true").lower() in ("1", "true", "yes", "on")
|
||
UPDATE_REPO_API = os.environ.get(
|
||
"UPDATE_REPO_API",
|
||
"https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet",
|
||
).rstrip("/")
|
||
UPDATE_CHECK_INTERVAL_SECONDS = float(os.environ.get("UPDATE_CHECK_INTERVAL_SECONDS", "3600"))
|
||
UPDATE_GIT_REMOTE = os.environ.get("UPDATE_GIT_REMOTE", "origin")
|
||
# Only needed if the repo is private — releases on a public repo read fine
|
||
# anonymously. A Gitea access token with read:repository.
|
||
UPDATE_TOKEN = os.environ.get("UPDATE_TOKEN", "")
|
||
# Reinstall requirements.txt when an update changes it. Off means a release
|
||
# that adds a dependency will roll straight back on the import smoke test.
|
||
UPDATE_INSTALL_DEPS = os.environ.get("UPDATE_INSTALL_DEPS", "true").lower() in ("1", "true", "yes", "on")
|
||
|
||
SAMPLE_RATE = 16000 # mic capture / STT rate
|
||
FRAME_LEN = 1280 # 80ms @ 16kHz — matches bolt_desk.py's chunking
|
||
|
||
# ── pet window ───────────────────────────────────────────────────────────────
|
||
|
||
PET_SIZE = int(os.environ.get("PET_SIZE", "160")) # on-screen pixel size (square)
|
||
PET_START_X = os.environ.get("PET_START_X", "") # blank = bottom-right of primary screen
|
||
PET_START_Y = os.environ.get("PET_START_Y", "")
|
||
PET_ALWAYS_ON_TOP = os.environ.get("PET_ALWAYS_ON_TOP", "true").lower() in ("1", "true", "yes", "on")
|
||
IDLE_ANIMATION_FPS = float(os.environ.get("IDLE_ANIMATION_FPS", "6"))
|
||
|
||
# ── wandering ────────────────────────────────────────────────────────────────
|
||
# The pet strolls to a random spot on its own while idle. Only ever moves when
|
||
# it's IDLE and not speaking/being dragged, so it never walks out from under a
|
||
# speech bubble mid-sentence.
|
||
|
||
PET_WANDER = os.environ.get("PET_WANDER", "true").lower() in ("1", "true", "yes", "on")
|
||
# Average seconds of standing still between strolls (each wait is randomized
|
||
# to 0.5x–1.5x this, so the pet doesn't move on an obvious metronome).
|
||
PET_WANDER_INTERVAL_SECONDS = float(os.environ.get("PET_WANDER_INTERVAL_SECONDS", "45"))
|
||
PET_WANDER_SPEED = float(os.environ.get("PET_WANDER_SPEED", "90")) # pixels/second
|
||
# Cap on how far one stroll can be, so it doesn't teleport across a 4K screen.
|
||
PET_WANDER_MAX_DISTANCE = float(os.environ.get("PET_WANDER_MAX_DISTANCE", "600"))
|
||
PET_WANDER_MARGIN = int(os.environ.get("PET_WANDER_MARGIN", "20")) # keep off screen edges
|
||
|
||
# ── mouse behaviour ─────────────────────────────────────────────────────────
|
||
# The sprite is drawn in a square translucent window, so its transparent
|
||
# corners would otherwise swallow clicks meant for whatever is underneath.
|
||
# PET_SHAPED_INPUT masks the window's input region to the sprite's own opaque
|
||
# pixels; PET_CLICK_THROUGH goes further and makes the whole pet ignore the
|
||
# mouse (tray-only control until you turn it back off).
|
||
|
||
PET_SHAPED_INPUT = os.environ.get("PET_SHAPED_INPUT", "true").lower() in ("1", "true", "yes", "on")
|
||
PET_CLICK_THROUGH = os.environ.get("PET_CLICK_THROUGH", "false").lower() in ("1", "true", "yes", "on")
|
||
# Snap flush to a screen edge when dropped/parked within this many pixels of it.
|
||
PET_EDGE_SNAP = os.environ.get("PET_EDGE_SNAP", "true").lower() in ("1", "true", "yes", "on")
|
||
PET_SNAP_MARGIN = int(os.environ.get("PET_SNAP_MARGIN", "48"))
|
||
|
||
|
||
def is_configured() -> bool:
|
||
return bool(SERVER_URL and API_KEY)
|
||
|
||
|
||
def missing_config() -> list[str]:
|
||
missing = []
|
||
if not SERVER_URL:
|
||
missing.append("BOLT_SERVER_URL")
|
||
if not API_KEY:
|
||
missing.append("DESK_API_KEY")
|
||
return missing
|