Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3977804ae3 | ||
|
|
54030a7b31 | ||
|
|
ad9925da02 | ||
|
|
2a2cf38399 | ||
|
|
3a0959f55d | ||
|
|
c4e805defd | ||
|
|
96afc351ac | ||
|
|
5b49670983 | ||
|
|
311fe8b709 | ||
|
|
b121bbba17 | ||
|
|
ccb3aeb7ae | ||
|
|
c16fada8d8 |
@@ -30,7 +30,66 @@
|
|||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(git remote *)",
|
"Bash(git remote *)",
|
||||||
"Bash(grep -v '^$')",
|
"Bash(grep -v '^$')",
|
||||||
"Bash(.venv/bin/pip install *)"
|
"Bash(.venv/bin/pip install *)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python *)",
|
||||||
|
"Bash(python3 *)",
|
||||||
|
"Bash(.venv/bin/pytest tests/test_monitors.py -q)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_pet_window_features.py -q)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git switch *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(echo \"=== EXIT: $? ===\")",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest tests/ -q)",
|
||||||
|
"Bash(.venv/bin/pytest tests/test_desk_api.py tests/test_desk_files.py tests/test_desk_voice.py tests/test_desk_status.py tests/test_desk_keys.py tests/test_desk_guild_action.py tests/test_desk_admin.py tests/test_desk_billing_auth.py -q)",
|
||||||
|
"Bash(docker inspect *)",
|
||||||
|
"Bash(python3 -m json.tool)",
|
||||||
|
"Bash(docker restart bolt *)",
|
||||||
|
"Bash(curl -s -m 5 \"http://localhost:5002/desk/health\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)",
|
||||||
|
"Bash(docker exec bolt *)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)",
|
||||||
|
"Bash(grep -rn *)",
|
||||||
|
"Bash(git ls-tree *)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_controller_features.py -q)",
|
||||||
|
"Read(//home/maji/Documents/tmn-api/**)",
|
||||||
|
"Bash(timeout 300 .venv/bin/python -m pytest tests/test_desk_voice.py -q)",
|
||||||
|
"Bash(echo \"exit=$?\")",
|
||||||
|
"Bash(timeout 300 /home/maji/Documents/tmn-api/.venv/bin/python -m pytest /home/maji/Documents/tmn-api/tests/test_desk_voice.py -q -p no:cacheprovider --rootdir=/home/maji/Documents/tmn-api)",
|
||||||
|
"Bash(echo \"EXIT=$?\")",
|
||||||
|
"Bash(/home/maji/Documents/tmn-api/.venv/bin/python -c \"import ast,pathlib; ast.parse\\(pathlib.Path\\('/home/maji/Documents/tmn-api/ai/desk_api.py'\\).read_text\\(\\)\\); print\\('desk_api.py parses OK'\\)\")",
|
||||||
|
"Bash(ps -eo pid,etime,cmd)",
|
||||||
|
"Bash(systemctl --user list-units --type=service)",
|
||||||
|
"Read(//run/user/1000/gvfs/sftp:host=192.168.2.231,user=root/Main/Docker-Compose/TMN-API/tmn-api/ai/**)",
|
||||||
|
"Bash(findmnt -T /home/maji/Documents/tmn-api -o TARGET,SOURCE,FSTYPE)",
|
||||||
|
"Bash(echo \"rc=$?\")",
|
||||||
|
"Bash(timeout 600 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 600 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(awk 'NR>=1150 && NR<=1310 && \\(/return / || /def /\\)' ai/agents/default.py)",
|
||||||
|
"Bash(/home/maji/Documents/Bolt-Pet/.venv/bin/python -c ' *)",
|
||||||
|
"Bash(timeout 900 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py tests/test_proactive.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 300 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_proactive.py::test_send_trims_swallowed_tool_lines -q -p no:cacheprovider)",
|
||||||
|
"Bash(/home/maji/Documents/Bolt-Pet/.venv/bin/python *)",
|
||||||
|
"Bash(./tmnvenv/bin/pip install *)",
|
||||||
|
"Bash(./tmnvenv/bin/python -c \"import pytest,dotenv,yaml; print\\('scratch venv ready'\\)\")",
|
||||||
|
"Bash(/tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/pip install *)",
|
||||||
|
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py)",
|
||||||
|
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py --ignore=tests/test_memory_store.py --ignore=tests/test_default_agent.py)",
|
||||||
|
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_default_agent.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_emotional_memory.py tests/test_inner_monologue.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_emotional_memory.py tests/test_inner_monologue.py tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py tests/test_proactive.py tests/test_desk_api.py tests/test_main_helpers.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py --ignore=tests/test_memory_store.py --ignore=tests/test_default_agent.py --ignore=tests/test_billing_web.py)",
|
||||||
|
"WebFetch(domain:elevenlabs.io)",
|
||||||
|
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_dialogue.py -q)",
|
||||||
|
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_system_index.py tests/test_initiative.py tests/test_self_experiments.py -q -p no:cacheprovider)",
|
||||||
|
"Bash($V *)",
|
||||||
|
"Bash(dig +short themajesticnetwork.com)",
|
||||||
|
"Bash(dig +short api.themajesticnetwork.com)",
|
||||||
|
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_site.py -q -p no:cacheprovider)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'HTTP %{http_code} bytes=%{size_download}\\\\n' -m 15 -H 'X-Forwarded-For: 1.2.3.4' -H 'X-Real-IP: 1.2.3.4' -A 'Mozilla/5.0 \\(X11; Linux x86_64\\) Firefox/152.0' https://themajesticnetwork.com/?claude-probe-__TRACKED_VAR__)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,34 @@
|
|||||||
# Copy this file to .env and fill in your values.
|
# Bolt Console — copy to .env and fill in the first two.
|
||||||
|
|
||||||
# ── Bolt server (required) ──────────────────────────────────────────────────
|
# Your Bolt server and its desk API key (required).
|
||||||
# The same server URL + key desk_client/bolt_desk.py and the Android app use.
|
BOLT_SERVER_URL=https://your-bolt-server.example.com
|
||||||
# DESK_API_KEY is the master key from the server's own .env, or a per-user
|
|
||||||
# key minted via the api_key_generate desk marker (see the main repo's
|
|
||||||
# CLAUDE.md "Per-user API keys" section).
|
|
||||||
BOLT_SERVER_URL=http://your-server:5002
|
|
||||||
DESK_API_KEY=
|
DESK_API_KEY=
|
||||||
|
|
||||||
# Unique per machine/install so sessions don't collide. Defaults to
|
# Conversation id on the server. Default: pet-console-<hostname>. Keep the
|
||||||
# "pet-<hostname>" if unset.
|
# "pet-" prefix: the server offers the filectl file tool to pet sessions.
|
||||||
#DESK_SESSION_ID=pet-my-desktop
|
#DESK_SESSION_ID=
|
||||||
|
|
||||||
# ── Wake word ────────────────────────────────────────────────────────────────
|
# ask = confirm shell commands, file writes and edits (reads never ask)
|
||||||
# openWakeWord model trained for "thunderbolt" — thunderbolt.onnx ships in
|
# auto = run everything without asking
|
||||||
# the project root next to this file. Point WAKE_MODEL_FILE at a different
|
#APPROVAL_MODE=ask
|
||||||
# .onnx model to change the phrase (same convention as desk_client's
|
|
||||||
# WAKE_MODEL_FILE / bolt.onnx in the main repo).
|
|
||||||
#WAKE_MODEL_FILE=thunderbolt.onnx
|
|
||||||
#WAKE_WORD_THRESHOLD=0.5
|
|
||||||
#WAKE_CHECK_INTERVAL_SECONDS=1.2
|
|
||||||
|
|
||||||
# ── STT (Deepgram) ──────────────────────────────────────────────────────────
|
# Seconds before a relayed shell command is killed.
|
||||||
DEEPGRAM_API_KEY=
|
#COMMAND_TIMEOUT_SECONDS=120
|
||||||
#DEEPGRAM_MODEL=nova-3
|
|
||||||
|
|
||||||
# ── TTS (ElevenLabs) — omit to use offline TTS only ─────────────────────────
|
# Print sentences as they stream in (falls back automatically if unsupported).
|
||||||
ELEVENLABS_API_KEY=
|
#STREAMING_REPLIES=true
|
||||||
ELEVENLABS_VOICE_ID=
|
|
||||||
#ELEVENLABS_MODEL_ID=eleven_flash_v2
|
|
||||||
#TTS_SAMPLE_RATE=24000
|
|
||||||
|
|
||||||
# ── Audio devices (optional — leave blank for the system default) ──────────
|
# Tell Bolt the working directory and OS with each message.
|
||||||
|
#CONSOLE_CONTEXT=true
|
||||||
|
|
||||||
|
# Files Bolt sends you with deliver_files land here.
|
||||||
|
#RECEIVE_FILES=true
|
||||||
|
#DELIVERED_FILES_DIR=~/Downloads/Bolt
|
||||||
|
|
||||||
|
# Dictation (mic button / Ctrl+M): input device index or name (blank = default),
|
||||||
|
# and how long a pause ends a recording once you've said something.
|
||||||
#MIC_DEVICE=
|
#MIC_DEVICE=
|
||||||
#SPEAKER_DEVICE=
|
#DICTATION_SILENCE_SECONDS=3
|
||||||
|
|
||||||
# ── VAD tuning (optional) ───────────────────────────────────────────────────
|
# ANSI colour (NO_COLOR in the environment also turns it off).
|
||||||
#VAD_RMS_THRESHOLD=300
|
#COLOR=true
|
||||||
#VAD_SILENCE_END_SEC=1.2
|
|
||||||
#VAD_MAX_UTTERANCE_SECONDS=15
|
|
||||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
|
||||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
|
||||||
|
|
||||||
# ── Follow-up listening (optional) ──────────────────────────────────────────
|
|
||||||
# When a reply ends on a question, the pet keeps listening for your answer
|
|
||||||
# instead of dropping back to idle and making you say the wake word again.
|
|
||||||
# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain
|
|
||||||
# without you re-triggering it (0 = no cap) — a stop on runaway loops if the
|
|
||||||
# server ends every reply with "?" and the mic keeps feeding it noise.
|
|
||||||
#FOLLOW_UP_LISTEN=true
|
|
||||||
#FOLLOW_UP_MAX_TURNS=3
|
|
||||||
#FOLLOW_UP_GRACE_SECONDS=7 # longer than VAD_GRACE_SECONDS: you were asked something
|
|
||||||
|
|
||||||
# ── Pet window (optional) ───────────────────────────────────────────────────
|
|
||||||
#PET_SIZE=160
|
|
||||||
#PET_START_X=
|
|
||||||
#PET_START_Y=
|
|
||||||
#PET_ALWAYS_ON_TOP=true
|
|
||||||
#IDLE_ANIMATION_FPS=6
|
|
||||||
|
|
||||||
# ── Wandering (optional) — the pet strolls to a random spot while idle ───────
|
|
||||||
#PET_WANDER=true
|
|
||||||
#PET_WANDER_INTERVAL_SECONDS=45 # average pause between strolls (randomized 0.5x-1.5x)
|
|
||||||
#PET_WANDER_SPEED=90 # pixels per second
|
|
||||||
#PET_WANDER_MAX_DISTANCE=600 # cap on a single stroll's length
|
|
||||||
#PET_WANDER_MARGIN=20 # keep this far off the screen edges
|
|
||||||
|
|
||||||
# ── Mouse behaviour (optional) ──────────────────────────────────────────────
|
|
||||||
# Shaped input = the square window's transparent corners stop eating clicks.
|
|
||||||
# Click-through = the pet ignores the mouse entirely (control it from the tray).
|
|
||||||
#PET_SHAPED_INPUT=true
|
|
||||||
#PET_CLICK_THROUGH=false
|
|
||||||
#PET_EDGE_SNAP=true
|
|
||||||
#PET_SNAP_MARGIN=48
|
|
||||||
|
|
||||||
# ── Barge-in (optional) — interrupt the pet mid-sentence ────────────────────
|
|
||||||
# BARGE_IN_MODE decides what counts as an interruption:
|
|
||||||
# wake — only the wake word cuts playback (default). Background noise,
|
|
||||||
# coughs and the TV can't stop it mid-sentence.
|
|
||||||
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster to
|
|
||||||
# trigger, but interrupts on anything loud. That threshold defaults
|
|
||||||
# to 4x VAD_RMS_THRESHOLD because the mic also hears the pet's own
|
|
||||||
# voice out of the speakers; raise it if playback self-interrupts.
|
|
||||||
#BARGE_IN=true
|
|
||||||
#BARGE_IN_MODE=wake
|
|
||||||
#BARGE_IN_RMS_THRESHOLD=1200 # energy mode only
|
|
||||||
#BARGE_IN_FRAMES=4 # energy mode only
|
|
||||||
# Wake mode only. Blank tracks the live WAKE_WORD_THRESHOLD (tray tuner);
|
|
||||||
# set a number to make interrupting harder than waking the pet from idle,
|
|
||||||
# e.g. if Bolt's own voice occasionally trips the model.
|
|
||||||
#BARGE_IN_WAKE_THRESHOLD=0.6
|
|
||||||
|
|
||||||
# ── Auto-update (optional) ──────────────────────────────────────────────────
|
|
||||||
# Watches the Gitea releases page for a tag newer than bolt_pet.__version__,
|
|
||||||
# then `git checkout`s it and restarts — only ever between turns, never
|
|
||||||
# mid-conversation. Requires the install to be a git clone; a working tree
|
|
||||||
# with local changes is skipped (never stashed), and any failure after the
|
|
||||||
# checkout rolls back to the ref that was live before.
|
|
||||||
#AUTO_UPDATE=true
|
|
||||||
#UPDATE_REPO_API=https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet
|
|
||||||
#UPDATE_CHECK_INTERVAL_SECONDS=3600
|
|
||||||
#UPDATE_GIT_REMOTE=origin
|
|
||||||
#UPDATE_INSTALL_DEPS=true
|
|
||||||
# Only needed if the repo is private (a Gitea token with read:repository).
|
|
||||||
#UPDATE_TOKEN=
|
|
||||||
|
|
||||||
# ── Streaming TTS (optional) — starts talking on the first chunk ────────────
|
|
||||||
#TTS_STREAMING=true
|
|
||||||
|
|
||||||
# ── Screen context (optional) ───────────────────────────────────────────────
|
|
||||||
# Sends the focused window's title along with what you said, so "what's this
|
|
||||||
# error?" has a referent. Text only — no screenshots leave the machine.
|
|
||||||
#SCREEN_CONTEXT=true
|
|
||||||
|
|
||||||
# ── Quiet hours / do-not-disturb (optional) ────────────────────────────────
|
|
||||||
# Comma-separated HH:MM-HH:MM ranges; wrapping past midnight is fine. While
|
|
||||||
# napping the pet dims, stops wandering and makes no proactive noise — the
|
|
||||||
# wake word and click-to-talk still work.
|
|
||||||
#QUIET_HOURS=23:00-08:00
|
|
||||||
#DND_ON_FULLSCREEN=true
|
|
||||||
|
|
||||||
# ── Desktop notification bridge (optional, Linux/D-Bus) ────────────────────
|
|
||||||
# Forwards matching desktop notifications to the server so Bolt can react to
|
|
||||||
# them. Off by default: each forwarded notification is a converse() round
|
|
||||||
# trip. NOTIFICATION_FILTER is a regex over "<app>: <summary> <body>".
|
|
||||||
#NOTIFICATION_BRIDGE=false
|
|
||||||
#NOTIFICATION_FILTER=build|deploy|calendar
|
|
||||||
#NOTIFICATION_MIN_INTERVAL_SECONDS=60
|
|
||||||
|
|
||||||
# ── Push-to-talk (optional) ─────────────────────────────────────────────────
|
|
||||||
# Global hotkey; needs pynput and a session that allows global key hooks
|
|
||||||
# (most Wayland sessions don't). Leave blank to disable.
|
|
||||||
#PUSH_TO_TALK_HOTKEY=ctrl+alt+space
|
|
||||||
|
|
||||||
# ── Wake-word tuning (optional) ─────────────────────────────────────────────
|
|
||||||
# Scores within this margin below the threshold show up as "near misses" in
|
|
||||||
# the tray's wake-word tuner.
|
|
||||||
#WAKE_NEAR_MISS_MARGIN=0.2
|
|
||||||
#WAKE_NEAR_MISS_LIMIT=40
|
|
||||||
|
|
||||||
# ── sudo password prompts (optional) ────────────────────────────────────────
|
|
||||||
# The pet has no terminal, so a server-relayed `sudo` would block forever on
|
|
||||||
# a tty nobody is watching. With this on, bare `sudo` becomes `sudo -A` and
|
|
||||||
# the password is collected in a desktop dialog you have to answer — a real
|
|
||||||
# askpass binary if one is installed, otherwise a generated zenity/kdialog
|
|
||||||
# wrapper in ~/.cache/bolt-pet/askpass.sh.
|
|
||||||
# Set it to false if you'd rather Bolt never be able to ask for root: sudo
|
|
||||||
# commands then just fail. Read the dialogs — that box is the only thing
|
|
||||||
# between "Bolt decided to run sudo" and it running.
|
|
||||||
#SUDO_ASKPASS_PROMPT=true
|
|
||||||
#SUDO_ASKPASS_HELPER= # blank = auto-detect
|
|
||||||
#SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer
|
|
||||||
|
|
||||||
# ── Misc (optional) ──────────────────────────────────────────────────────────
|
|
||||||
#COMMAND_TIMEOUT_SECONDS=30
|
|
||||||
#HEARTBEAT_INTERVAL_SECONDS=60
|
|
||||||
#HISTORY_LIMIT=100
|
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# PyInstaller output (build_bolt_console.py) and packaged releases
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
Release/
|
||||||
|
*.spec
|
||||||
|
|||||||
@@ -4,267 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A desktop pet (PySide6 window) that is a voice/click UI on top of an external
|
A PySide6 desktop app (default) and a terminal REPL (`--cli`), both Claude Code-style, on top of an
|
||||||
Bolt server's desk API — same brain, memory, tools, and persona as that
|
external Bolt server's desk API. It uses the same
|
||||||
server's Discord bot and Linux desk client. This repo has **no import
|
brain, memory, tools and persona as the server's Discord bot and its other desk clients. There is
|
||||||
dependency** on the server repo; it's a standalone HTTP client configured via
|
**no import dependency** on the server repo: it's a standalone HTTP client configured through its
|
||||||
its own `.env`.
|
own `.env`. It was forked from Bolt-Pet with the desktop pet, wake word, STT/TTS, screen
|
||||||
|
context/OCR, notifications, auto-updater and self-restart removed.
|
||||||
|
|
||||||
Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk /
|
Pipeline: `typed line → + cwd/OS note → POST /desk/converse_stream (or /desk/converse) → [server
|
||||||
click → record utterance → Deepgram STT → + active-window context → POST
|
relays a command: filectl file op, or a shell command, after y/n approval] → /desk/tool_result →
|
||||||
/desk/converse → [server may relay a shell command to run on this machine, or
|
… → reply printed`.
|
||||||
a `petctl` pseudo-command that moves/emotes the pet instead] → reply →
|
|
||||||
ElevenLabs streaming TTS (or offline pyttsx3 fallback) → speakers`, with the
|
|
||||||
pet sprite/speech bubble reflecting state throughout, and playback
|
|
||||||
interruptible by talking over it (barge-in).
|
|
||||||
|
|
||||||
Side channels that let the pet act between turns: the heartbeat (proactive
|
|
||||||
announcements), the desktop notification bridge, and autonomous wandering —
|
|
||||||
all suppressed while it's napping (quiet hours / fullscreen DND).
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup + run (creates .venv and installs requirements.txt on first run)
|
run.bat / run-cli.bat / ./run.sh [--cli] # window / terminal; creates .venv; keeps the caller's cwd
|
||||||
./run.sh # macOS/Linux
|
python -m bolt_console --doctor [--deep]
|
||||||
run.bat # Windows
|
python -m bolt_console -p "message" # one-shot
|
||||||
|
.venv/Scripts/python -m pytest tests # (.venv/bin on Linux/macOS)
|
||||||
# Run tests (no pytest config file — tests self-insert repo root via sys.path).
|
|
||||||
# QT_QPA_PLATFORM=offscreen avoids a QApplication segfault on headless/no-display hosts.
|
|
||||||
QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/
|
|
||||||
.venv/bin/pytest tests/test_state.py::test_happy_path_transitions # single test
|
|
||||||
|
|
||||||
# Convert a grid sprite sheet into the per-frame-PNG convention sprite.py expects
|
|
||||||
python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no lint/build step configured beyond pytest. `cp .env.example .env`
|
Runtime dependencies: `requests`, plus `PySide6` for the window only. `bolt_console.gui` is imported
|
||||||
and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
|
lazily, so `--cli` works without Qt. The local venv is Python 3.9, so no `match`, no
|
||||||
`ELEVENLABS_API_KEY`) before running — without server config the controller
|
runtime `X | Y` unions, and no builtin generics outside annotations.
|
||||||
logs a missing-config message and exits its thread instead of starting.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture (`bolt_console/`)
|
||||||
|
|
||||||
- **`config.py`** — loads `.env` from the project root (not via python-dotenv;
|
- **`config.py`**: a small hand-written `.env` parser feeding module constants. Nothing else reads
|
||||||
a small hand-rolled parser matching the server repo's `desk_client/bolt_desk.py`
|
`os.environ`. `SESSION_ID` defaults to `pet-console-<host>`. The `pet-` prefix is deliberate: the
|
||||||
convention) into module-level constants. Everything else reads config from
|
server advertises `filectl` to pet sessions, and `-console` keeps this a separate conversation
|
||||||
here, never `os.environ` directly.
|
from a Bolt-Pet on the same machine. `new_session()` (New chat / `/new`) rotates to
|
||||||
- **`state.py`** — `PetStateMachine`, pure logic with no Qt/audio imports (kept
|
`<base>-<timestamp>` and remembers it in `~/.cache/bolt-console/session.json`. `DESK_SESSION_ID`
|
||||||
that way deliberately for cheap unit testing). Enforces a transition table;
|
in `.env` overrides the remembered id. Tests must monkeypatch `config.STATE_FILE`.
|
||||||
notably `IDLE -> TALKING` is legal directly (no LISTENING/THINKING leg)
|
- **`server_client.py`**: the desk protocol. `_relay()` is the shared command → tool_result loop.
|
||||||
because the heartbeat can make the pet speak proactively/unprompted.
|
`converse_stream()` hands each `say` sentence over as it arrives and finishes a tool call through
|
||||||
- **`controller.py`** — `PetController(QObject)`, the pipeline orchestrator.
|
`_relay`. It raises `ServerError` only if nothing was shown yet, which lets the caller fall back to
|
||||||
Runs on a background `QThread` (wired in `ui/app.py`) so audio I/O/network
|
`converse()` invisibly. `Reply.streamed` means "already on screen, don't print again".
|
||||||
never blocks the Qt event loop; communicates with the UI only through Qt
|
`run_local_command` runs in `os.getcwd()` with stdin closed and `CREATE_NO_WINDOW`, because under
|
||||||
signals (`state_changed`, `said`, `log`, `action`, `napping`), never touches
|
pythonw every cmd.exe would otherwise flash a console.
|
||||||
a `QWidget` directly. Also drives the periodic heartbeat
|
- **`tools.py`**: `Tools`, the `on_command` callable. It routes `filectl` to `file_ops`, answers
|
||||||
(`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the
|
`petctl`/`dialoguectl` with `PET_UNAVAILABLE` (the server may still emit them), and treats
|
||||||
server push proactive spoken announcements between user turns, and on the
|
anything else as a shell command. Approval: reads and lists never ask. Shell, write and edit ask
|
||||||
same tick re-evaluates nap state and drains queued desktop notifications.
|
via `ui.confirm` unless `mode == "auto"`, and "always" switches the session to auto. A decline
|
||||||
It owns the live wake-word threshold (`wake_threshold()` is passed to
|
returns `DECLINED` to the model rather than raising, so the turn still completes.
|
||||||
`listen_for_wake_word` as a *callable* so the tray slider takes effect
|
- **`file_ops.py`**: `filectl {json}` list/read/write/edit. It is a **single-line** JSON wire format
|
||||||
mid-listen) and the conversation `history`.
|
because the server's `command` marker only captures to the first newline. `edit` must match
|
||||||
- **`server_client.py`** — HTTP client for the desk API, dependency-free
|
exactly once. Parsing (`parse`) is separate from I/O (`execute`).
|
||||||
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
- **`relay_json.py`**: lenient JSON for model-typed payloads. It tries strict parsing first, then
|
||||||
server-issued shell commands (`run_local_command`, executed via
|
named repairs (fences, smart quotes, invalid backslash escapes, stray quotes, trailing commas,
|
||||||
`subprocess.run(shell=True)` as the desktop user, 30s default timeout) via
|
Python literals, single quotes), and accepts a repair only if the result parses. Repairs are
|
||||||
`/desk/tool_result` until the server sends a final `reply` (capped at
|
always reported back to the model, never silent. The backslash repair only touches *invalid*
|
||||||
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
|
escapes, so `C:\temp` still parses as a tab (that's ambiguous, so it's left alone).
|
||||||
the server repo's other desk clients — commands only ever originate from
|
- **`ui.py`**: `Terminal` handles plain ANSI rendering, the spinner thread, tool-call/result lines,
|
||||||
the user's own voice/click requests in their own session.
|
diffs, `confirm` and `read_message`. All output goes through `_emit` under the same lock the
|
||||||
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
|
spinner draws under. The spinner redraws by erasing the current line, so output is always written
|
||||||
server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx`
|
as whole lines (see `assistant_chunk`). Glyphs fall back to ASCII on non-UTF-8 streams.
|
||||||
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
|
- **`console.py`**: `Session` (one turn, delivered-file download, slash commands, `!` local shell,
|
||||||
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
|
the loop) and `run()` (argv). Collaborators are injected so tests can drive it.
|
||||||
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
- **`gui/`**: the window. `bridge.Bridge` implements the same surface as `ui.Terminal`
|
||||||
carries odd bytes across HTTP chunk boundaries, without which everything
|
(tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the
|
||||||
after the first split sample plays as static — falling back to whole-clip
|
window reuses `console.Session` and `Tools` unchanged. `window.TurnRunner` runs `Session.send` on a
|
||||||
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one
|
QThread. `Bridge.confirm` blocks the worker on an Event until a ToolCard's Allow/Deny calls
|
||||||
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
|
`answer()`, and `close()` releases it as "no". `window.MainWindow` reacts only to Bridge signals.
|
||||||
**wake** (default) scores every frame with the same openWakeWord model the
|
`widgets.py` holds the transcript pieces (ToolCard, AssistantMessage, Composer, …). `theme.py` holds
|
||||||
idle listener uses, so only the wake phrase cuts playback; **energy** is the
|
the colours and the one stylesheet (widgets set object names and dynamic properties for it, then
|
||||||
original N-consecutive-loud-frames rule, threshold ~4x the VAD one because
|
`repolish()`). `icons.py` draws every icon with QPainter, so there are no asset files.
|
||||||
the mic hears the pet's own voice. Wake mode shares `_default_model` with
|
`markdown_html` swaps Qt's fixed font for code (Courier New on Windows) with the app's mono font.
|
||||||
the idle listener — the two never run concurrently — and `reset()`s it on
|
- **`dictation.py`**: the mic button. It streams 16 kHz int16 to the server's `/desk/stt`
|
||||||
detection so the tail of one reply can't count toward the next). Each
|
websocket (Bolt-Pet's route: the server relays to Deepgram) and buffers every frame. If the live
|
||||||
accepts an injectable stream/model/protocol so tests don't need real audio
|
socket never opened or died, `_fallback()` sends the whole buffer once from an empty transcript,
|
||||||
hardware or a display.
|
so a half-live session's words don't come back twice. A healthy socket is never re-sent, even
|
||||||
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
|
when it heard nothing, because that would pay twice. Stops on click, on `silence_seconds` after
|
||||||
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the
|
speech, or on `max_seconds`. No Qt and no numpy (the array module does RMS), so it's identical
|
||||||
pet" payload type and this repo can't change the server, so these ride the
|
in the Linux single file. The mic and socket are injected. The window turns the callbacks into
|
||||||
existing shell-command relay: `controller._handle_command` parses them and
|
`DictationSignals`, and the composer is read-only while a recording rewrites it. PySide6 6.10
|
||||||
they never reach `subprocess`; anything else is a real shell command exactly
|
needs `QTextCursor.MoveOperation.End`, not `cursor.End`.
|
||||||
as before. Pure parsing; the UI half is `PetWindow.apply_action`.
|
- **Chats**: `server_client.list_chats()`/`create_chat()` wrap `GET`/`POST /desk/chats` (the server
|
||||||
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
|
mints `chat-…` ids; a chat is only listed once used). Switching is `config.set_session(id)`: a
|
||||||
osascript) appended to each utterance via `context_for()`, plus
|
plain reassignment of the `SESSION_ID` every call reads, persisted to `session.json`, and
|
||||||
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
|
deliberately not locked. The window refuses to switch mid-turn instead. `gui/chat_list_window.py`
|
||||||
no images. Every probe is best-effort and returns None/False rather than
|
runs list/create as `ChatTask`s on `QThreadPool.globalInstance()` and hands results back through
|
||||||
raising; the parsing is split into pure functions that are tested without a
|
`ChatSignals`, never calling the network from a slot. The header New chat uses the same pool.
|
||||||
display server.
|
`Session.new_conversation()` (terminal `/new`) is blocking and falls back to a local id if the
|
||||||
- **`quiet.py`** — quiet-hours spec parsing (`23:00-08:00`, wraps midnight,
|
server can't mint one. `chats.py` holds the Qt-free row helpers (the preview drops the
|
||||||
comma-separated). Napping suppresses *proactive* noise and wandering only;
|
`[Bolt Console: …]` and `[on screen right now: …]` notes).
|
||||||
wake word / click / push-to-talk still work.
|
- **`file_delivery.py`**: saves files the server queues via `deliver_files`. The name is reduced to
|
||||||
- **`notifications.py`** — Linux/D-Bus notification bridge: tails
|
its basename and never overwrites an existing file. `Session.check_deliveries` runs after every
|
||||||
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
|
turn.
|
||||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
- **`doctor.py`**: independent checks, each reporting ok/warn/fail plus what to do about it. A check
|
||||||
survivors through `converse()`. Off by default — each one is a round trip.
|
never raises.
|
||||||
- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process
|
|
||||||
with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and
|
|
||||||
rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex
|
|
||||||
that skips anything already carrying a flag and anything inside quotes).
|
|
||||||
Prefers a real askpass binary and falls back to generating a
|
|
||||||
zenity/kdialog wrapper in `~/.cache/bolt-pet/askpass.sh`. Resolution order
|
|
||||||
is injectable (`is_executable`/`which`) so it's testable on a machine with a
|
|
||||||
different set installed. See the security notes — the dialog is the boundary.
|
|
||||||
- **`updater.py`** — self-update from the Gitea releases API. Polls
|
|
||||||
`<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
|
||||||
`bolt_pet.__version__` and moves the checkout to it with
|
|
||||||
`git fetch --tags` + `git checkout tags/<tag>`, so "downloading an update"
|
|
||||||
is just git and rolling back is one command. Three safety rules: a **dirty
|
|
||||||
working tree is skipped, never stashed** (silently discarding your
|
|
||||||
work-in-progress beats running an old version); everything after the
|
|
||||||
checkout — dependency install, then an **import smoke test in a
|
|
||||||
subprocess** (this process still has the old modules loaded, so importing
|
|
||||||
in-process would prove nothing) — is guarded, and any failure rolls back to
|
|
||||||
the exact ref that was live before, branch name or SHA; and the restart only
|
|
||||||
happens once the new code imports, so a broken release costs a log line
|
|
||||||
rather than a pet that won't start. Git goes through an injectable
|
|
||||||
`run(args) -> (code, output)` callable so apply/rollback is unit-tested
|
|
||||||
against a fake git; version comparison and release parsing are pure.
|
|
||||||
`controller._maybe_update` drives it from the wake-listener tick (so the pet
|
|
||||||
is IDLE and between turns by construction) and the actual `os.execv` happens
|
|
||||||
in `ui/app.py` *after* `app.exec()` returns — that ordering is what
|
|
||||||
guarantees the mic is released before the new process opens it.
|
|
||||||
- **`history.py`** — rolling transcript (`HISTORY_LIMIT` turns) behind the
|
|
||||||
tray's History window and click-to-copy on the bubble.
|
|
||||||
- **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged
|
|
||||||
reason (Wayland, missing package, macOS permissions) since the wake word is
|
|
||||||
the primary trigger.
|
|
||||||
- **`speech_text.py`** — sanitizes server replies before they're heard/shown.
|
|
||||||
`for_speech()` (called inside `tts.speak()`, so every path to the speakers is
|
|
||||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
|
||||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
|
||||||
words a few symbols (`&` → "and"). `for_display()` is the looser version for
|
|
||||||
the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides
|
|
||||||
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
|
||||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
|
||||||
trailing one counts, since a question asked in passing isn't awaiting a
|
|
||||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
|
||||||
wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply
|
|
||||||
with a question can't loop forever off mic noise. Pure string logic, no
|
|
||||||
Qt/audio imports.
|
|
||||||
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
|
||||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
|
||||||
together; `pet_window.py` is the frameless/translucent/always-on-top sprite
|
|
||||||
window + speech bubble (non-square frames are centered in the square
|
|
||||||
`PET_SIZE` window, see `paintEvent`), and also owns:
|
|
||||||
- **wandering** — a ~30fps timer walks the window toward a random on-screen
|
|
||||||
target every `PET_WANDER_INTERVAL_SECONDS` (randomized), suppressed
|
|
||||||
whenever the pet is non-IDLE, napping, dragged, or has a bubble up. A
|
|
||||||
commanded `petctl move` overrides all of that except the drag.
|
|
||||||
- **emotes** — `emote_transform()` is pure maths (dx, dy, rotation, scale
|
|
||||||
from a 0..1 progress) kept out of `paintEvent` so the curves are unit
|
|
||||||
tested; every emote must return to the identity transform at progress 1.0
|
|
||||||
or the pet ends up permanently askew.
|
|
||||||
- **shaped input / click-through** — `PET_SHAPED_INPUT` masks the window to
|
|
||||||
the sprite's opaque pixels so the square window's transparent corners stop
|
|
||||||
eating clicks (mask rebuilt only when the frame changes, and pinned to the
|
|
||||||
resting position so a bob/spin doesn't thrash it); `PET_CLICK_THROUGH`
|
|
||||||
makes the pet ignore the mouse entirely.
|
|
||||||
- **edge snapping** (`PET_EDGE_SNAP`) after a drag or a stroll, and **nap
|
|
||||||
dimming** (`set_napping`).
|
|
||||||
`sprite.py` loads `assets/sprites/<state>/*.png` (filename-sorted, looping —
|
|
||||||
currently Kenney's CC0 robot pack, see `assets/sprites/README.md`) and falls
|
|
||||||
back to a procedurally-drawn placeholder blob per state if a folder has no
|
|
||||||
frames; `tray.py` is the system tray menu (talk now / mute / nap / wander /
|
|
||||||
click-through / history / wake-word tuning / quit) — the pet window has no
|
|
||||||
title bar or taskbar entry; `history_window.py` and `wake_tuner.py` are the
|
|
||||||
two dialogs it opens.
|
|
||||||
|
|
||||||
### Wake-word detection
|
## Testing conventions
|
||||||
|
|
||||||
`audio/wake_word.py` uses a custom-trained openWakeWord model,
|
`tests/test_gui.py` sets `QT_QPA_PLATFORM=offscreen` itself and drives real turns through
|
||||||
`thunderbolt.onnx` (ships in the project root), the same way the server
|
`MainWindow` and its worker thread against the loopback server (`pump_until` processes events).
|
||||||
repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey bolt" — same
|
Offscreen Qt has **no font database**, so check the look by rendering with the real platform plus
|
||||||
runtime (openWakeWord, ONNX inference framework), same per-frame
|
`WA_DontShowOnScreen` and `grab()`, not offscreen.
|
||||||
`predict()`/`reset()` loop. Every mic frame is scored; any class score at or
|
|
||||||
above `WAKE_WORD_THRESHOLD` (default `0.5`, in `.env`) counts as a
|
|
||||||
detection. Swap `WAKE_MODEL_FILE` to point at a differently-trained `.onnx`
|
|
||||||
model to change the wake phrase — everything downstream (STT, server call,
|
|
||||||
TTS) is unaffected.
|
|
||||||
|
|
||||||
**openwakeword's `Model.reset()` is not enough to forget a detection.** It
|
Tests cover logic with injected fakes (`FakeUI`, `FakeShell`, a `Terminal` writing to `StringIO`
|
||||||
clears the *prediction* buffer only; the rolling audio window the classifier
|
with a scripted `input_fn`). `tests/test_console.py` is the end-to-end file: a real threaded HTTP
|
||||||
actually scores lives in `model.preprocessor` (`raw_data_buffer` — 10s of raw
|
server on a loopback port driven through the real `server_client`, `Tools` and `Terminal`. Add a
|
||||||
audio — plus `melspectrogram_buffer` and a ~120-frame `feature_buffer`) and
|
case there when a bug crosses module boundaries. Tests that embed a path in JSON must use
|
||||||
`AudioFeatures` has no reset method at all. So after a detection the wake
|
`json.dumps(str(path))`, because Windows backslashes are otherwise invalid escapes.
|
||||||
phrase is still in the window, and the next frame fed to the model re-fires on
|
|
||||||
it. Symptom when this bites: the pet cuts itself off a word into every reply,
|
|
||||||
because wake-mode barge-in resumes feeding the model and instantly matches the
|
|
||||||
"thunderbolt" that *started* the turn. `wake_word.hard_reset(model)` restores
|
|
||||||
the preprocessor to its as-constructed (silence) state and is what both
|
|
||||||
`listen_for_wake_word` and `WakeWordBargeIn.reset()` call — use it, not
|
|
||||||
`reset()`, anywhere a detection needs to be genuinely forgotten. The blank
|
|
||||||
state is cached on the preprocessor object (not in an `id()`-keyed dict —
|
|
||||||
CPython reuses ids after GC), since rebuilding it costs an ONNX pass over 10s
|
|
||||||
of silence.
|
|
||||||
|
|
||||||
The threshold is tunable at runtime: the tray's **Wake word tuning…** window
|
## Fabricated tool results
|
||||||
(`ui/wake_tuner.py`) shows the peak score seen and a rolling list of near
|
|
||||||
misses (frames within `WAKE_NEAR_MISS_MARGIN` *below* the threshold — i.e.
|
|
||||||
the times it nearly heard you), and its slider is read per frame because
|
|
||||||
`listen_for_wake_word` accepts a callable threshold. Set the threshold just
|
|
||||||
under the peak you can hit reliably, then persist it in `.env`.
|
|
||||||
|
|
||||||
### Testing conventions
|
The model sometimes writes tool output as prose instead of emitting a command. The tool marker
|
||||||
|
syntax in `console.annotate()` is load-bearing: vague wording ("use filectl") measurably caused
|
||||||
`tests/` covers pure logic only (state machine, wake-word scoring loop, mic
|
this against the live server, and spelling out `command: ...` fixed it. `console.fabricated()`
|
||||||
VAD, HTTP client against mocks) — nothing there needs real audio hardware or
|
flags a reply shaped like tool output when `Tools.calls` didn't move, and the next message carries
|
||||||
a display. Modules under test are written to accept fake streams/models/
|
`CORRECTION`. A polluted session keeps copying its own history, so the remedy is New chat.
|
||||||
`on_command` callables specifically to keep tests hardware-free; follow that
|
|
||||||
pattern (inject a `Protocol`-typed collaborator) rather than mocking at the
|
|
||||||
`sounddevice`/`openwakeword` import boundary when adding new testable logic.
|
|
||||||
`test_controller.py`, `test_controller_features.py`, `test_wander.py` and
|
|
||||||
`test_pet_window_features.py` need a `QApplication`, which segfaults without
|
|
||||||
a display unless run with `QT_QPA_PLATFORM=offscreen`.
|
|
||||||
|
|
||||||
Newer subsystems follow the same rule — the testable part is separated from
|
|
||||||
the part that needs hardware: dbus-monitor output is parsed by a pure
|
|
||||||
`iter_notifications(lines)`, xprop output by pure `parse_xprop_*` functions,
|
|
||||||
HTTP chunk reassembly by `chunks_to_int16`, emote motion by
|
|
||||||
`emote_transform`. Tests that touch the controller monkeypatch
|
|
||||||
`screen_context.context_for` / `is_fullscreen_active`, otherwise they shell
|
|
||||||
out to xprop on a headless box.
|
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
The server can relay a shell command back to this machine to execute as the
|
Approved shell commands, writes and edits run as the user with no sandbox. `auto` mode removes the
|
||||||
desktop user (see `server_client.run_local_command`). This is intentional
|
prompt. That is the same "full desktop control" trust model as the server's other desk clients,
|
||||||
("full desktop control" for things like "open firefox" or disk checks) and
|
which is why `ask` is the default here. Keep `DESK_API_KEY` private.
|
||||||
matches the trust model of the server repo's other desk clients. Keep
|
|
||||||
`DESK_API_KEY` private and don't expose the desk API port to the open
|
|
||||||
internet.
|
|
||||||
|
|
||||||
`sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT`
|
|
||||||
on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the
|
|
||||||
password is collected in a desktop dialog, so commands can escalate to root
|
|
||||||
instead of hanging on a tty the pet doesn't have. The dialog is the security
|
|
||||||
boundary — it's the only thing between the server deciding to run `sudo` and
|
|
||||||
it running, so the prompt is deliberately not suppressible per-command and
|
|
||||||
those commands get their own longer timeout (`SUDO_COMMAND_TIMEOUT_SECONDS`)
|
|
||||||
rather than being made non-interactive. Set `SUDO_ASKPASS_PROMPT=false` to
|
|
||||||
take the capability away entirely; sudo commands then fail. Note that
|
|
||||||
`sudo -n` / `sudo -A` / `sudo -u …` in a relayed command are never rewritten,
|
|
||||||
so an explicit non-interactive sudo stays non-interactive.
|
|
||||||
|
|
||||||
**Don't run the pet as root.** It needs no privileges of its own, PortAudio
|
|
||||||
can't reach the user's PipeWire socket from a root session (raw ALSA devices
|
|
||||||
reject the 16 kHz capture rate — `paInvalidSampleRate`), and every relayed
|
|
||||||
command would run unconstrained.
|
|
||||||
|
|
||||||
Two newer features widen what leaves this machine, both switchable in `.env`:
|
|
||||||
`SCREEN_CONTEXT` appends the focused window's *title* to each utterance
|
|
||||||
(titles often contain file paths, document names, or subject lines), and
|
|
||||||
`NOTIFICATION_BRIDGE` (off by default) forwards matching desktop
|
|
||||||
notifications to the server. Neither sends screenshots or notification
|
|
||||||
contents you haven't matched with `NOTIFICATION_FILTER`.
|
|
||||||
|
|||||||
@@ -1,175 +1,122 @@
|
|||||||
# Bolt Desktop Pet
|
# Bolt Console
|
||||||
|
|
||||||
A little animated pet that lives on your desktop and is just a face on top
|
A desktop app for Bolt, in the style of Claude Code (with a terminal version too). You type, Bolt
|
||||||
of your Bolt server — same brain, memory, tools, and persona as Discord
|
answers, and when he needs to look at or change something on this machine you see each step as it
|
||||||
chat and the Linux desk client. It talks to `ai/desk_api.py` on the server
|
happens: reading files, listing folders, editing, writing, running commands. Changes wait for your
|
||||||
exactly the way `desk_client/bolt_desk.py` does; this project only adds the
|
**Allow** on the step itself, and edits show a diff first.
|
||||||
on-screen pet and swaps Deepgram/ElevenLabs playback to be cross-platform
|
|
||||||
(no `mpv`/`ffplay`/`espeak-ng` subprocess calls — pure `sounddevice`).
|
It talks to the same Bolt server as Bolt-Pet, the Discord bot and the phone app, so it has the same
|
||||||
|
brain, memory, tools and persona. What it drops is the desktop pet around it: no sprite, no wake
|
||||||
|
word, no microphone, no speech, no screen reading.
|
||||||
|
|
||||||
|
The window is PySide6 with its own dark theme. Header: working folder (click to change) and an
|
||||||
|
**Ask first / Auto** switch. Below that: the conversation, with each tool call as a card that you can
|
||||||
|
click to expand, and a composer at the bottom (Enter sends, Shift+Enter adds a new line, Ctrl+L
|
||||||
|
clears, Ctrl+O picks a folder).
|
||||||
|
|
||||||
|
**Chats** (Ctrl+K) lists every conversation you've had with Bolt, from any device (console, pet,
|
||||||
|
phone), newest first. Open one to continue it. **New chat** (Ctrl+N, or the button in the Chats
|
||||||
|
dialog) asks the server for a fresh chat: new conversation history, but Bolt still remembers you,
|
||||||
|
because his memory belongs to you, not to a chat. Switching clears the window's transcript, since
|
||||||
|
the server has no endpoint for an old chat's messages, although Bolt still has them. The current
|
||||||
|
chat is remembered across restarts. The terminal version has `/chats`, `/chat <n>` and `/new`.
|
||||||
|
|
||||||
|
**Dictation:** the mic button next to Send (or Ctrl+M) records you and types what you say into the box
|
||||||
|
as you talk. Click again or pause for 3 seconds to stop, and Esc cancels. The text isn't sent until you
|
||||||
|
press Enter, so you can fix it first. It uses the same speech-to-text as Bolt-Pet: the Bolt server's
|
||||||
|
`/desk/stt` relay to Deepgram, so you don't need a Deepgram account. The server does count it against
|
||||||
|
your credits like a chat turn. `MIC_DEVICE` in `.env` picks the microphone.
|
||||||
|
|
||||||
|
The terminal version (`run-cli.bat` / `./run.sh --cli`) looks like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
mic → wake-phrase spotter ("thunderbolt") / hotkey / click → record utterance
|
> what's taking up space in this repo?
|
||||||
→ Deepgram STT (+ the focused window's title, for "what's this error?")
|
|
||||||
→ POST /desk/converse on your Bolt server → [server may relay a shell
|
● List(C:\work\site, recursive)
|
||||||
command back to run on THIS machine, or a `petctl` command that moves
|
⎿ assets/
|
||||||
or emotes the pet] → reply → ElevenLabs streaming TTS → speakers
|
assets/hero.mp4 48211332B
|
||||||
→ shown in a speech bubble + the pet's sprite state (idle/listening/
|
... +212 lines
|
||||||
thinking/talking) updates the whole time
|
● Shell(du -sh assets/* | sort -h | tail -5)
|
||||||
|
Run this command? [y]es / [n]o / [a]lways this session y
|
||||||
|
⎿ 48M assets/hero.mp4
|
||||||
|
...
|
||||||
|
● Most of it is one file: assets/hero.mp4 at 48 MB. ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing is sent to the server until the wake phrase fires, you press the
|
|
||||||
push-to-talk hotkey, or you click the pet — plus, if you turn them on, the
|
|
||||||
heartbeat and the desktop-notification bridge.
|
|
||||||
|
|
||||||
## Why a separate project instead of living in the tmn-api repo
|
|
||||||
|
|
||||||
This runs on your desktop machine, not the server — same relationship as
|
|
||||||
`desk_client/` (Linux) or the Android app, both of which are just clients of
|
|
||||||
the desk API over HTTP. It has no import dependency on the server repo at
|
|
||||||
all, so it can be copied anywhere and configured with its own `.env`.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Copy this whole `bolt-pet/` folder to the machine you want the pet to run
|
```bash
|
||||||
on (if that isn't already this machine).
|
cp .env.example .env # set BOLT_SERVER_URL and DESK_API_KEY
|
||||||
2. `cp .env.example .env` and fill in:
|
run.bat # Windows: the window
|
||||||
- `BOLT_SERVER_URL` + `DESK_API_KEY` — same as `desk_client/.env` on the
|
run-cli.bat # Windows: the terminal version
|
||||||
server side. Use the server's master `DESK_API_KEY`, or mint yourself a
|
./run.sh [--cli] # macOS/Linux
|
||||||
personal one via the desk-only `api_key_generate` marker (see the main
|
|
||||||
repo's `CLAUDE.md` → "Per-user API keys").
|
|
||||||
- `DEEPGRAM_API_KEY` for STT.
|
|
||||||
- `ELEVENLABS_API_KEY` + `ELEVENLABS_VOICE_ID` for TTS (optional — falls
|
|
||||||
back to offline TTS via `pyttsx3` if omitted or if a request fails).
|
|
||||||
3. Run it:
|
|
||||||
- macOS/Linux: `./run.sh`
|
|
||||||
- Windows: `run.bat`
|
|
||||||
|
|
||||||
Both scripts create a local `.venv` and install `requirements.txt` on
|
|
||||||
first run. On Linux you'll also need system packages for audio:
|
|
||||||
`sudo apt install libportaudio2 espeak-ng`.
|
|
||||||
|
|
||||||
The pet appears near the bottom-right of your screen. It wanders off on its
|
|
||||||
own now and then; drag it anywhere and it tucks itself flush against a nearby
|
|
||||||
screen edge. That position isn't saved across restarts (see Known
|
|
||||||
limitations).
|
|
||||||
|
|
||||||
## Talking to it
|
|
||||||
|
|
||||||
- Say **"thunderbolt"** — detected fully on-device by a custom-trained
|
|
||||||
openWakeWord model (`thunderbolt.onnx`, ships in the project root), the
|
|
||||||
same way the server repo's `desk_client/bolt_desk.py` detects "hey bolt"
|
|
||||||
with `bolt.onnx`. Matches the `DEFAULT_WAKE_WORD` already used for Bolt's
|
|
||||||
Discord voice channels, so it's the same word everywhere.
|
|
||||||
- Or press **Ctrl+Alt+Space** (`PUSH_TO_TALK_HOTKEY`) from anywhere — useful
|
|
||||||
in a noisy room where the wake word misfires. Needs `pynput` and a session
|
|
||||||
that allows global key hooks; most Wayland sessions don't, in which case it
|
|
||||||
logs why at startup and everything else still works.
|
|
||||||
- Or just **click the pet** once (a drag doesn't count as a click).
|
|
||||||
- **Talk over it** to cut a long answer short — the mic stays live while it
|
|
||||||
speaks, and barging in starts your next turn immediately (`BARGE_IN`).
|
|
||||||
- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**,
|
|
||||||
**Wander around**, **Click through the pet**, **History…**, **Wake word
|
|
||||||
tuning…** and **Quit** — the pet window itself has no title bar or taskbar
|
|
||||||
entry.
|
|
||||||
- **Click the speech bubble** to copy what it just said; the tray's
|
|
||||||
**History…** window keeps the last `HISTORY_LIMIT` turns.
|
|
||||||
|
|
||||||
## What it does on its own
|
|
||||||
|
|
||||||
- **Wanders** the desktop while idle (`PET_WANDER`), stands still while
|
|
||||||
listening/thinking/talking or while a bubble is up.
|
|
||||||
- **Moves and emotes on command.** Bolt can relay `petctl move top-left`,
|
|
||||||
`petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander
|
|
||||||
on|off`, `petctl nap on|off`. These are intercepted here and never reach a
|
|
||||||
shell.
|
|
||||||
- **Naps** during `QUIET_HOURS` (e.g. `23:00-08:00`) or while a fullscreen
|
|
||||||
app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes
|
|
||||||
no proactive noise. It still answers when you speak to it.
|
|
||||||
- **Reacts to desktop notifications** if you turn on `NOTIFICATION_BRIDGE`
|
|
||||||
(Linux/D-Bus) and set a `NOTIFICATION_FILTER` regex — matching
|
|
||||||
notifications get forwarded to the server, so it can tell you the deploy
|
|
||||||
went green. Off by default: each one costs a round trip.
|
|
||||||
|
|
||||||
## Wake-word detection
|
|
||||||
|
|
||||||
`bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx`
|
|
||||||
via the openWakeWord runtime (ONNX inference) and treats any class score at
|
|
||||||
or above `WAKE_WORD_THRESHOLD` (default `0.5`) as a detection — the exact
|
|
||||||
same per-frame `predict()`/`reset()` pattern as `desk_client/bolt_desk.py`'s
|
|
||||||
main loop. Point `WAKE_MODEL_FILE` in `.env` at a different `.onnx` model to
|
|
||||||
change the wake phrase later without touching any other code.
|
|
||||||
|
|
||||||
If it keeps ignoring you (or firing at the TV), open **Wake word tuning…**
|
|
||||||
from the tray: it shows the peak score while you talk and a rolling list of
|
|
||||||
near misses — frames that scored just under the threshold — and the slider
|
|
||||||
takes effect immediately, mid-listen. Set the threshold just below the peak
|
|
||||||
you can hit reliably, then write it into `.env` as `WAKE_WORD_THRESHOLD`.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
bolt_pet/
|
|
||||||
config.py .env loading (same pattern as desk_client/bolt_desk.py)
|
|
||||||
state.py PetState enum + a small transition-checked state machine
|
|
||||||
server_client.py /desk/converse, /desk/tool_result, /desk/report_status
|
|
||||||
controller.py the pipeline: wake word -> STT -> server -> TTS, on a QThread
|
|
||||||
speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk"
|
|
||||||
pet_actions.py petctl move/emote/say/wander/nap parsing
|
|
||||||
screen_context.py active-window title + fullscreen detection
|
|
||||||
quiet.py quiet-hours schedule
|
|
||||||
notifications.py desktop notification bridge (Linux/D-Bus)
|
|
||||||
history.py rolling conversation transcript
|
|
||||||
hotkey.py global push-to-talk (pynput, optional)
|
|
||||||
audio/
|
|
||||||
mic.py input stream + energy-based VAD utterance capture
|
|
||||||
wake_word.py openWakeWord thunderbolt.onnx detection (see above)
|
|
||||||
stt.py Deepgram
|
|
||||||
tts.py ElevenLabs streaming PCM, offline pyttsx3 fallback
|
|
||||||
barge_in.py "you started talking" detector, to cut playback short
|
|
||||||
ui/
|
|
||||||
app.py wires QApplication + window + tray + controller thread together
|
|
||||||
pet_window.py frameless/translucent/always-on-top sprite window + speech bubble
|
|
||||||
sprite.py frame animation loader (see assets/sprites/README.md)
|
|
||||||
tray.py system tray menu
|
|
||||||
history_window.py conversation scrollback (copyable)
|
|
||||||
wake_tuner.py live wake-word threshold + near-miss log
|
|
||||||
assets/sprites/ Kenney robot-pack art (CC0) — see assets/sprites/README.md
|
|
||||||
scripts/
|
|
||||||
slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention
|
|
||||||
tests/ pure-logic unit tests (state machine, wake-phrase
|
|
||||||
matching, HTTP client against mocks) — nothing here
|
|
||||||
needs real audio hardware or a display
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security notes
|
The launchers create `.venv` on first run. Dependencies: `requests`, plus `PySide6` for the window
|
||||||
|
(the terminal version doesn't import Qt). Bolt works in the
|
||||||
|
directory you **launch from**, so you can put the launcher on your PATH and start it inside whatever
|
||||||
|
project you want him to work on.
|
||||||
|
|
||||||
Same as `desk_client/bolt_desk.py`: the server can relay a shell command
|
```bash
|
||||||
back to this machine ("full desktop control" — "open firefox", "how full is
|
run.bat --auto # don't ask before commands, writes and edits
|
||||||
my disk", etc.), which this client executes as your desktop user with a
|
run-cli.bat -p "summarise README.md" # one message, print the reply, exit
|
||||||
30-second timeout (`COMMAND_TIMEOUT_SECONDS`). That's the same trust model
|
run-cli.bat --doctor [--deep] # check the install (--deep contacts the server)
|
||||||
as the Linux desk client and the Android app — commands only ever originate
|
```
|
||||||
from your own voice/click requests in your own session. Keep `DESK_API_KEY`
|
|
||||||
private; don't expose the desk API port to the open internet.
|
|
||||||
|
|
||||||
`petctl` commands (move/emote/say/wander/nap) are handled inside the pet and
|
## Using the terminal version
|
||||||
never reach a shell, so that channel can't run anything.
|
|
||||||
|
|
||||||
Two features widen what leaves this machine, both off-switchable in `.env`:
|
| Input | What it does |
|
||||||
`SCREEN_CONTEXT=true` (default) appends the focused window's *title* to what
|
|---|---|
|
||||||
you say — titles often contain file paths, document names or email subjects —
|
| any text | sent to Bolt |
|
||||||
and `NOTIFICATION_BRIDGE=false` (default) can forward matching desktop
|
| `!<command>` | runs a shell command yourself. Nothing is sent to Bolt |
|
||||||
notifications. No screenshots or images are ever sent.
|
| `/cd <dir>`, `/pwd` | change or show the working directory |
|
||||||
|
| `/ask`, `/auto` | turn approval prompts on or off for this session |
|
||||||
|
| `/status` | server, session, approval mode, working directory |
|
||||||
|
| `/files` | download anything Bolt has queued for you |
|
||||||
|
| `/new` | start a fresh chat (same as New chat in the window) |
|
||||||
|
| `/chats`, `/chat <n>` | list your chats from every device; switch to number n (or a session id) |
|
||||||
|
| `/clear` | clear the screen. Bolt's memory lives on the server and is kept |
|
||||||
|
| `/help`, `/exit` | |
|
||||||
|
| line ending in `\` | keep typing on the next line |
|
||||||
|
|
||||||
## Known limitations / not-yet-done
|
Ctrl+C stops the spinner and gives the prompt back. The server may still finish that turn on its
|
||||||
|
side. At an approval prompt, Ctrl+C means no.
|
||||||
|
|
||||||
- Pet screen position isn't persisted across restarts.
|
## How Bolt reaches this machine
|
||||||
- Wandering is a straight walk to a random point — no Shimeji-style physics,
|
|
||||||
wall-climbing or falling.
|
The desk API has one tool channel: the server sends back a `command` string, the client runs it and
|
||||||
- Push-to-talk and the notification bridge are platform-limited: the hotkey
|
posts the output to `/desk/tool_result`, and this repeats until a final reply. The console decides
|
||||||
needs a session that allows global key hooks (most Wayland setups don't),
|
what each string means (`bolt_console/tools.py`):
|
||||||
and the notification bridge is Linux/D-Bus only.
|
|
||||||
- Barge-in listens through the same mic that hears the pet's own voice. It
|
- **`filectl {json}`**: list, read, write or edit a file (`file_ops.py`). This is what makes file
|
||||||
wants headphones or a decent gap between speaker and mic; if playback
|
editing reliable, because the model never has to hand-quote a shell heredoc. Edits must match
|
||||||
interrupts itself, raise `BARGE_IN_RMS_THRESHOLD` or set `BARGE_IN=false`.
|
exactly once. Common JSON slips, such as a raw `C:\Users\...` path, are repaired and the model is
|
||||||
- Screen context is the window *title* only — the desk API takes text, so
|
told about the repair.
|
||||||
there's no screenshot understanding.
|
- **`petctl …` / `dialoguectl …`**: the pet's body and voice. These are not run. Bolt is told
|
||||||
|
there's no pet here.
|
||||||
|
- **anything else**: a real shell command, run in the working directory, with a
|
||||||
|
`COMMAND_TIMEOUT_SECONDS` limit (default 120s).
|
||||||
|
|
||||||
|
Reads and listings never ask. Shell commands, writes and edits ask first in `ask` mode (the
|
||||||
|
default). Edits show a diff before you answer.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Anything you approve runs as your user, with your permissions. In `auto` mode that means anything
|
||||||
|
the server asks for. Keep `DESK_API_KEY` private and don't expose the desk API to the open internet.
|
||||||
|
Each message includes your working directory and OS name (`CONSOLE_CONTEXT=false` to stop that).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/ # Windows: .venv\Scripts\pytest tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_gui.py` drives turns through the real window and its worker thread against the same
|
||||||
|
loopback server.
|
||||||
|
|
||||||
|
`tests/test_console.py` runs whole turns against a real HTTP server on a loopback port. Only the
|
||||||
|
keyboard is faked. Everything else takes its collaborators as arguments, so no test needs a real
|
||||||
|
terminal or server.
|
||||||
|
|
||||||
|
This project started as a copy of Bolt-Pet. See that repo for the voice/desktop-pet version.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Bolt Console — a terminal client for Bolt's desk API."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Entry point.
|
||||||
|
|
||||||
|
python -m bolt_console the window
|
||||||
|
python -m bolt_console --cli the terminal REPL
|
||||||
|
python -m bolt_console -p MESSAGE one message in the terminal, then exit
|
||||||
|
python -m bolt_console --doctor [--deep]
|
||||||
|
(--auto works with the window and the terminal)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
|
||||||
|
if "--doctor" in args:
|
||||||
|
from bolt_console.doctor import main as doctor
|
||||||
|
|
||||||
|
return doctor(args)
|
||||||
|
|
||||||
|
if "--cli" in args or "-p" in args or "-h" in args or "--help" in args:
|
||||||
|
from bolt_console.console import run
|
||||||
|
|
||||||
|
return run([a for a in args if a != "--cli"])
|
||||||
|
|
||||||
|
from bolt_console.gui.window import run
|
||||||
|
|
||||||
|
return run(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Chat-list helpers with no Qt, shared by the Chats dialog and the
|
||||||
|
terminal's /chats: turning what GET /desk/chats returns into readable rows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Context notes clients append to what you typed. They're in the stored last
|
||||||
|
# message, but they aren't what you said, so they stay out of the preview.
|
||||||
|
# The preview may be cut off mid-note, so an unclosed one runs to the end.
|
||||||
|
_CONTEXT_NOTES = re.compile(r"\s*\[(?:Bolt Console:|on screen right now:)[^\]]*(?:\]|$)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_preview(text: str, limit: int = 110) -> str:
|
||||||
|
text = " ".join(_CONTEXT_NOTES.sub("", str(text or "")).split())
|
||||||
|
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def relative_time(iso: str, now: Optional[datetime] = None) -> str:
|
||||||
|
"""'just now', '12 min ago', '3 h ago', 'yesterday', '4 days ago', or a
|
||||||
|
date. Unparseable input comes back as it was."""
|
||||||
|
try:
|
||||||
|
then = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(iso or "")
|
||||||
|
if then.tzinfo is None:
|
||||||
|
then = then.replace(tzinfo=timezone.utc)
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
seconds = (now - then).total_seconds()
|
||||||
|
if seconds < 60:
|
||||||
|
return "just now"
|
||||||
|
if seconds < 3600:
|
||||||
|
return f"{int(seconds // 60)} min ago"
|
||||||
|
if seconds < 86400:
|
||||||
|
return f"{int(seconds // 3600)} h ago"
|
||||||
|
days = int(seconds // 86400)
|
||||||
|
if days == 1:
|
||||||
|
return "yesterday"
|
||||||
|
if days < 7:
|
||||||
|
return f"{days} days ago"
|
||||||
|
return then.astimezone().strftime("%d %b %Y").lstrip("0")
|
||||||
|
|
||||||
|
|
||||||
|
def local_time(iso: str) -> str:
|
||||||
|
try:
|
||||||
|
then = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
|
||||||
|
if then.tzinfo is None:
|
||||||
|
then = then.replace(tzinfo=timezone.utc)
|
||||||
|
return then.astimezone().strftime("%A %d %B %Y, %H:%M")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(iso or "")
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Environment configuration for Bolt Console.
|
||||||
|
|
||||||
|
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
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# PyInstaller build: the .env sits next to the .exe, not inside the bundle.
|
||||||
|
HERE = Path(sys.executable).resolve().parent
|
||||||
|
else:
|
||||||
|
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_console/)
|
||||||
|
|
||||||
|
|
||||||
|
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 _flag(name: str, default: str) -> bool:
|
||||||
|
return os.environ.get(name, default).lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_name() -> str:
|
||||||
|
try:
|
||||||
|
return platform.node() or "desktop"
|
||||||
|
except Exception:
|
||||||
|
return "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
# ── server / identity ───────────────────────────────────────────────────────
|
||||||
|
# The session id keeps the "pet-" prefix on purpose: the desk API advertises
|
||||||
|
# the `filectl` read/write/edit tool to pet sessions, and this console is
|
||||||
|
# built around that tool. "-console" keeps it a separate conversation from a
|
||||||
|
# Bolt-Pet running on the same machine.
|
||||||
|
|
||||||
|
SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/")
|
||||||
|
API_KEY = os.environ.get("DESK_API_KEY", "")
|
||||||
|
#
|
||||||
|
# "New chat" (GUI) / /new (terminal) rotates to a fresh id, remembered in
|
||||||
|
# STATE_FILE so a restart carries on the same conversation. DESK_SESSION_ID in
|
||||||
|
# .env pins the session and takes precedence over the remembered one.
|
||||||
|
|
||||||
|
STATE_FILE = Path.home() / ".cache" / "bolt-console" / "session.json"
|
||||||
|
_BASE_SESSION = "pet-console-" + _node_name()
|
||||||
|
|
||||||
|
|
||||||
|
def _remembered_session() -> str:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
value = json.loads(STATE_FILE.read_text(encoding="utf-8")).get("session_id")
|
||||||
|
return str(value) if value else ""
|
||||||
|
except Exception:
|
||||||
|
return "" # missing or corrupt state means the default, never a crash
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_ID = os.environ.get("DESK_SESSION_ID") or _remembered_session() or _BASE_SESSION
|
||||||
|
|
||||||
|
|
||||||
|
def set_session(session_id: str) -> str:
|
||||||
|
"""Make *session_id* the current conversation and remember it for the
|
||||||
|
next start. A plain reassignment of the module attribute every
|
||||||
|
server_client call reads; not locked against an in-flight request, which
|
||||||
|
is fine for a rare, deliberate user action (the window blocks switching
|
||||||
|
while a turn is running anyway)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
global SESSION_ID
|
||||||
|
SESSION_ID = session_id
|
||||||
|
try:
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE.write_text(json.dumps({"session_id": SESSION_ID}), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass # switched for this run; it just won't survive a restart
|
||||||
|
return SESSION_ID
|
||||||
|
|
||||||
|
|
||||||
|
def new_session() -> str:
|
||||||
|
"""A locally made fresh id — the fallback when the server can't mint one
|
||||||
|
(POST /desk/chats is the normal route; see Session.new_conversation)."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
return set_session(f"{_BASE_SESSION}-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||||
|
|
||||||
|
|
||||||
|
def session_pinned() -> bool:
|
||||||
|
return bool(os.environ.get("DESK_SESSION_ID"))
|
||||||
|
|
||||||
|
# ── relayed commands ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "120"))
|
||||||
|
|
||||||
|
# What happens when Bolt wants to change something on this machine:
|
||||||
|
# ask — shell commands and file writes/edits wait for y/n at the prompt
|
||||||
|
# (reads and directory listings never ask).
|
||||||
|
# auto — everything runs without asking, like the pet did.
|
||||||
|
# Toggle for the current session with /auto and /ask.
|
||||||
|
APPROVAL_MODE = os.environ.get("APPROVAL_MODE", "ask").strip().lower()
|
||||||
|
|
||||||
|
# ── replies ─────────────────────────────────────────────────────────────────
|
||||||
|
# Print each sentence as the server produces it instead of waiting out the
|
||||||
|
# whole model call. Falls back to the ordinary request/response path when the
|
||||||
|
# stream fails before anything was printed.
|
||||||
|
STREAMING_REPLIES = _flag("STREAMING_REPLIES", "true")
|
||||||
|
|
||||||
|
# Append a one-line note (working directory, OS) to each message, so "what's
|
||||||
|
# in this folder?" has a referent and Bolt knows there's no pet body here.
|
||||||
|
CONSOLE_CONTEXT = _flag("CONSOLE_CONTEXT", "true")
|
||||||
|
|
||||||
|
# ANSI colour. Also off when NO_COLOR is set or stdout isn't a terminal.
|
||||||
|
COLOR = _flag("COLOR", "true") and "NO_COLOR" not in os.environ
|
||||||
|
|
||||||
|
# ── file delivery ───────────────────────────────────────────────────────────
|
||||||
|
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||||||
|
# queues 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 = _flag("RECEIVE_FILES", "true")
|
||||||
|
DELIVERED_FILES_DIR = Path(
|
||||||
|
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
|
||||||
|
).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
# ── dictation (the mic button) ──────────────────────────────────────────────
|
||||||
|
# Speech goes to the server's /desk/stt relay, like Bolt-Pet. MIC_DEVICE is an
|
||||||
|
# input index or name substring (blank = system default); a recording stops
|
||||||
|
# itself after DICTATION_SILENCE_SECONDS of quiet once you've said something.
|
||||||
|
|
||||||
|
MIC_DEVICE = os.environ.get("MIC_DEVICE", "")
|
||||||
|
DICTATION_SILENCE_SECONDS = float(os.environ.get("DICTATION_SILENCE_SECONDS", "3"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool(SERVER_URL and API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def missing_config() -> list:
|
||||||
|
missing = []
|
||||||
|
if not SERVER_URL:
|
||||||
|
missing.append("BOLT_SERVER_URL")
|
||||||
|
if not API_KEY:
|
||||||
|
missing.append("DESK_API_KEY")
|
||||||
|
return missing
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""The REPL: read a line, send it to Bolt, show the tool calls and the reply.
|
||||||
|
|
||||||
|
`Session` holds everything a turn needs and takes its collaborators as
|
||||||
|
arguments (the terminal, the tool dispatcher, the two converse functions), so
|
||||||
|
tests drive whole turns without a real server or a real terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from . import __version__, config, file_delivery, server_client
|
||||||
|
from .chats import clean_preview, relative_time
|
||||||
|
from .tools import Tools
|
||||||
|
from .ui import Terminal, enable_windows_ansi
|
||||||
|
|
||||||
|
HELP = """\
|
||||||
|
Commands
|
||||||
|
/help this list
|
||||||
|
/exit, /quit leave (Ctrl+D / Ctrl+Z also works)
|
||||||
|
/clear clear the screen (Bolt's memory lives on the server and is kept)
|
||||||
|
/cd <dir> change the working directory commands and relative paths use
|
||||||
|
/pwd show the working directory
|
||||||
|
/ask ask before shell commands, writes and edits (default)
|
||||||
|
/auto run everything without asking
|
||||||
|
/status server, session and settings
|
||||||
|
/files download anything Bolt has queued for you now
|
||||||
|
/new start a fresh chat (new history; Bolt still remembers you)
|
||||||
|
/chats list your chats with Bolt, from every device
|
||||||
|
/chat <n|id> switch to chat number n from /chats, or a session id
|
||||||
|
!<command> run a shell command yourself, without sending it to Bolt
|
||||||
|
|
||||||
|
End a line with \\ to keep typing on the next one."""
|
||||||
|
|
||||||
|
|
||||||
|
def _os_label() -> str:
|
||||||
|
try:
|
||||||
|
return f"{platform.system()} {platform.release()}".strip() or "unknown OS"
|
||||||
|
except Exception:
|
||||||
|
return "unknown OS"
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(text: str, cwd: str, os_label: str) -> str:
|
||||||
|
"""The context note appended to each message: where "here" is, and that
|
||||||
|
this client is text-only."""
|
||||||
|
# The exact marker syntax matters. Measured against the live server
|
||||||
|
# (2026-09-25): "use filectl and shell commands" made the model write
|
||||||
|
# `filectl: {...}` or a <tool_call> block as prose and then invent the
|
||||||
|
# output, on both /desk/converse and /desk/converse_stream; spelling out
|
||||||
|
# `command: ...` got a real relayed command on both.
|
||||||
|
return (
|
||||||
|
f"{text}\n\n[Bolt Console: a text terminal on {os_label}; working directory {cwd}. "
|
||||||
|
"To act on this machine, use your command tool: "
|
||||||
|
'`command: filectl {"op": "list", "path": "..."}` for files (ops: list, read, write, edit), '
|
||||||
|
"or `command: <shell command>` for anything else. The console runs it and sends back "
|
||||||
|
"the real output. Never write a command's output yourself. "
|
||||||
|
"There is no pet body, voice or screen here.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# What a reply looks like when it narrates tool output instead of calling a
|
||||||
|
# tool: the model copies the shape of real results it has seen earlier in the
|
||||||
|
# conversation ("→ Command result: [filectl] wrote 1200 chars to …"). Seen
|
||||||
|
# live: three turns in a row of "wrote the files" with no command ever sent.
|
||||||
|
_FAKE_RESULT = re.compile(
|
||||||
|
r"(?:→|->)\s*(?:command|deliver files|tool)\s+result\s*:"
|
||||||
|
r"|\[filectl\]\s+(?:wrote|edited)\b"
|
||||||
|
r"|\[exit \d+\]",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
FABRICATION_WARNING = (
|
||||||
|
"Bolt described running commands, but none reached this app, so nothing was run "
|
||||||
|
"or written. The results in that reply are made up. Bolt will be told on your next message."
|
||||||
|
)
|
||||||
|
|
||||||
|
CORRECTION = (
|
||||||
|
"[Bolt Console: correction. Your previous reply described command results, but no "
|
||||||
|
"command reached this machine, so nothing ran and no files were written. Never write "
|
||||||
|
"results yourself; emit the actual command and wait for its real output.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fabricated(text: str, commands_run: int) -> bool:
|
||||||
|
"""A reply that reads like tool output when no tool ran this turn."""
|
||||||
|
return commands_run == 0 and bool(_FAKE_RESULT.search(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ui: Terminal,
|
||||||
|
tools: Optional[Tools] = None,
|
||||||
|
converse: Callable = server_client.converse,
|
||||||
|
converse_stream: Optional[Callable] = server_client.converse_stream,
|
||||||
|
list_files: Callable = server_client.list_outbox_files,
|
||||||
|
download_file: Callable = server_client.download_outbox_file,
|
||||||
|
create_chat: Callable = server_client.create_chat,
|
||||||
|
list_chats: Callable = server_client.list_chats,
|
||||||
|
):
|
||||||
|
self.ui = ui
|
||||||
|
self.tools = tools or Tools(ui, mode=config.APPROVAL_MODE)
|
||||||
|
self._converse = converse
|
||||||
|
self._converse_stream = converse_stream if config.STREAMING_REPLIES else None
|
||||||
|
self._list_files = list_files
|
||||||
|
self._download_file = download_file
|
||||||
|
self._create_chat = create_chat
|
||||||
|
self._list_chats = list_chats
|
||||||
|
self._last_chats = [] # what /chats showed, so /chat 3 means that row
|
||||||
|
self._said = [] # sentences shown this turn
|
||||||
|
self._correct_next = False # tell Bolt his last reply was fiction
|
||||||
|
|
||||||
|
# ── one turn ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send(self, text: str) -> None:
|
||||||
|
message = annotate(text, os.getcwd(), _os_label()) if config.CONSOLE_CONTEXT else text
|
||||||
|
if self._correct_next:
|
||||||
|
message = f"{CORRECTION}\n\n{message}"
|
||||||
|
self._correct_next = False
|
||||||
|
self._said = []
|
||||||
|
calls_before = self.tools.calls
|
||||||
|
self.ui.start_spinner()
|
||||||
|
try:
|
||||||
|
reply = self._ask(message)
|
||||||
|
except server_client.ServerError as exc:
|
||||||
|
self.ui.stop_spinner()
|
||||||
|
self.ui.error(f"Error: {exc}")
|
||||||
|
return
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.ui.stop_spinner()
|
||||||
|
self.ui.warn("Interrupted. (The server may still finish that turn on its side.)")
|
||||||
|
return
|
||||||
|
self.ui.stop_spinner()
|
||||||
|
if reply.streamed:
|
||||||
|
self.ui.end_reply()
|
||||||
|
else:
|
||||||
|
self.ui.assistant(reply.text)
|
||||||
|
shown = " ".join(self._said + [reply.text])
|
||||||
|
if fabricated(shown, self.tools.calls - calls_before):
|
||||||
|
self.ui.warn(FABRICATION_WARNING)
|
||||||
|
self._correct_next = True
|
||||||
|
self.check_deliveries()
|
||||||
|
|
||||||
|
def _ask(self, message: str) -> server_client.Reply:
|
||||||
|
if self._converse_stream is not None:
|
||||||
|
try:
|
||||||
|
return self._converse_stream(message, on_say=self._say, on_command=self.tools)
|
||||||
|
except server_client.ServerError:
|
||||||
|
pass # nothing was shown yet; the plain path is invisible to the user
|
||||||
|
return self._converse(message, on_command=self.tools, on_say=self._holding)
|
||||||
|
|
||||||
|
def _say(self, sentence: str) -> None:
|
||||||
|
self._said.append(sentence)
|
||||||
|
self.ui.assistant_chunk(sentence)
|
||||||
|
|
||||||
|
def _holding(self, line: str) -> None:
|
||||||
|
self._said.append(line)
|
||||||
|
self.ui.holding(line)
|
||||||
|
|
||||||
|
# ── delivered files ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_deliveries(self, announce_empty: bool = False) -> None:
|
||||||
|
if not config.RECEIVE_FILES:
|
||||||
|
if announce_empty:
|
||||||
|
self.ui.info("File delivery is off (RECEIVE_FILES=false).")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
queued = self._list_files()
|
||||||
|
except server_client.ServerError as exc:
|
||||||
|
self.ui.warn(f"Couldn't check for delivered files: {exc}")
|
||||||
|
return
|
||||||
|
if not queued and announce_empty:
|
||||||
|
self.ui.info("Nothing queued.")
|
||||||
|
for entry in queued:
|
||||||
|
file_id, name = str(entry.get("id") or ""), str(entry.get("name") or "")
|
||||||
|
if not file_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = self._download_file(file_id)
|
||||||
|
path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data)
|
||||||
|
except (server_client.ServerError, OSError) as exc:
|
||||||
|
self.ui.warn(f"Couldn't save delivered file {name!r}: {exc}")
|
||||||
|
continue
|
||||||
|
self.ui.line(self.ui.style(f"Received {name} -> {path}", "green"))
|
||||||
|
|
||||||
|
# ── input dispatch ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def handle(self, line: str) -> bool:
|
||||||
|
"""Act on one line of input. Returns False when the user asked to leave."""
|
||||||
|
text = line.strip()
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
if text.startswith("!"):
|
||||||
|
self._local_shell(text[1:].strip())
|
||||||
|
return True
|
||||||
|
if text.startswith("/"):
|
||||||
|
return self._slash(text)
|
||||||
|
self.ui.line()
|
||||||
|
self.send(text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _local_shell(self, command: str) -> None:
|
||||||
|
if not command:
|
||||||
|
return
|
||||||
|
# The user's own command: live output, their terminal, nothing captured
|
||||||
|
# and nothing sent to Bolt.
|
||||||
|
try:
|
||||||
|
code = subprocess.run(command, shell=True, cwd=os.getcwd()).returncode
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.ui.warn("Interrupted.")
|
||||||
|
return
|
||||||
|
if code:
|
||||||
|
self.ui.info(f"[exit {code}]")
|
||||||
|
|
||||||
|
def _slash(self, text: str) -> bool:
|
||||||
|
name, _, arg = text[1:].partition(" ")
|
||||||
|
name, arg = name.lower(), arg.strip()
|
||||||
|
if name in ("exit", "quit", "q"):
|
||||||
|
return False
|
||||||
|
if name == "help":
|
||||||
|
self.ui.line(HELP)
|
||||||
|
elif name == "clear":
|
||||||
|
self.ui.line("\033[2J\033[H" if self.ui.color else "\n" * 3)
|
||||||
|
elif name == "cd":
|
||||||
|
self._cd(arg)
|
||||||
|
elif name == "pwd":
|
||||||
|
self.ui.line(os.getcwd())
|
||||||
|
elif name in ("auto", "ask"):
|
||||||
|
self.tools.mode = name
|
||||||
|
self.ui.info(
|
||||||
|
"Auto mode: commands, writes and edits run without asking."
|
||||||
|
if name == "auto" else "Ask mode: commands, writes and edits need your OK."
|
||||||
|
)
|
||||||
|
elif name == "status":
|
||||||
|
self.status()
|
||||||
|
elif name == "new":
|
||||||
|
self.ui.info(self.new_conversation())
|
||||||
|
elif name == "chats":
|
||||||
|
self.show_chats()
|
||||||
|
elif name == "chat":
|
||||||
|
self.pick_chat(arg)
|
||||||
|
elif name == "files":
|
||||||
|
self.check_deliveries(announce_empty=True)
|
||||||
|
else:
|
||||||
|
self.ui.warn(f"Unknown command /{name} - try /help.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def switch_to(self, session_id: str) -> str:
|
||||||
|
"""Make *session_id* the current chat (new or picked from the list).
|
||||||
|
Server-side history follows the id; Bolt's memory is per user, so it
|
||||||
|
carries over either way. Returns a one-line note for the UI."""
|
||||||
|
config.set_session(session_id)
|
||||||
|
self._correct_next = False # a correction belongs to the chat it was about
|
||||||
|
note = f"Chat: {session_id}"
|
||||||
|
if config.session_pinned():
|
||||||
|
note += " (DESK_SESSION_ID in .env pins a different one after a restart)"
|
||||||
|
return note
|
||||||
|
|
||||||
|
def show_chats(self) -> None:
|
||||||
|
try:
|
||||||
|
self._last_chats = self._list_chats()
|
||||||
|
except server_client.ServerError as exc:
|
||||||
|
self.ui.error(f"Error: {exc}")
|
||||||
|
return
|
||||||
|
if not self._last_chats:
|
||||||
|
self.ui.info("No chats yet.")
|
||||||
|
return
|
||||||
|
for number, chat in enumerate(self._last_chats, 1):
|
||||||
|
session_id = str(chat.get("session_id") or "")
|
||||||
|
mark = self.ui.style("*", "green") if session_id == config.SESSION_ID else " "
|
||||||
|
label = str(chat.get("label") or session_id)
|
||||||
|
when = relative_time(chat.get("last_active", ""))
|
||||||
|
self.ui.line(f"{mark}{number:>3} {self.ui.style(label, 'bold')} {self.ui.style(when, 'dim')}")
|
||||||
|
preview = clean_preview(chat.get("last_message", ""), 90)
|
||||||
|
if preview:
|
||||||
|
self.ui.line(" " + self.ui.style(preview, "dim"))
|
||||||
|
self.ui.info("/chat <number> to switch, /new for a fresh one")
|
||||||
|
|
||||||
|
def pick_chat(self, arg: str) -> None:
|
||||||
|
arg = arg.strip()
|
||||||
|
if not arg:
|
||||||
|
self.ui.warn("Usage: /chat <number from /chats> or /chat <session id>")
|
||||||
|
return
|
||||||
|
if arg.isdigit():
|
||||||
|
index = int(arg) - 1
|
||||||
|
if not 0 <= index < len(self._last_chats):
|
||||||
|
self.ui.warn("No such number; run /chats first.")
|
||||||
|
return
|
||||||
|
chat = self._last_chats[index]
|
||||||
|
session_id, label = str(chat.get("session_id")), str(chat.get("label") or "")
|
||||||
|
else:
|
||||||
|
session_id, label = arg, arg
|
||||||
|
self.ui.info(f"{self.switch_to(session_id)} ({label})")
|
||||||
|
|
||||||
|
def new_conversation(self) -> str:
|
||||||
|
"""A fresh chat: empty history, so nothing said earlier (including
|
||||||
|
invented tool results) is there to be copied. The server mints the id
|
||||||
|
(POST /desk/chats); if it can't, a local id still gives a clean slate.
|
||||||
|
Blocking — the window runs create_chat on a worker and calls
|
||||||
|
switch_to() itself instead."""
|
||||||
|
try:
|
||||||
|
return "New chat. " + self.switch_to(self._create_chat())
|
||||||
|
except server_client.ServerError as exc:
|
||||||
|
note = self.switch_to(config.new_session())
|
||||||
|
return f"New chat (made locally; the server couldn't: {exc}). {note}"
|
||||||
|
|
||||||
|
def _cd(self, arg: str) -> None:
|
||||||
|
target = Path(arg or str(Path.home())).expanduser()
|
||||||
|
try:
|
||||||
|
os.chdir(target)
|
||||||
|
except OSError as exc:
|
||||||
|
self.ui.error(f"cd: {exc}")
|
||||||
|
return
|
||||||
|
self.ui.info(os.getcwd())
|
||||||
|
|
||||||
|
def status(self) -> None:
|
||||||
|
rows = [
|
||||||
|
("server", config.SERVER_URL or "(not set)"),
|
||||||
|
("session", config.SESSION_ID),
|
||||||
|
("approval", self.tools.mode),
|
||||||
|
("cwd", os.getcwd()),
|
||||||
|
("streaming", "on" if self._converse_stream else "off"),
|
||||||
|
("deliveries", str(config.DELIVERED_FILES_DIR) if config.RECEIVE_FILES else "off"),
|
||||||
|
]
|
||||||
|
for key, value in rows:
|
||||||
|
self.ui.line(f" {self.ui.style(key.ljust(11), 'dim')}{value}")
|
||||||
|
|
||||||
|
# ── the loop ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def banner(self) -> None:
|
||||||
|
ui = self.ui
|
||||||
|
ui.line(ui.style(f"{ui.glyphs.bullet} Bolt Console", "bold", "cyan") + ui.style(f" v{__version__}", "dim"))
|
||||||
|
ui.info(f" {config.SERVER_URL} | session {config.SESSION_ID} | approval: {self.tools.mode}")
|
||||||
|
ui.info(f" cwd: {os.getcwd()}")
|
||||||
|
ui.info(" /help for commands, /exit to leave")
|
||||||
|
ui.line()
|
||||||
|
|
||||||
|
def loop(self) -> int:
|
||||||
|
self.banner()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = self.ui.read_message()
|
||||||
|
except EOFError:
|
||||||
|
self.ui.line()
|
||||||
|
return 0
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# Ctrl+C at an empty prompt clears the line, like a shell.
|
||||||
|
self.ui.line()
|
||||||
|
continue
|
||||||
|
if not self.handle(line):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _usage() -> str:
|
||||||
|
return (
|
||||||
|
"usage: python -m bolt_console [--auto] [-p MESSAGE] [--doctor [--deep]]\n"
|
||||||
|
" -p MESSAGE send one message, print the reply, exit\n"
|
||||||
|
" --auto don't ask before commands, writes and edits\n"
|
||||||
|
" --doctor check the install (--deep also contacts the server)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(argv: Optional[list] = None) -> int:
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if "-h" in argv or "--help" in argv:
|
||||||
|
print(_usage())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
enable_windows_ansi()
|
||||||
|
|
||||||
|
ui = Terminal(color=config.COLOR)
|
||||||
|
if not config.is_configured():
|
||||||
|
ui.error("Missing " + ", ".join(config.missing_config()) + ". Copy .env.example to .env and fill them in.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
mode = "auto" if "--auto" in argv else config.APPROVAL_MODE
|
||||||
|
session = Session(ui, tools=Tools(ui, mode=mode))
|
||||||
|
|
||||||
|
if "-p" in argv:
|
||||||
|
index = argv.index("-p")
|
||||||
|
message = " ".join(argv[index + 1:]).strip()
|
||||||
|
if not message:
|
||||||
|
ui.error("-p needs a message")
|
||||||
|
return 2
|
||||||
|
session.send(message)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return session.loop()
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""Dictation: speak into the mic, get text in the composer.
|
||||||
|
|
||||||
|
Same route as Bolt-Pet: 16 kHz mono PCM streamed over a websocket to the Bolt
|
||||||
|
server's own `/desk/stt` relay, which forwards it to Deepgram and sends
|
||||||
|
Deepgram's JSON back untouched. No Deepgram account or key here — auth is the
|
||||||
|
desk API key — though the server does meter it against the same credits as a
|
||||||
|
chat turn.
|
||||||
|
|
||||||
|
One `Dictation` is one recording:
|
||||||
|
|
||||||
|
d = Dictation(on_update, on_level, on_done)
|
||||||
|
d.start() # mic opens, audio streams up, on_update(final, interim) as words arrive
|
||||||
|
d.stop() # or silence / the length cap stops it; on_done(text, error) follows
|
||||||
|
d.cancel() # stop and throw the result away
|
||||||
|
|
||||||
|
Design points:
|
||||||
|
|
||||||
|
- **Live, with a safety net.** Audio goes up as it's captured so the words
|
||||||
|
appear while you talk. Every frame is also buffered, and if the socket
|
||||||
|
never opened or died, stop() opens a fresh one and sends the whole buffer —
|
||||||
|
Bolt-Pet's one-shot fallback.
|
||||||
|
- **You decide when it's done**, with help: a click stops it, and so do
|
||||||
|
`silence_seconds` of quiet after you've said something, or `max_seconds`.
|
||||||
|
- **No Qt, no numpy.** Callbacks run on background threads; the window turns
|
||||||
|
them into signals. Audio is raw int16 bytes, and loudness is computed with
|
||||||
|
the array module. That keeps this identical in the single-file Linux app.
|
||||||
|
- **Everything with hardware or network is injected** (`connect`,
|
||||||
|
`open_stream`), so the whole flow is tested without a mic or a server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import array
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
BLOCK_FRAMES = 1280 # 80 ms, same chunking as Bolt-Pet
|
||||||
|
SPEECH_RMS = 500 # int16 RMS above this counts as talking
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable_reason() -> Optional[str]:
|
||||||
|
"""None if dictation can work here, otherwise a sentence saying why —
|
||||||
|
shown as the mic button's tooltip instead of a crash."""
|
||||||
|
if not config.is_configured():
|
||||||
|
return "Set BOLT_SERVER_URL and DESK_API_KEY to use dictation"
|
||||||
|
try:
|
||||||
|
import websocket # noqa: F401 (websocket-client)
|
||||||
|
except Exception:
|
||||||
|
return "Dictation needs websocket-client: pip install websocket-client"
|
||||||
|
try:
|
||||||
|
import sounddevice # noqa: F401
|
||||||
|
except OSError:
|
||||||
|
return ("Dictation needs the PortAudio library (e.g. sudo apt install libportaudio2)"
|
||||||
|
if sys.platform.startswith("linux") else "PortAudio isn't available on this system")
|
||||||
|
except Exception:
|
||||||
|
return "Dictation needs sounddevice: pip install sounddevice"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mic_device():
|
||||||
|
"""MIC_DEVICE from .env: an index (as saved by Bolt-Pet's mic picker) or a
|
||||||
|
name substring; blank means the system default."""
|
||||||
|
raw = str(getattr(config, "MIC_DEVICE", "") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return int(raw) if raw.lstrip("-").isdigit() else raw
|
||||||
|
|
||||||
|
|
||||||
|
def connect(sample_rate: int = SAMPLE_RATE):
|
||||||
|
"""Open the server's /desk/stt websocket for one recording."""
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"session_id": config.SESSION_ID, "encoding": "linear16", "sample_rate": sample_rate,
|
||||||
|
})
|
||||||
|
url = config.SERVER_URL.replace("http", "ws", 1) + "/desk/stt?" + query
|
||||||
|
return websocket.create_connection(url, header=[f"X-Desk-Api-Key: {config.API_KEY}"], timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def open_stream(callback: Callable[[bytes], None], sample_rate: int = SAMPLE_RATE):
|
||||||
|
"""Start the default (or MIC_DEVICE) microphone, handing raw int16 mono
|
||||||
|
bytes to *callback* from PortAudio's thread. Returns something with
|
||||||
|
stop() and close()."""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
stream = sd.RawInputStream(
|
||||||
|
samplerate=sample_rate, channels=1, dtype="int16", blocksize=BLOCK_FRAMES,
|
||||||
|
device=mic_device(), callback=lambda data, _frames, _time, _status: callback(bytes(data)),
|
||||||
|
)
|
||||||
|
stream.start()
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
def rms(chunk: bytes) -> float:
|
||||||
|
samples = array.array("h")
|
||||||
|
samples.frombytes(chunk[: len(chunk) - len(chunk) % 2])
|
||||||
|
if not samples:
|
||||||
|
return 0.0
|
||||||
|
return math.sqrt(sum(s * s for s in samples) / len(samples))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_result(message) -> tuple:
|
||||||
|
"""(text, is_final) from a Deepgram results frame; ("", False) for
|
||||||
|
anything else. Never raises — it runs on the reader thread."""
|
||||||
|
try:
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
message = message.decode("utf-8", "ignore")
|
||||||
|
data = json.loads(message)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "", False
|
||||||
|
if not isinstance(data, dict) or data.get("type") not in (None, "Results"):
|
||||||
|
return "", False
|
||||||
|
alternatives = (data.get("channel") or {}).get("alternatives") or []
|
||||||
|
if not alternatives:
|
||||||
|
return "", False
|
||||||
|
text = str((alternatives[0] or {}).get("transcript") or "").strip()
|
||||||
|
return text, bool(data.get("is_final") or data.get("speech_final"))
|
||||||
|
|
||||||
|
|
||||||
|
class Dictation:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_update: Callable[[str, str], None],
|
||||||
|
on_level: Callable[[float], None],
|
||||||
|
on_done: Callable[[str, Optional[str]], None],
|
||||||
|
*,
|
||||||
|
connect_fn: Callable = None,
|
||||||
|
open_stream_fn: Callable = None,
|
||||||
|
silence_seconds: float = 3.0,
|
||||||
|
max_seconds: float = 300.0,
|
||||||
|
sample_rate: int = SAMPLE_RATE,
|
||||||
|
on_stopping: Callable[[], None] = None,
|
||||||
|
):
|
||||||
|
self._on_update, self._on_level, self._on_done = on_update, on_level, on_done
|
||||||
|
# Recording over (click, silence or cap), transcript not back yet.
|
||||||
|
self._on_stopping = on_stopping or (lambda: None)
|
||||||
|
self._connect = connect_fn or connect
|
||||||
|
self._open_stream = open_stream_fn or open_stream
|
||||||
|
self._silence_seconds = silence_seconds
|
||||||
|
self._max_seconds = max_seconds
|
||||||
|
self._rate = sample_rate
|
||||||
|
self._frames: "queue.Queue[bytes]" = queue.Queue()
|
||||||
|
self._buffer: List[bytes] = []
|
||||||
|
self._finals: List[str] = []
|
||||||
|
self._interim = ""
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._cancelled = False
|
||||||
|
self._socket = None
|
||||||
|
self._socket_ok = False
|
||||||
|
self._stream = None
|
||||||
|
self._reader = None
|
||||||
|
self._pump = None
|
||||||
|
|
||||||
|
# ── control (any thread) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Open the mic (raises if that fails — nothing to record without it)
|
||||||
|
and try the live socket (failure there is fine: the fallback has the
|
||||||
|
audio)."""
|
||||||
|
try:
|
||||||
|
self._socket = self._connect(self._rate)
|
||||||
|
self._socket_ok = True
|
||||||
|
self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
except Exception:
|
||||||
|
self._socket, self._socket_ok = None, False
|
||||||
|
try:
|
||||||
|
self._stream = self._open_stream(self._frames.put, self._rate)
|
||||||
|
except Exception:
|
||||||
|
self._close_socket()
|
||||||
|
raise
|
||||||
|
self._pump = threading.Thread(target=self._pump_loop, name="stt-pump", daemon=True)
|
||||||
|
self._pump.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self._cancelled = True
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live(self) -> bool:
|
||||||
|
"""Whether words are arriving as you speak (vs. only after stop)."""
|
||||||
|
return self._socket_ok
|
||||||
|
|
||||||
|
# ── the pump: mic frames -> socket, buffer, level, auto-stop ─────────
|
||||||
|
|
||||||
|
def _pump_loop(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
heard_speech, last_speech = False, started
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
chunk = self._frames.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
chunk = None
|
||||||
|
now = time.monotonic()
|
||||||
|
if chunk:
|
||||||
|
self._buffer.append(chunk)
|
||||||
|
self._send(chunk)
|
||||||
|
loudness = rms(chunk)
|
||||||
|
self._safe(self._on_level, min(1.0, math.sqrt(loudness / 8000.0)))
|
||||||
|
if loudness >= SPEECH_RMS:
|
||||||
|
heard_speech, last_speech = True, now
|
||||||
|
if heard_speech and now - last_speech >= self._silence_seconds:
|
||||||
|
break
|
||||||
|
if now - started >= self._max_seconds:
|
||||||
|
break
|
||||||
|
self._finish()
|
||||||
|
|
||||||
|
def _send(self, chunk: bytes) -> None:
|
||||||
|
if not self._socket_ok:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._socket.send_binary(chunk)
|
||||||
|
except Exception:
|
||||||
|
self._socket_ok = False # the fallback will send the buffer instead
|
||||||
|
|
||||||
|
def _finish(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._safe(self._on_stopping)
|
||||||
|
for step in ("stop", "close"):
|
||||||
|
try:
|
||||||
|
getattr(self._stream, step)()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._safe(self._on_level, 0.0)
|
||||||
|
if self._cancelled:
|
||||||
|
self._close_socket()
|
||||||
|
self._safe(self._on_done, "", None)
|
||||||
|
return
|
||||||
|
# Whatever was still queued when we stopped belongs to the recording.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = self._frames.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
self._buffer.append(chunk)
|
||||||
|
self._send(chunk)
|
||||||
|
|
||||||
|
# Live socket healthy to the end: its transcript is the answer, even
|
||||||
|
# an empty one (silence) — re-sending would pay to transcribe it twice.
|
||||||
|
error = None
|
||||||
|
if self._socket_ok:
|
||||||
|
self._flush_socket()
|
||||||
|
else:
|
||||||
|
error = self._fallback()
|
||||||
|
text = self._text()
|
||||||
|
if not text and error is None:
|
||||||
|
error = "Didn't catch anything — try again a little closer to the mic."
|
||||||
|
self._safe(self._on_done, text, error)
|
||||||
|
|
||||||
|
def _flush_socket(self) -> None:
|
||||||
|
"""Deepgram flushes its last results after CloseStream; wait for them,
|
||||||
|
bounded so a hung socket can't hold the text hostage."""
|
||||||
|
try:
|
||||||
|
self._socket.send(json.dumps({"type": "CloseStream"}))
|
||||||
|
except Exception:
|
||||||
|
self._socket_ok = False
|
||||||
|
if self._reader is not None:
|
||||||
|
self._reader.join(timeout=4)
|
||||||
|
self._close_socket()
|
||||||
|
|
||||||
|
def _fallback(self) -> Optional[str]:
|
||||||
|
"""The live socket never worked or died part-way: one fresh connection,
|
||||||
|
the whole buffer, from an empty transcript (a half-live session's words
|
||||||
|
would otherwise come back twice)."""
|
||||||
|
self._close_socket()
|
||||||
|
if self._reader is not None:
|
||||||
|
self._reader.join(timeout=1)
|
||||||
|
with self._lock:
|
||||||
|
self._finals, self._interim = [], ""
|
||||||
|
if not self._buffer:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
self._socket = self._connect(self._rate)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Couldn't reach the transcription server: {exc}"
|
||||||
|
self._socket_ok = True
|
||||||
|
self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
audio = b"".join(self._buffer)
|
||||||
|
for offset in range(0, len(audio), BLOCK_FRAMES * 2 * 8):
|
||||||
|
self._send(audio[offset:offset + BLOCK_FRAMES * 2 * 8])
|
||||||
|
self._flush_socket()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _close_socket(self) -> None:
|
||||||
|
socket, self._socket = self._socket, None
|
||||||
|
if socket is not None:
|
||||||
|
try:
|
||||||
|
socket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── the reader: transcripts back ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_loop(self) -> None:
|
||||||
|
socket = self._socket
|
||||||
|
while socket is not None:
|
||||||
|
try:
|
||||||
|
message = socket.recv()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not message:
|
||||||
|
break
|
||||||
|
text, is_final = parse_result(message)
|
||||||
|
with self._lock:
|
||||||
|
if is_final:
|
||||||
|
if text:
|
||||||
|
self._finals.append(text)
|
||||||
|
self._interim = ""
|
||||||
|
elif text:
|
||||||
|
self._interim = text
|
||||||
|
finals, interim = " ".join(self._finals), self._interim
|
||||||
|
if text:
|
||||||
|
self._safe(self._on_update, finals, interim)
|
||||||
|
|
||||||
|
def _text(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
# An interim that never became final is still better than nothing.
|
||||||
|
return " ".join(self._finals + ([self._interim] if self._interim else [])).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe(callback, *args) -> None:
|
||||||
|
try:
|
||||||
|
callback(*args)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""`python -m bolt_console --doctor` — is this install actually going to work?
|
||||||
|
|
||||||
|
Carried over from Bolt-Pet, where a missing port publish, a wrong reverse-proxy
|
||||||
|
header and a venv whose python was a zero-byte file all presented as "it's
|
||||||
|
being weird" and each took a conversation to find.
|
||||||
|
|
||||||
|
Each check is independent and reports one of three things — ok, a warning
|
||||||
|
(works, but degraded), or a failure (this will not do what you expect) — and
|
||||||
|
says *what to do about it* rather than just what is wrong. Nothing here raises:
|
||||||
|
a doctor that crashes on a broken install is diagnosing the wrong patient.
|
||||||
|
|
||||||
|
Deliberately does not touch the network by default — it's the first thing you
|
||||||
|
reach for when the network is what's broken. `--deep` contacts the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
OK, WARN, FAIL = "ok", "warn", "fail"
|
||||||
|
|
||||||
|
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Check:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
detail: str = ""
|
||||||
|
fix: str = ""
|
||||||
|
|
||||||
|
def line(self) -> str:
|
||||||
|
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
|
||||||
|
if self.fix and self.status != OK:
|
||||||
|
text += f"\n{'':32}→ {self.fix}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ── the checks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_config() -> Check:
|
||||||
|
missing = config.missing_config()
|
||||||
|
if missing:
|
||||||
|
return Check("server config", FAIL, f"missing {', '.join(missing)}",
|
||||||
|
"set them in .env — without these the console exits at startup")
|
||||||
|
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_server(deep: bool = False) -> Check:
|
||||||
|
if not config.SERVER_URL:
|
||||||
|
return Check("server", FAIL, "no BOLT_SERVER_URL",
|
||||||
|
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
|
||||||
|
if not deep:
|
||||||
|
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
|
||||||
|
try:
|
||||||
|
from . import server_client
|
||||||
|
|
||||||
|
health = server_client.check_health(timeout=8)
|
||||||
|
return Check("server", OK, f"reachable — {health}")
|
||||||
|
except Exception as exc:
|
||||||
|
return Check("server", FAIL, f"unreachable: {exc}",
|
||||||
|
"check the URL, the key, and that the container is up")
|
||||||
|
|
||||||
|
|
||||||
|
def check_streaming_endpoint(deep: bool = False) -> Check:
|
||||||
|
"""The streamed-reply endpoint is newer than some deployed servers."""
|
||||||
|
if not config.STREAMING_REPLIES:
|
||||||
|
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
|
||||||
|
if not deep:
|
||||||
|
return Check("streamed replies", OK, "enabled (endpoint not probed)")
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{config.SERVER_URL}/desk/converse_stream",
|
||||||
|
json={"session_id": config.SESSION_ID, "text": ""},
|
||||||
|
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
|
||||||
|
"update the server, or set STREAMING_REPLIES=false to skip the probe")
|
||||||
|
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
|
||||||
|
except Exception as exc:
|
||||||
|
return Check("streamed replies", WARN, f"probe failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_python() -> Check:
|
||||||
|
version = ".".join(str(n) for n in sys.version_info[:3])
|
||||||
|
if sys.version_info < (3, 9):
|
||||||
|
return Check("python", FAIL, version, "Python 3.9 or newer is required")
|
||||||
|
return Check("python", OK, f"{version} at {sys.executable}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_approval() -> Check:
|
||||||
|
mode = config.APPROVAL_MODE
|
||||||
|
if mode == "auto":
|
||||||
|
return Check("approval", WARN, "auto — Bolt's commands, writes and edits run without asking",
|
||||||
|
"APPROVAL_MODE=ask to confirm each one (or /ask for one session)")
|
||||||
|
if mode != "ask":
|
||||||
|
return Check("approval", WARN, f"unknown APPROVAL_MODE={mode!r}, treated as ask",
|
||||||
|
"use ask or auto")
|
||||||
|
return Check("approval", OK, "ask before commands, writes and edits")
|
||||||
|
|
||||||
|
|
||||||
|
def check_deliveries() -> Check:
|
||||||
|
if not config.RECEIVE_FILES:
|
||||||
|
return Check("file delivery", WARN, "disabled (RECEIVE_FILES=false)")
|
||||||
|
directory = config.DELIVERED_FILES_DIR
|
||||||
|
probe = directory
|
||||||
|
while not probe.exists() and probe.parent != probe:
|
||||||
|
probe = probe.parent
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.access(str(probe), os.W_OK):
|
||||||
|
return Check("file delivery", FAIL, f"can't write under {probe}",
|
||||||
|
"set DELIVERED_FILES_DIR to a folder you can write to")
|
||||||
|
return Check("file delivery", OK, str(directory))
|
||||||
|
|
||||||
|
|
||||||
|
CHECKS = (
|
||||||
|
("python", check_python),
|
||||||
|
("config", check_config),
|
||||||
|
("server", check_server),
|
||||||
|
("stream", check_streaming_endpoint),
|
||||||
|
("approval", check_approval),
|
||||||
|
("deliveries", check_deliveries),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(deep: bool = False) -> list:
|
||||||
|
results = []
|
||||||
|
for _name, check in CHECKS:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
results.append(check(deep))
|
||||||
|
except TypeError:
|
||||||
|
results.append(check())
|
||||||
|
except Exception as exc: # a broken check must not hide the others
|
||||||
|
results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list] = None) -> int:
|
||||||
|
argv = list(argv if argv is not None else sys.argv[1:])
|
||||||
|
deep = "--deep" in argv
|
||||||
|
print(f"Bolt Console preflight{' (deep: contacting the server)' if deep else ''}\n")
|
||||||
|
results = run(deep=deep)
|
||||||
|
for check in results:
|
||||||
|
print(check.line())
|
||||||
|
failures = [c for c in results if c.status == FAIL]
|
||||||
|
warnings = [c for c in results if c.status == WARN]
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
|
||||||
|
elif warnings:
|
||||||
|
print(f"Ready. {len(warnings)} thing(s) degraded but working.")
|
||||||
|
else:
|
||||||
|
print("Everything checks out.")
|
||||||
|
return 1 if failures else 0
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Saves files the server queues via its deliver_files tool (ai/desk_api.py
|
||||||
|
in the main tmn-api repo) to a local downloads folder.
|
||||||
|
|
||||||
|
The server side of this is already generic — any desk client can list
|
||||||
|
GET /desk/files and fetch GET /desk/files/<id> (see server_client.
|
||||||
|
list_outbox_files / download_outbox_file) — so this module is just the
|
||||||
|
filesystem half: turn a server-supplied display name into a safe path and
|
||||||
|
write the bytes.
|
||||||
|
|
||||||
|
Pure filename/path logic lives here so it's testable without touching a real
|
||||||
|
mic/network; the only I/O is the final write in save().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_FALLBACK_NAME = "delivered_file"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""Reduce a server-supplied name to a bare filename. Defends against a
|
||||||
|
delivered name that's actually a path (../../etc, an absolute path, ...)
|
||||||
|
— Path(...).name strips every directory component, and anything that
|
||||||
|
collapses to nothing (or "." / "..") falls back to a generic name."""
|
||||||
|
candidate = Path(str(name or "").strip()).name
|
||||||
|
if candidate in ("", ".", ".."):
|
||||||
|
return _FALLBACK_NAME
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def unique_path(directory: Path, name: str) -> Path:
|
||||||
|
"""*name* under *directory*, suffixed " (1)", " (2)", ... if that name is
|
||||||
|
already taken — a delivered file never overwrites an earlier download."""
|
||||||
|
directory = Path(directory)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
candidate = directory / name
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
stem, suffix = candidate.stem, candidate.suffix
|
||||||
|
n = 1
|
||||||
|
while True:
|
||||||
|
candidate = directory / f"{stem} ({n}){suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
|
||||||
|
def save(directory: Path, name: str, data: bytes) -> Path:
|
||||||
|
"""Write *data* under *directory* as *name* (sanitized + uniquified),
|
||||||
|
returning the path written."""
|
||||||
|
path = unique_path(directory, sanitize_filename(name))
|
||||||
|
path.write_bytes(data)
|
||||||
|
return path
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Local file read/edit/write, intercepted from the server-relayed command
|
||||||
|
channel before it reaches a shell (see tools.Tools.handle). Whatever text the server's
|
||||||
|
"command" tool sends is just a string this repo is free to interpret before
|
||||||
|
it ever reaches subprocess — a `filectl` pseudo-command is one such
|
||||||
|
interpretation, giving the model a way to read/write/edit files on this
|
||||||
|
machine without constructing a raw shell heredoc, where quoting, `$`,
|
||||||
|
backticks, and embedded quotes make anything beyond a one-liner failure-prone.
|
||||||
|
|
||||||
|
Executing arbitrary commands already works today (that's exactly what
|
||||||
|
run_local_command/subprocess.run does) — filectl doesn't add that ability,
|
||||||
|
it only makes the read/write/edit slice of it reliable. It also doesn't
|
||||||
|
expand what the server can already do to this machine: a relayed shell
|
||||||
|
command could already overwrite any file the desktop user can write (see the
|
||||||
|
security notes in the README) — filectl is a safer *path* to the same
|
||||||
|
capability, not a new capability.
|
||||||
|
|
||||||
|
Wire format: `filectl <json>`, where <json> is a single-line, compact JSON
|
||||||
|
object — critically, ONE LINE. The server's "command" tool marker only
|
||||||
|
captures the argument up to the next newline (see TOOL_SPECS/
|
||||||
|
_extract_all_tool_calls in the main repo's ai/agents/default.py — "command"
|
||||||
|
is not declared multiline), so a marker-delimited multi-line payload (this
|
||||||
|
module's first design) silently got truncated at the first line no matter
|
||||||
|
how the prompt worded it. JSON sidesteps that for free: json.dumps() already
|
||||||
|
encodes embedded newlines as the two characters "\n", not an actual line
|
||||||
|
break, so arbitrarily multi-line file content still fits on the one physical
|
||||||
|
line the extractor captures.
|
||||||
|
|
||||||
|
filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}
|
||||||
|
filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
|
||||||
|
filectl {"op": "write", "path": "<path>", "content": "<text>"}
|
||||||
|
filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}
|
||||||
|
|
||||||
|
"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list`
|
||||||
|
exists even though a real `ls`/`dir` shell command already works, because
|
||||||
|
this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't
|
||||||
|
have to guess which listing command applies on this machine — one glob-based
|
||||||
|
op covers all three. Must be invoked as the argument to the
|
||||||
|
ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`)
|
||||||
|
— see the pet-only paragraph in the main repo's ai/desk_api.py
|
||||||
|
_system_context for the exact instruction the model is given, including the
|
||||||
|
"keep it one line" requirement.
|
||||||
|
|
||||||
|
Pure parsing (parse) is separated from the filesystem I/O (execute) so the
|
||||||
|
syntax is unit-testable without touching disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import relay_json
|
||||||
|
|
||||||
|
_PREFIXES = ("filectl", "file")
|
||||||
|
|
||||||
|
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
|
||||||
|
# for read/list, from flooding the model's context with a giant response).
|
||||||
|
_MAX_READ_BYTES = 200_000
|
||||||
|
_MAX_WRITE_BYTES = 2_000_000
|
||||||
|
_MAX_LIST_ENTRIES = 500
|
||||||
|
|
||||||
|
HELP = (
|
||||||
|
'filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
|
||||||
|
'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
|
||||||
|
'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
|
||||||
|
'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\n'
|
||||||
|
"Must be all on one line — this rides the single-line \"command\" marker."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileOpError(Exception):
|
||||||
|
"""Bad filectl syntax or a filesystem error — reported back to the
|
||||||
|
server as command output."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_file_command(command: str) -> bool:
|
||||||
|
stripped = (command or "").strip()
|
||||||
|
if not stripped:
|
||||||
|
return False
|
||||||
|
first_word = stripped.split(None, 1)[0]
|
||||||
|
return first_word.lower() in _PREFIXES
|
||||||
|
|
||||||
|
|
||||||
|
def parse(command: str) -> Optional[dict]:
|
||||||
|
"""Parse a `filectl <json>` string into an action dict, or None if this
|
||||||
|
isn't a filectl command at all (caller should try the next handler, or
|
||||||
|
fall back to a real shell command). Raises FileOpError on a filectl
|
||||||
|
command that doesn't make sense."""
|
||||||
|
if not is_file_command(command):
|
||||||
|
return None
|
||||||
|
stripped = command.strip()
|
||||||
|
_, _, rest = stripped.partition(" ")
|
||||||
|
rest = rest.strip()
|
||||||
|
if not rest or rest.lower() in ("help", "-h", "--help"):
|
||||||
|
return {"action": "help"}
|
||||||
|
|
||||||
|
# Lenient on purpose — see relay_json. A stray quote in machine-written
|
||||||
|
# JSON should not cost a turn, but the repair is reported back so the model
|
||||||
|
# is told it sent something broken while it can still learn from it.
|
||||||
|
try:
|
||||||
|
payload, repairs = relay_json.loads(rest)
|
||||||
|
except relay_json.RelayJsonError as exc:
|
||||||
|
raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
|
||||||
|
|
||||||
|
op = str(payload.get("op") or "help").lower()
|
||||||
|
# Carried on the action so execute() can tell the model what it got wrong;
|
||||||
|
# a silent repair would fix today's call and guarantee tomorrow's.
|
||||||
|
tag = {"_repairs": repairs} if repairs else {}
|
||||||
|
|
||||||
|
if op == "help":
|
||||||
|
return {"action": "help", **tag}
|
||||||
|
|
||||||
|
if op == "list":
|
||||||
|
path = _required_str(payload, "path")
|
||||||
|
pattern = payload.get("pattern", "*")
|
||||||
|
if not isinstance(pattern, str) or not pattern:
|
||||||
|
raise FileOpError('"pattern" must be a non-empty string')
|
||||||
|
return {
|
||||||
|
"action": "list", "path": path,
|
||||||
|
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if op == "read":
|
||||||
|
path = _required_str(payload, "path")
|
||||||
|
return {
|
||||||
|
"action": "read", "path": path,
|
||||||
|
"start": _line_number(payload.get("start"), "start"),
|
||||||
|
"end": _line_number(payload.get("end"), "end"), **tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if op == "write":
|
||||||
|
path = _required_str(payload, "path")
|
||||||
|
if payload.get("content") is None:
|
||||||
|
raise FileOpError('write needs "content"')
|
||||||
|
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
|
||||||
|
|
||||||
|
if op == "edit":
|
||||||
|
path = _required_str(payload, "path")
|
||||||
|
if payload.get("old") is None or payload.get("new") is None:
|
||||||
|
raise FileOpError('edit needs "old" and "new"')
|
||||||
|
old, new = str(payload["old"]), str(payload["new"])
|
||||||
|
if old == new:
|
||||||
|
raise FileOpError("old and new text are identical — nothing to edit")
|
||||||
|
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
|
||||||
|
|
||||||
|
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
||||||
|
|
||||||
|
|
||||||
|
def _required_str(payload: dict, key: str) -> str:
|
||||||
|
value = payload.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise FileOpError(f'filectl needs a non-empty "{key}"')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _line_number(value, label: str) -> Optional[int]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise FileOpError(f"{label} must be an integer line number, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def describe(action: dict) -> str:
|
||||||
|
"""A one-line summary of the action, for logs."""
|
||||||
|
kind = action.get("action")
|
||||||
|
if kind == "help":
|
||||||
|
return "[filectl] help"
|
||||||
|
return f"[filectl] {kind} {action.get('path', '')}"
|
||||||
|
|
||||||
|
|
||||||
|
def execute(action: dict) -> str:
|
||||||
|
"""Actually perform a parsed filectl action, returning the text to send
|
||||||
|
back to the server as the command's output. Raises FileOpError on any
|
||||||
|
filesystem problem, same as a bad-syntax parse error."""
|
||||||
|
kind = action.get("action")
|
||||||
|
if kind == "help":
|
||||||
|
return HELP
|
||||||
|
if kind == "list":
|
||||||
|
output = _do_list(action)
|
||||||
|
elif kind == "read":
|
||||||
|
output = _do_read(action)
|
||||||
|
elif kind == "write":
|
||||||
|
output = _do_write(action)
|
||||||
|
elif kind == "edit":
|
||||||
|
output = _do_edit(action)
|
||||||
|
else:
|
||||||
|
output = "[filectl] ok"
|
||||||
|
return output + relay_json.repair_note(action.get("_repairs") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(path_str: str) -> Path:
|
||||||
|
return Path(path_str).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_list(action: dict) -> str:
|
||||||
|
path = _resolve(action["path"])
|
||||||
|
if not path.is_dir():
|
||||||
|
raise FileOpError(f"no such directory: {path}")
|
||||||
|
pattern = action["pattern"]
|
||||||
|
glob = path.rglob if action["recursive"] else path.glob
|
||||||
|
try:
|
||||||
|
entries = sorted(glob(pattern), key=lambda p: str(p).lower())
|
||||||
|
except OSError as exc:
|
||||||
|
raise FileOpError(f"couldn't list {path}: {exc}") from exc
|
||||||
|
if not entries:
|
||||||
|
return f"[no entries matching {pattern!r} in {path}]"
|
||||||
|
truncated = len(entries) > _MAX_LIST_ENTRIES
|
||||||
|
lines = []
|
||||||
|
for entry in entries[:_MAX_LIST_ENTRIES]:
|
||||||
|
rel = entry.relative_to(path)
|
||||||
|
if entry.is_dir():
|
||||||
|
lines.append(f"{rel}/")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
size = entry.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = -1
|
||||||
|
lines.append(f"{rel}\t{size}B")
|
||||||
|
if truncated:
|
||||||
|
lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_read(action: dict) -> str:
|
||||||
|
path = _resolve(action["path"])
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileOpError(f"no such file: {path}")
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
raise FileOpError(f"couldn't read {path}: {exc}") from exc
|
||||||
|
if len(data) > _MAX_READ_BYTES:
|
||||||
|
raise FileOpError(
|
||||||
|
f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — "
|
||||||
|
'pass "start"/"end" to read a slice instead'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = max(1, action.get("start") or 1)
|
||||||
|
end = min(len(lines), action.get("end") or len(lines))
|
||||||
|
if not lines:
|
||||||
|
return "[empty file]"
|
||||||
|
return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _do_write(action: dict) -> str:
|
||||||
|
path = _resolve(action["path"])
|
||||||
|
content = action["content"]
|
||||||
|
if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
|
||||||
|
raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit")
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise FileOpError(f"couldn't write {path}: {exc}") from exc
|
||||||
|
return f"[filectl] wrote {len(content)} chars to {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _do_edit(action: dict) -> str:
|
||||||
|
path = _resolve(action["path"])
|
||||||
|
old, new = action["old"], action["new"]
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileOpError(f"no such file: {path}")
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise FileOpError(f"couldn't read {path}: {exc}") from exc
|
||||||
|
count = text.count(old)
|
||||||
|
if count == 0:
|
||||||
|
raise FileOpError(f"didn't find that text in {path} — nothing changed")
|
||||||
|
if count > 1:
|
||||||
|
raise FileOpError(
|
||||||
|
f"that text appears {count} times in {path} — filectl edit needs a unique match; "
|
||||||
|
"include more surrounding context"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise FileOpError(f"couldn't write {path}: {exc}") from exc
|
||||||
|
return f"[filectl] edited {path}"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""PySide6 front end. Imported only when the window is wanted, so the
|
||||||
|
terminal client works without Qt installed."""
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""The seam between a turn running on a worker thread and the window.
|
||||||
|
|
||||||
|
`Session` and `Tools` were written against a terminal-shaped UI (tool_call,
|
||||||
|
tool_result, confirm, assistant_chunk, …). `Bridge` is that same shape, but
|
||||||
|
every call becomes a Qt signal — delivered to the window on the GUI thread
|
||||||
|
through a queued connection — so the whole turn logic is shared, unchanged,
|
||||||
|
between the terminal and the window.
|
||||||
|
|
||||||
|
The one call that has to *return* something is confirm(): the worker blocks on
|
||||||
|
an Event until the user clicks Allow/Deny in the window, which calls answer()
|
||||||
|
from the GUI thread. close() releases a pending wait so the app can quit mid
|
||||||
|
approval without hanging the worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
|
||||||
|
class Bridge(QObject):
|
||||||
|
busy = Signal(bool)
|
||||||
|
chunk = Signal(str) # one streamed sentence
|
||||||
|
reply_ended = Signal() # the streamed block is complete
|
||||||
|
reply = Signal(str) # a whole (non-streamed) reply
|
||||||
|
holding_line = Signal(str) # "give me a sec" while a tool runs
|
||||||
|
tool_started = Signal(int, str, str) # id, name, argument
|
||||||
|
tool_output = Signal(int, str, bool) # id, text, is_error
|
||||||
|
tool_diff = Signal(int, str, str) # id, old, new
|
||||||
|
approval_needed = Signal(int, str) # id, question
|
||||||
|
notice = Signal(str, str) # level (info/warn/error), text
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._tool_id = 0
|
||||||
|
self._answer = "no"
|
||||||
|
self._answered = threading.Event()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
# ── the terminal-shaped surface Session and Tools call ───────────────
|
||||||
|
|
||||||
|
def start_spinner(self, label: str = "Thinking") -> None:
|
||||||
|
self.busy.emit(True)
|
||||||
|
|
||||||
|
def stop_spinner(self) -> None:
|
||||||
|
self.busy.emit(False)
|
||||||
|
|
||||||
|
def assistant_chunk(self, sentence: str) -> None:
|
||||||
|
if sentence.strip():
|
||||||
|
self.chunk.emit(sentence.strip())
|
||||||
|
|
||||||
|
def end_reply(self) -> None:
|
||||||
|
self.reply_ended.emit()
|
||||||
|
|
||||||
|
def assistant(self, text: str) -> None:
|
||||||
|
if text.strip():
|
||||||
|
self.reply.emit(text.strip())
|
||||||
|
|
||||||
|
def holding(self, text: str) -> None:
|
||||||
|
self.holding_line.emit(text.strip())
|
||||||
|
|
||||||
|
def tool_call(self, name: str, argument: str) -> None:
|
||||||
|
self._tool_id += 1
|
||||||
|
self.tool_started.emit(self._tool_id, name, argument)
|
||||||
|
|
||||||
|
def tool_result(self, text: str, error: bool = False) -> None:
|
||||||
|
self.tool_output.emit(self._tool_id, text, error)
|
||||||
|
|
||||||
|
def diff(self, old: str, new: str) -> None:
|
||||||
|
self.tool_diff.emit(self._tool_id, old, new)
|
||||||
|
|
||||||
|
def confirm(self, question: str) -> str:
|
||||||
|
if self._closed:
|
||||||
|
return "no"
|
||||||
|
self._answered.clear()
|
||||||
|
self._answer = "no"
|
||||||
|
self.approval_needed.emit(self._tool_id, question)
|
||||||
|
self._answered.wait()
|
||||||
|
return self._answer
|
||||||
|
|
||||||
|
def info(self, text: str) -> None:
|
||||||
|
self.notice.emit("info", text)
|
||||||
|
|
||||||
|
def warn(self, text: str) -> None:
|
||||||
|
self.notice.emit("warn", text)
|
||||||
|
|
||||||
|
def error(self, text: str) -> None:
|
||||||
|
self.notice.emit("error", text)
|
||||||
|
|
||||||
|
def line(self, text: str = "") -> None:
|
||||||
|
if text.strip():
|
||||||
|
self.notice.emit("info", text.strip())
|
||||||
|
|
||||||
|
def style(self, text: str, *_names: str) -> str:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# ── called from the GUI thread ───────────────────────────────────────
|
||||||
|
|
||||||
|
def answer(self, value: str) -> None:
|
||||||
|
self._answer = value if value in ("yes", "no", "always") else "no"
|
||||||
|
self._answered.set()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
self.answer("no")
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""The Chats dialog: every conversation this user has with Bolt, across the
|
||||||
|
console, the pet and the phone, newest first, and a New Chat button.
|
||||||
|
|
||||||
|
Network calls never run on the UI thread. Each one is a `ChatTask` on the
|
||||||
|
global QThreadPool that calls server_client synchronously and hands the
|
||||||
|
result back through `ChatSignals`, the same signal-based hand-off the turn
|
||||||
|
worker uses. The list is fetched when the dialog opens and on Refresh; there
|
||||||
|
is no polling.
|
||||||
|
|
||||||
|
Picking or creating a chat doesn't switch anything here. The dialog emits
|
||||||
|
`chosen(session_id, label)` and the window does the switch, because the
|
||||||
|
window owns the transcript that has to be cleared along with it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
|
||||||
|
QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import config, server_client
|
||||||
|
from ..chats import clean_preview, local_time, relative_time
|
||||||
|
from . import icons, theme
|
||||||
|
|
||||||
|
# ── off-thread calls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ChatSignals(QObject):
|
||||||
|
loaded = Signal(list) # chats, newest first
|
||||||
|
created = Signal(str) # the new session_id
|
||||||
|
failed = Signal(str, str) # operation ("list" / "create"), message
|
||||||
|
|
||||||
|
|
||||||
|
class ChatTask(QRunnable):
|
||||||
|
"""Runs one server_client call on the thread pool and reports back."""
|
||||||
|
|
||||||
|
def __init__(self, operation: str, call: Callable, signals: ChatSignals):
|
||||||
|
super().__init__()
|
||||||
|
self.operation, self.call, self.signals = operation, call, signals
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.call()
|
||||||
|
except Exception as exc: # ServerError, or anything unexpected
|
||||||
|
self.signals.failed.emit(self.operation, str(exc))
|
||||||
|
return
|
||||||
|
if self.operation == "list":
|
||||||
|
self.signals.loaded.emit(list(result))
|
||||||
|
else:
|
||||||
|
self.signals.created.emit(str(result))
|
||||||
|
|
||||||
|
|
||||||
|
def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None:
|
||||||
|
QThreadPool.globalInstance().start(ChatTask(operation, call, signals))
|
||||||
|
|
||||||
|
|
||||||
|
# ── the dialog ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ChatRow(QWidget):
|
||||||
|
"""Label and time on the first line, the last message under it."""
|
||||||
|
|
||||||
|
def __init__(self, chat: dict, current: bool, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(14, 9, 14, 9)
|
||||||
|
outer.setSpacing(2)
|
||||||
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled"))
|
||||||
|
title.setObjectName("ChatTitle")
|
||||||
|
top.addWidget(title, 1)
|
||||||
|
if current:
|
||||||
|
badge = QLabel("Current")
|
||||||
|
badge.setObjectName("Pill")
|
||||||
|
badge.setProperty("kind", "waiting")
|
||||||
|
top.addWidget(badge)
|
||||||
|
when = QLabel(relative_time(chat.get("last_active", "")))
|
||||||
|
when.setObjectName("ChatTime")
|
||||||
|
top.addWidget(when)
|
||||||
|
outer.addLayout(top)
|
||||||
|
preview = QLabel(clean_preview(chat.get("last_message", "")) or "—")
|
||||||
|
preview.setObjectName("ChatPreview")
|
||||||
|
outer.addWidget(preview)
|
||||||
|
for widget in (title, when, preview):
|
||||||
|
widget.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatListDialog(QDialog):
|
||||||
|
"""`chosen(session_id, label)` fires for a picked chat and for a newly
|
||||||
|
created one (label "New chat"); the window does the actual switch."""
|
||||||
|
|
||||||
|
chosen = Signal(str, str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("ChatDialog")
|
||||||
|
self.setWindowTitle("Chats")
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(560, 580)
|
||||||
|
self._list_chats = list_chats or server_client.list_chats
|
||||||
|
self._create_chat = create_chat or server_client.create_chat
|
||||||
|
self.signals = ChatSignals()
|
||||||
|
self.signals.loaded.connect(self._populate)
|
||||||
|
self.signals.created.connect(self._created)
|
||||||
|
self.signals.failed.connect(self._failed)
|
||||||
|
|
||||||
|
col = QVBoxLayout(self)
|
||||||
|
col.setContentsMargins(18, 18, 18, 16)
|
||||||
|
col.setSpacing(12)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
head.setSpacing(10)
|
||||||
|
head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT))
|
||||||
|
heading = QLabel("Chats")
|
||||||
|
heading.setObjectName("Wordmark")
|
||||||
|
head.addWidget(heading)
|
||||||
|
head.addStretch(1)
|
||||||
|
self.status = QLabel("")
|
||||||
|
self.status.setObjectName("Hint")
|
||||||
|
head.addWidget(self.status)
|
||||||
|
col.addLayout(head)
|
||||||
|
|
||||||
|
sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with "
|
||||||
|
"fresh history; Bolt still remembers you.")
|
||||||
|
sub.setObjectName("HeroSub")
|
||||||
|
sub.setWordWrap(True)
|
||||||
|
col.addWidget(sub)
|
||||||
|
|
||||||
|
self.list = QListWidget()
|
||||||
|
self.list.setObjectName("ChatList")
|
||||||
|
self.list.setUniformItemSizes(False)
|
||||||
|
self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
|
||||||
|
self.list.itemActivated.connect(self._open_item) # Enter / double-click
|
||||||
|
self.list.currentItemChanged.connect(lambda *_: self._sync_buttons())
|
||||||
|
col.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.setSpacing(8)
|
||||||
|
self.refresh_button = QPushButton("Refresh")
|
||||||
|
self.refresh_button.setObjectName("Ghost")
|
||||||
|
self.refresh_button.clicked.connect(self.refresh)
|
||||||
|
buttons.addWidget(self.refresh_button)
|
||||||
|
buttons.addStretch(1)
|
||||||
|
self.new_button = QPushButton("New Chat")
|
||||||
|
self.new_button.setObjectName("Secondary")
|
||||||
|
self.new_button.clicked.connect(self.new_chat)
|
||||||
|
buttons.addWidget(self.new_button)
|
||||||
|
self.open_button = QPushButton("Open")
|
||||||
|
self.open_button.setObjectName("Primary")
|
||||||
|
self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem()))
|
||||||
|
buttons.addWidget(self.open_button)
|
||||||
|
col.addLayout(buttons)
|
||||||
|
for button in (self.refresh_button, self.new_button, self.open_button):
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
self._busy = False
|
||||||
|
self._sync_buttons()
|
||||||
|
|
||||||
|
# ── loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def showEvent(self, event) -> None:
|
||||||
|
super().showEvent(event)
|
||||||
|
theme.dark_title_bar(self) # same caption colour as the main window on Windows
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._set_busy(True, "Loading…")
|
||||||
|
run_in_pool("list", self._list_chats, self.signals)
|
||||||
|
|
||||||
|
def _populate(self, chats: list) -> None:
|
||||||
|
self._set_busy(False, "")
|
||||||
|
self.list.clear()
|
||||||
|
current = None
|
||||||
|
for chat in chats:
|
||||||
|
session_id = str(chat.get("session_id") or "")
|
||||||
|
if not session_id:
|
||||||
|
continue
|
||||||
|
is_current = session_id == config.SESSION_ID
|
||||||
|
item = QListWidgetItem()
|
||||||
|
item.setData(Qt.UserRole, session_id)
|
||||||
|
item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id))
|
||||||
|
item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}")
|
||||||
|
row = ChatRow(chat, is_current)
|
||||||
|
item.setSizeHint(QSize(0, row.sizeHint().height()))
|
||||||
|
self.list.addItem(item)
|
||||||
|
self.list.setItemWidget(item, row)
|
||||||
|
if is_current:
|
||||||
|
current = item
|
||||||
|
if self.list.count() == 0:
|
||||||
|
self.status.setText("No chats yet")
|
||||||
|
self.list.setCurrentItem(current or self.list.item(0))
|
||||||
|
self._sync_buttons()
|
||||||
|
|
||||||
|
def _failed(self, operation: str, message: str) -> None:
|
||||||
|
self._set_busy(False, "")
|
||||||
|
what = "load chats" if operation == "list" else "start a new chat"
|
||||||
|
self.status.setText(f"Couldn't {what}")
|
||||||
|
self.status.setToolTip(message)
|
||||||
|
self.status.setStyleSheet(f"color: {theme.RED};")
|
||||||
|
|
||||||
|
# ── actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def new_chat(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._set_busy(True, "Starting a new chat…")
|
||||||
|
run_in_pool("create", self._create_chat, self.signals)
|
||||||
|
|
||||||
|
def _created(self, session_id: str) -> None:
|
||||||
|
# A new chat isn't listed until it's used, so there's nothing to show:
|
||||||
|
# hand it to the window and close.
|
||||||
|
self._set_busy(False, "")
|
||||||
|
self.chosen.emit(session_id, "New chat")
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _open_item(self, item: Optional[QListWidgetItem]) -> None:
|
||||||
|
if item is None or self._busy:
|
||||||
|
return
|
||||||
|
self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1))
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool, status: str) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
self.status.setText(status)
|
||||||
|
self.status.setToolTip("")
|
||||||
|
self.status.setStyleSheet("")
|
||||||
|
self._sync_buttons()
|
||||||
|
|
||||||
|
def _sync_buttons(self) -> None:
|
||||||
|
self.refresh_button.setEnabled(not self._busy)
|
||||||
|
self.new_button.setEnabled(not self._busy)
|
||||||
|
self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Icons drawn with QPainter rather than shipped as files or borrowed from an
|
||||||
|
emoji font — crisp at any DPI, recolourable, and no assets to lose.
|
||||||
|
|
||||||
|
`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)`
|
||||||
|
wraps that as a QIcon; `IconLabel` is a tiny widget that shows one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
def _pen(colour: QColor, width: float) -> QPen:
|
||||||
|
pen = QPen(colour, width)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.RoundJoin)
|
||||||
|
return pen
|
||||||
|
|
||||||
|
|
||||||
|
def paint(p: QPainter, name: str, rect: QRectF, colour) -> None:
|
||||||
|
colour = QColor(colour)
|
||||||
|
p.save()
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
# Everything below is drawn on a 24x24 grid, then scaled into *rect*.
|
||||||
|
side = min(rect.width(), rect.height())
|
||||||
|
p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2)
|
||||||
|
p.scale(side / 24.0, side / 24.0)
|
||||||
|
stroke = _pen(colour, 1.9)
|
||||||
|
|
||||||
|
if name == "bolt":
|
||||||
|
path = QPainterPath()
|
||||||
|
for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22),
|
||||||
|
(19.5, 9.5), (13, 9.5), (13.5, 2)]):
|
||||||
|
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(colour)
|
||||||
|
p.drawPath(path)
|
||||||
|
elif name == "send":
|
||||||
|
p.setPen(_pen(colour, 2.4))
|
||||||
|
p.drawLine(QPointF(12, 19), QPointF(12, 5.5))
|
||||||
|
p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5))
|
||||||
|
p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5))
|
||||||
|
elif name == "folder":
|
||||||
|
p.setPen(stroke)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.moveTo(3, 7)
|
||||||
|
path.lineTo(3, 18.5)
|
||||||
|
path.quadTo(3, 20, 4.5, 20)
|
||||||
|
path.lineTo(19.5, 20)
|
||||||
|
path.quadTo(21, 20, 21, 18.5)
|
||||||
|
path.lineTo(21, 9.5)
|
||||||
|
path.quadTo(21, 8, 19.5, 8)
|
||||||
|
path.lineTo(11.5, 8)
|
||||||
|
path.lineTo(9.5, 5.5)
|
||||||
|
path.lineTo(4.5, 5.5)
|
||||||
|
path.quadTo(3, 5.5, 3, 7)
|
||||||
|
p.drawPath(path)
|
||||||
|
elif name == "shell":
|
||||||
|
p.setPen(stroke)
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3)
|
||||||
|
p.drawLine(QPointF(7, 10), QPointF(10, 12.5))
|
||||||
|
p.drawLine(QPointF(10, 12.5), QPointF(7, 15))
|
||||||
|
p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5))
|
||||||
|
elif name in ("read", "write", "edit"):
|
||||||
|
p.setPen(stroke)
|
||||||
|
page = QPainterPath()
|
||||||
|
page.moveTo(14, 2.5)
|
||||||
|
page.lineTo(6, 2.5)
|
||||||
|
page.quadTo(4.5, 2.5, 4.5, 4)
|
||||||
|
page.lineTo(4.5, 20)
|
||||||
|
page.quadTo(4.5, 21.5, 6, 21.5)
|
||||||
|
page.lineTo(18, 21.5)
|
||||||
|
page.quadTo(19.5, 21.5, 19.5, 20)
|
||||||
|
page.lineTo(19.5, 8)
|
||||||
|
page.closeSubpath()
|
||||||
|
p.drawPath(page)
|
||||||
|
p.drawLine(QPointF(14, 2.5), QPointF(14, 8))
|
||||||
|
p.drawLine(QPointF(14, 8), QPointF(19.5, 8))
|
||||||
|
if name == "read":
|
||||||
|
for y in (12, 15, 18):
|
||||||
|
p.drawLine(QPointF(8, y), QPointF(16, y))
|
||||||
|
elif name == "write":
|
||||||
|
p.drawLine(QPointF(12, 11.5), QPointF(12, 18))
|
||||||
|
p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75))
|
||||||
|
else:
|
||||||
|
p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5))
|
||||||
|
p.drawLine(QPointF(8, 18), QPointF(8, 15.5))
|
||||||
|
p.drawLine(QPointF(8, 18), QPointF(10.5, 18))
|
||||||
|
elif name == "list":
|
||||||
|
p.setPen(stroke)
|
||||||
|
for y in (6.5, 12, 17.5):
|
||||||
|
p.drawPoint(QPointF(4.5, y))
|
||||||
|
p.drawLine(QPointF(8.5, y), QPointF(20, y))
|
||||||
|
elif name == "blocked":
|
||||||
|
p.setPen(stroke)
|
||||||
|
p.drawEllipse(QRectF(3.5, 3.5, 17, 17))
|
||||||
|
p.drawLine(QPointF(7, 17), QPointF(17, 7))
|
||||||
|
elif name == "chevron":
|
||||||
|
p.setPen(stroke)
|
||||||
|
p.drawLine(QPointF(9, 6), QPointF(15, 12))
|
||||||
|
p.drawLine(QPointF(15, 12), QPointF(9, 18))
|
||||||
|
elif name == "chats":
|
||||||
|
p.setPen(stroke)
|
||||||
|
bubble = QPainterPath()
|
||||||
|
bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4)
|
||||||
|
p.drawPath(bubble)
|
||||||
|
p.drawLine(QPointF(8, 17), QPointF(7, 21))
|
||||||
|
p.drawLine(QPointF(7, 21), QPointF(12, 17))
|
||||||
|
for x in (8, 12, 16):
|
||||||
|
p.drawPoint(QPointF(x, 10.5))
|
||||||
|
elif name == "mic":
|
||||||
|
p.setPen(stroke)
|
||||||
|
p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3)
|
||||||
|
arc = QPainterPath()
|
||||||
|
arc.moveTo(5.5, 11)
|
||||||
|
arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11)
|
||||||
|
p.drawPath(arc)
|
||||||
|
p.drawLine(QPointF(12, 17), QPointF(12, 21))
|
||||||
|
elif name == "stop":
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(colour)
|
||||||
|
p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
|
||||||
|
elif name == "plus":
|
||||||
|
p.setPen(_pen(colour, 2.1))
|
||||||
|
p.drawLine(QPointF(12, 5), QPointF(12, 19))
|
||||||
|
p.drawLine(QPointF(5, 12), QPointF(19, 12))
|
||||||
|
elif name == "file":
|
||||||
|
paint(p, "read", QRectF(0, 0, 24, 24), colour)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
|
||||||
|
def icon(name: str, colour, size: int = 32) -> QIcon:
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
paint(painter, name, QRectF(0, 0, size, size), colour)
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon_image(size: int) -> QImage:
|
||||||
|
"""An amber rounded square with a dark bolt, at *size* pixels. The one
|
||||||
|
drawing behind both the window icon and the .exe's icon (write_ico)."""
|
||||||
|
image = QImage(size, size, QImage.Format_ARGB32_Premultiplied)
|
||||||
|
image.fill(Qt.transparent)
|
||||||
|
p = QPainter(image)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QColor("#f5b83d"))
|
||||||
|
p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26)
|
||||||
|
inset = size * 0.18
|
||||||
|
paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305")
|
||||||
|
p.end()
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon() -> QIcon:
|
||||||
|
"""The window/taskbar icon."""
|
||||||
|
result = QIcon()
|
||||||
|
for size in APP_ICON_SIZES:
|
||||||
|
result.addPixmap(QPixmap.fromImage(app_icon_image(size)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def write_ico(path) -> None:
|
||||||
|
"""Save the app icon as a multi-size Windows .ico (PNG-compressed entries,
|
||||||
|
which Windows Vista+ reads at every size), for the frozen .exe."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
blobs = []
|
||||||
|
for size in APP_ICON_SIZES:
|
||||||
|
data = QByteArray()
|
||||||
|
buffer = QBuffer(data)
|
||||||
|
buffer.open(QIODevice.WriteOnly)
|
||||||
|
app_icon_image(size).save(buffer, "PNG")
|
||||||
|
buffer.close()
|
||||||
|
blobs.append((size, bytes(data)))
|
||||||
|
|
||||||
|
header = struct.pack("<HHH", 0, 1, len(blobs)) # reserved, type 1 = icon, count
|
||||||
|
offset = len(header) + 16 * len(blobs)
|
||||||
|
entries, payload = b"", b""
|
||||||
|
for size, blob in blobs:
|
||||||
|
edge = 0 if size >= 256 else size # 0 means 256 in the ICO format
|
||||||
|
entries += struct.pack("<BBBBHHII", edge, edge, 0, 0, 1, 32, len(blob), offset + len(payload))
|
||||||
|
payload += blob
|
||||||
|
with open(path, "wb") as handle:
|
||||||
|
handle.write(header + entries + payload)
|
||||||
|
|
||||||
|
|
||||||
|
class IconLabel(QWidget):
|
||||||
|
"""A fixed-size painted icon, optionally on a rounded tile."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, colour, size: int = 18, tile=None, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._name, self._colour, self._tile = name, QColor(colour), tile
|
||||||
|
self.setFixedSize(QSize(size, size))
|
||||||
|
|
||||||
|
def set_icon(self, name: str, colour=None) -> None:
|
||||||
|
self._name = name
|
||||||
|
if colour is not None:
|
||||||
|
self._colour = QColor(colour)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, _event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
rect = QRectF(self.rect())
|
||||||
|
if self._tile is not None:
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QColor(self._tile))
|
||||||
|
p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28)
|
||||||
|
pad = rect.width() * 0.22
|
||||||
|
rect = rect.adjusted(pad, pad, -pad, -pad)
|
||||||
|
paint(p, self._name, rect, self._colour)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Colours, fonts and the application stylesheet.
|
||||||
|
|
||||||
|
One dark theme, tuned by hand: near-black charcoal surfaces a step apart, a
|
||||||
|
single warm accent (the bolt's amber), and colour used only where it carries
|
||||||
|
meaning — green for done, red for failed, amber for "needs you".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
|
||||||
|
|
||||||
|
BG = "#0e1014" # window
|
||||||
|
SURFACE = "#15181e" # cards, composer
|
||||||
|
SURFACE_HI = "#1b1f27" # hover / user bubble
|
||||||
|
BORDER = "#252a34"
|
||||||
|
BORDER_HI = "#343b48"
|
||||||
|
TEXT = "#e7e9ee"
|
||||||
|
TEXT_DIM = "#9aa1ae"
|
||||||
|
TEXT_FAINT = "#6b7280"
|
||||||
|
ACCENT = "#f5b83d" # the bolt
|
||||||
|
ACCENT_HI = "#ffc95c"
|
||||||
|
ACCENT_INK = "#1a1305" # text on accent
|
||||||
|
GREEN = "#4ec98a"
|
||||||
|
RED = "#f06a6a"
|
||||||
|
BLUE = "#7aa7ff"
|
||||||
|
CODE = "#e8c98f" # inline code in replies
|
||||||
|
DIFF_ADD_BG = "#12291d"
|
||||||
|
DIFF_DEL_BG = "#301719"
|
||||||
|
|
||||||
|
RADIUS = 12
|
||||||
|
COLUMN_WIDTH = 820 # the transcript's reading column
|
||||||
|
|
||||||
|
|
||||||
|
def _first_available(families, fallback: str) -> str:
|
||||||
|
installed = set(QFontDatabase.families())
|
||||||
|
for family in families:
|
||||||
|
if family in installed:
|
||||||
|
return family
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def ui_font_family() -> str:
|
||||||
|
return _first_available(
|
||||||
|
["Segoe UI Variable Text", "Inter", "SF Pro Text", "Segoe UI", "Cantarell", "Ubuntu"],
|
||||||
|
QFont().defaultFamily(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mono_font_family() -> str:
|
||||||
|
return _first_available(
|
||||||
|
["Cascadia Code", "Cascadia Mono", "JetBrains Mono", "SF Mono", "Consolas",
|
||||||
|
"DejaVu Sans Mono", "Menlo"],
|
||||||
|
"monospace",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mono_font(size: float = 9.5) -> QFont:
|
||||||
|
font = QFont(mono_font_family())
|
||||||
|
font.setPointSizeF(size)
|
||||||
|
font.setStyleHint(QFont.Monospace)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def palette() -> QPalette:
|
||||||
|
pal = QPalette()
|
||||||
|
for role, colour in (
|
||||||
|
(QPalette.Window, BG), (QPalette.Base, SURFACE), (QPalette.AlternateBase, SURFACE_HI),
|
||||||
|
(QPalette.Text, TEXT), (QPalette.WindowText, TEXT), (QPalette.ButtonText, TEXT),
|
||||||
|
(QPalette.Button, SURFACE), (QPalette.Highlight, "#3a3222"),
|
||||||
|
(QPalette.HighlightedText, TEXT), (QPalette.Link, BLUE),
|
||||||
|
(QPalette.PlaceholderText, TEXT_FAINT), (QPalette.ToolTipBase, SURFACE_HI),
|
||||||
|
(QPalette.ToolTipText, TEXT),
|
||||||
|
):
|
||||||
|
pal.setColor(role, QColor(colour))
|
||||||
|
return pal
|
||||||
|
|
||||||
|
|
||||||
|
def stylesheet() -> str:
|
||||||
|
mono = mono_font_family()
|
||||||
|
return f"""
|
||||||
|
QWidget {{ color: {TEXT}; font-size: 10.5pt; }}
|
||||||
|
QMainWindow, #Root {{ background: {BG}; }}
|
||||||
|
QToolTip {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
||||||
|
border-radius: 6px; padding: 5px 8px; }}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
#Header {{ background: {BG}; border-bottom: 1px solid {BORDER}; }}
|
||||||
|
#Wordmark {{ font-size: 12pt; font-weight: 600; letter-spacing: 0.3px; }}
|
||||||
|
#WordmarkSub {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
||||||
|
#Chip {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px;
|
||||||
|
padding: 5px 12px 5px 30px; color: {TEXT_DIM}; text-align: left; }}
|
||||||
|
#Chip:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
||||||
|
#Chip:disabled {{ color: {TEXT_FAINT}; }}
|
||||||
|
#Segment {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; }}
|
||||||
|
#SegmentButton {{ background: transparent; border: none; border-radius: 12px;
|
||||||
|
padding: 4px 14px; color: {TEXT_DIM}; font-weight: 500; }}
|
||||||
|
#SegmentButton:hover {{ color: {TEXT}; }}
|
||||||
|
#SegmentButton:checked {{ background: {SURFACE_HI}; color: {TEXT}; }}
|
||||||
|
#SegmentButton[danger="true"]:checked {{ background: #3a2c12; color: {ACCENT_HI}; }}
|
||||||
|
|
||||||
|
/* transcript */
|
||||||
|
QScrollArea {{ background: transparent; border: none; }}
|
||||||
|
#Transcript {{ background: {BG}; }}
|
||||||
|
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }}
|
||||||
|
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 40px; }}
|
||||||
|
QScrollBar::handle:vertical:hover {{ background: {BORDER_HI}; }}
|
||||||
|
QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{
|
||||||
|
background: none; height: 0; }}
|
||||||
|
QScrollBar:horizontal {{ height: 0; }}
|
||||||
|
|
||||||
|
#UserBubble {{ background: {SURFACE_HI}; border: 1px solid {BORDER}; border-radius: 16px; }}
|
||||||
|
#AssistantText {{ color: {TEXT}; }}
|
||||||
|
#Holding {{ color: {TEXT_FAINT}; font-style: italic; }}
|
||||||
|
#Notice {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
||||||
|
#NoticeError {{ color: {RED}; }}
|
||||||
|
|
||||||
|
#ToolCard {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; }}
|
||||||
|
#ToolCard[state="waiting"] {{ border-color: #5a4418; }}
|
||||||
|
#ToolHeader {{ background: transparent; border: none; border-radius: {RADIUS}px; }}
|
||||||
|
#ToolHeader:hover {{ background: {SURFACE_HI}; }}
|
||||||
|
#ToolName {{ font-weight: 600; }}
|
||||||
|
#ToolArg {{ color: {TEXT_DIM}; font-family: "{mono}"; font-size: 9.5pt; }}
|
||||||
|
#ToolOutput {{ background: transparent; color: {TEXT_DIM}; border: none; border-top: 1px solid {BORDER};
|
||||||
|
font-family: "{mono}"; font-size: 9.2pt; padding: 10px 14px; }}
|
||||||
|
#Pill {{ border-radius: 9px; padding: 1px 9px; font-size: 8.5pt; font-weight: 600; }}
|
||||||
|
#Pill[kind="running"] {{ background: #1c2433; color: {BLUE}; }}
|
||||||
|
#Pill[kind="ok"] {{ background: #13271d; color: {GREEN}; }}
|
||||||
|
#Pill[kind="error"] {{ background: #2e1618; color: {RED}; }}
|
||||||
|
#Pill[kind="waiting"] {{ background: #33280f; color: {ACCENT}; }}
|
||||||
|
#Pill[kind="declined"] {{ background: #22252c; color: {TEXT_DIM}; }}
|
||||||
|
|
||||||
|
#ApprovalBar {{ border-top: 1px solid {BORDER}; background: transparent; }}
|
||||||
|
#ApprovalQuestion {{ color: {TEXT}; }}
|
||||||
|
#Primary {{ background: {ACCENT}; color: {ACCENT_INK}; border: none; border-radius: 8px;
|
||||||
|
padding: 6px 16px; font-weight: 600; }}
|
||||||
|
#Primary:hover {{ background: {ACCENT_HI}; }}
|
||||||
|
#Secondary {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
||||||
|
border-radius: 8px; padding: 6px 14px; }}
|
||||||
|
#Secondary:hover {{ border-color: {TEXT_FAINT}; }}
|
||||||
|
#Ghost {{ background: transparent; color: {TEXT_DIM}; border: none; padding: 6px 10px; }}
|
||||||
|
#Ghost:hover {{ color: {TEXT}; }}
|
||||||
|
|
||||||
|
/* empty state */
|
||||||
|
#Hero {{ font-size: 19pt; font-weight: 600; }}
|
||||||
|
#HeroSub {{ color: {TEXT_DIM}; }}
|
||||||
|
#Suggestion {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 12px;
|
||||||
|
padding: 12px 14px; color: {TEXT_DIM}; text-align: left; }}
|
||||||
|
#Suggestion:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
||||||
|
|
||||||
|
/* composer */
|
||||||
|
#Composer {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 18px; }}
|
||||||
|
#Composer[focused="true"] {{ border-color: {BORDER_HI}; }}
|
||||||
|
#ComposerEdit {{ background: transparent; border: none; padding: 0; selection-background-color: #3a3222; }}
|
||||||
|
#Hint {{ color: {TEXT_FAINT}; font-size: 8.8pt; }}
|
||||||
|
|
||||||
|
/* chats dialog */
|
||||||
|
QDialog#ChatDialog {{ background: {BG}; }}
|
||||||
|
#ChatList {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px;
|
||||||
|
padding: 4px; outline: none; }}
|
||||||
|
#ChatList::item {{ border-radius: 9px; margin: 1px 0; }}
|
||||||
|
#ChatList::item:hover {{ background: {SURFACE_HI}; }}
|
||||||
|
#ChatList::item:selected {{ background: #262017; border: 1px solid #4a3a18; }}
|
||||||
|
#ChatTitle {{ font-weight: 600; }}
|
||||||
|
#ChatTime {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
||||||
|
#ChatPreview {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def dark_title_bar(window) -> None:
|
||||||
|
"""Windows 11: dark caption, painted the same colour as the window so the
|
||||||
|
title bar and the app read as one surface. Silently does nothing on other
|
||||||
|
platforms or older Windows."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
hwnd = wintypes.HWND(int(window.winId()))
|
||||||
|
dwm = ctypes.windll.dwmapi
|
||||||
|
|
||||||
|
def attr(key: int, value: int) -> None:
|
||||||
|
data = ctypes.c_int(value)
|
||||||
|
dwm.DwmSetWindowAttribute(hwnd, key, ctypes.byref(data), ctypes.sizeof(data))
|
||||||
|
|
||||||
|
attr(20, 1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
colour = BG.lstrip("#")
|
||||||
|
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
|
||||||
|
attr(35, (b << 16) | (g << 8) | r) # DWMWA_CAPTION_COLOR (COLORREF is 0x00BBGGRR)
|
||||||
|
border = BORDER.lstrip("#")
|
||||||
|
r, g, b = int(border[0:2], 16), int(border[2:4], 16), int(border[4:6], 16)
|
||||||
|
attr(34, (b << 16) | (g << 8) | r) # DWMWA_BORDER_COLOR
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
"""The pieces the window is built from: messages, tool cards, the composer,
|
||||||
|
the header controls and the empty state. Styling lives in theme.stylesheet();
|
||||||
|
these set object names and properties for it to hook onto."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
|
||||||
|
QSizePolicy, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import icons, theme
|
||||||
|
|
||||||
|
# Tool name (as tools.py reports it) -> icon.
|
||||||
|
TOOL_ICONS = {
|
||||||
|
"Shell": "shell", "Read": "read", "List": "list", "Write": "write",
|
||||||
|
"Edit": "edit", "Pet": "blocked", "File": "file",
|
||||||
|
}
|
||||||
|
COLLAPSED_LINES = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel:
|
||||||
|
label = QLabel(text)
|
||||||
|
if name:
|
||||||
|
label.setObjectName(name)
|
||||||
|
label.setWordWrap(wrap)
|
||||||
|
if selectable:
|
||||||
|
label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
|
||||||
|
label.setCursor(Qt.IBeamCursor)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def repolish(widget: QWidget) -> None:
|
||||||
|
"""Re-apply the stylesheet after a dynamic property changed."""
|
||||||
|
widget.style().unpolish(widget)
|
||||||
|
widget.style().polish(widget)
|
||||||
|
widget.update()
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_html(text: str, font) -> str:
|
||||||
|
"""Markdown -> the HTML QLabel renders, with code in the app's mono font
|
||||||
|
and a warm tint instead of Qt's generic 'monospace' (usually Courier)."""
|
||||||
|
doc = QTextDocument()
|
||||||
|
doc.setDefaultFont(font)
|
||||||
|
doc.setMarkdown(text)
|
||||||
|
out = doc.toHtml()
|
||||||
|
code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};"
|
||||||
|
# Qt writes the platform's fixed font for code: 'Courier New' on Windows,
|
||||||
|
# 'monospace' elsewhere.
|
||||||
|
for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}:
|
||||||
|
out = out.replace(f"font-family:'{fixed}';", code_style)
|
||||||
|
return out.replace("font-family:'Sans Serif';", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ── transcript entries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserMessage(QWidget):
|
||||||
|
def __init__(self, text: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(80, 6, 0, 6)
|
||||||
|
row.addStretch(1)
|
||||||
|
bubble = QFrame()
|
||||||
|
bubble.setObjectName("UserBubble")
|
||||||
|
inner = QVBoxLayout(bubble)
|
||||||
|
inner.setContentsMargins(16, 10, 16, 10)
|
||||||
|
label = _label(text, selectable=True)
|
||||||
|
label.setTextFormat(Qt.PlainText)
|
||||||
|
# A word-wrapped QLabel's size hint is a guess that wraps short text
|
||||||
|
# early; give it the width its longest line actually needs.
|
||||||
|
widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""])
|
||||||
|
label.setMinimumWidth(min(widest + 4, 588))
|
||||||
|
inner.addWidget(label)
|
||||||
|
bubble.setMaximumWidth(620)
|
||||||
|
bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||||||
|
row.addWidget(bubble)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantMessage(QWidget):
|
||||||
|
"""Bolt's reply. Streamed sentences are appended; a sentence that starts a
|
||||||
|
list item or heading goes on its own line so the markdown still renders."""
|
||||||
|
|
||||||
|
_BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)")
|
||||||
|
|
||||||
|
def __init__(self, text: str = "", parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._text = ""
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(0, 8, 0, 8)
|
||||||
|
row.setSpacing(14)
|
||||||
|
avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)
|
||||||
|
row.addWidget(avatar, 0, Qt.AlignTop)
|
||||||
|
self.label = _label(name="AssistantText", selectable=True)
|
||||||
|
self.label.setTextFormat(Qt.RichText)
|
||||||
|
self.label.setOpenExternalLinks(True)
|
||||||
|
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
row.addWidget(self.label, 1)
|
||||||
|
if text:
|
||||||
|
self.set_text(text)
|
||||||
|
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
self._text = text
|
||||||
|
self.label.setText(markdown_html(text, self.label.font()))
|
||||||
|
|
||||||
|
def append(self, sentence: str) -> None:
|
||||||
|
if not self._text:
|
||||||
|
joiner = ""
|
||||||
|
elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"):
|
||||||
|
joiner = "\n\n" if not self._text.endswith("\n") else ""
|
||||||
|
else:
|
||||||
|
joiner = " "
|
||||||
|
self.set_text(self._text + joiner + sentence)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
class HoldingLine(QWidget):
|
||||||
|
def __init__(self, text: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(40, 0, 0, 2)
|
||||||
|
row.addWidget(_label(text, "Holding"))
|
||||||
|
|
||||||
|
|
||||||
|
class Notice(QWidget):
|
||||||
|
def __init__(self, level: str, text: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(40, 4, 0, 4)
|
||||||
|
label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True)
|
||||||
|
label.setTextFormat(Qt.PlainText)
|
||||||
|
if level == "warn":
|
||||||
|
label.setStyleSheet(f"color: {theme.ACCENT};")
|
||||||
|
row.addWidget(label, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class Pill(QLabel):
|
||||||
|
TEXT = {"running": "Running", "ok": "Done", "error": "Failed",
|
||||||
|
"waiting": "Needs approval", "declined": "Declined"}
|
||||||
|
|
||||||
|
def __init__(self, kind: str = "running", parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("Pill")
|
||||||
|
self.set_kind(kind)
|
||||||
|
|
||||||
|
def set_kind(self, kind: str) -> None:
|
||||||
|
self.setProperty("kind", kind)
|
||||||
|
self.setText(self.TEXT.get(kind, kind))
|
||||||
|
repolish(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _ClickableFrame(QFrame):
|
||||||
|
clicked = Signal()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event) -> None:
|
||||||
|
if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()):
|
||||||
|
self.clicked.emit()
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCard(QFrame):
|
||||||
|
"""One relayed command: header (icon, name, argument, status), optional
|
||||||
|
diff, output that expands on click, and an inline approval bar."""
|
||||||
|
|
||||||
|
answered = Signal(str) # yes / no / always
|
||||||
|
|
||||||
|
def __init__(self, name: str, argument: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("ToolCard")
|
||||||
|
self._output = ""
|
||||||
|
self._expanded = False
|
||||||
|
self._error = False
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
outer.setSpacing(0)
|
||||||
|
|
||||||
|
self.header = _ClickableFrame()
|
||||||
|
self.header.setObjectName("ToolHeader")
|
||||||
|
self.header.setCursor(Qt.PointingHandCursor)
|
||||||
|
head = QHBoxLayout(self.header)
|
||||||
|
head.setContentsMargins(12, 9, 12, 9)
|
||||||
|
head.setSpacing(10)
|
||||||
|
self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16)
|
||||||
|
head.addWidget(self.icon)
|
||||||
|
title = _label(name, "ToolName", wrap=False)
|
||||||
|
head.addWidget(title)
|
||||||
|
shown = argument if len(argument) <= 110 else argument[:107] + "..."
|
||||||
|
arg = _label(shown, "ToolArg", wrap=False)
|
||||||
|
arg.setTextFormat(Qt.PlainText)
|
||||||
|
arg.setToolTip(argument)
|
||||||
|
arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||||
|
head.addWidget(arg, 1)
|
||||||
|
self.pill = Pill("running")
|
||||||
|
head.addWidget(self.pill)
|
||||||
|
self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14)
|
||||||
|
self.chevron.hide()
|
||||||
|
head.addWidget(self.chevron)
|
||||||
|
self.header.clicked.connect(self.toggle)
|
||||||
|
outer.addWidget(self.header)
|
||||||
|
|
||||||
|
self.diff = _label(name="ToolOutput", wrap=True, selectable=True)
|
||||||
|
self.diff.setTextFormat(Qt.RichText)
|
||||||
|
self.diff.hide()
|
||||||
|
outer.addWidget(self.diff)
|
||||||
|
|
||||||
|
self.output = _label(name="ToolOutput", wrap=True, selectable=True)
|
||||||
|
self.output.setTextFormat(Qt.PlainText)
|
||||||
|
self.output.hide()
|
||||||
|
outer.addWidget(self.output)
|
||||||
|
|
||||||
|
self.approval = QFrame()
|
||||||
|
self.approval.setObjectName("ApprovalBar")
|
||||||
|
bar = QHBoxLayout(self.approval)
|
||||||
|
bar.setContentsMargins(14, 10, 10, 10)
|
||||||
|
bar.setSpacing(8)
|
||||||
|
self.question = _label("", "ApprovalQuestion", wrap=False)
|
||||||
|
bar.addWidget(self.question, 1)
|
||||||
|
for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"),
|
||||||
|
("Allow", "yes", "Primary")):
|
||||||
|
button = QPushButton(text)
|
||||||
|
button.setObjectName(style)
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
button.clicked.connect(lambda _=False, v=value: self._answer(v))
|
||||||
|
bar.addWidget(button)
|
||||||
|
if value == "yes":
|
||||||
|
self.allow_button = button
|
||||||
|
self.approval.hide()
|
||||||
|
outer.addWidget(self.approval)
|
||||||
|
|
||||||
|
# ── state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_result(self, text: str, error: bool) -> None:
|
||||||
|
if self.pill.property("kind") == "declined":
|
||||||
|
return
|
||||||
|
self._output, self._error = text.rstrip(), error
|
||||||
|
self.pill.set_kind("error" if error else "ok")
|
||||||
|
self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM)
|
||||||
|
self._render_output()
|
||||||
|
|
||||||
|
def set_diff(self, old: str, new: str, limit: int = 40) -> None:
|
||||||
|
rows = []
|
||||||
|
for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG),
|
||||||
|
("+", new, theme.GREEN, theme.DIFF_ADD_BG)):
|
||||||
|
lines = text.splitlines() or [""]
|
||||||
|
for line in lines[:limit]:
|
||||||
|
rows.append(
|
||||||
|
f'<tr><td style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||||
|
f'white-space:pre;">{sign} {html.escape(line) or " "}</td></tr>')
|
||||||
|
if len(lines) > limit:
|
||||||
|
rows.append(f'<tr><td style="color:{theme.TEXT_FAINT}; padding:1px 8px;">'
|
||||||
|
f' … {len(lines) - limit} more lines</td></tr>')
|
||||||
|
self.diff.setText('<table width="100%" cellspacing="0" cellpadding="0">' + "".join(rows) + "</table>")
|
||||||
|
self.diff.show()
|
||||||
|
|
||||||
|
def ask(self, question: str) -> None:
|
||||||
|
self.question.setText(question)
|
||||||
|
self.pill.set_kind("waiting")
|
||||||
|
self.setProperty("state", "waiting")
|
||||||
|
repolish(self)
|
||||||
|
self.approval.show()
|
||||||
|
self.allow_button.setFocus()
|
||||||
|
|
||||||
|
def _answer(self, value: str) -> None:
|
||||||
|
self.approval.hide()
|
||||||
|
self.setProperty("state", "")
|
||||||
|
repolish(self)
|
||||||
|
if value == "no":
|
||||||
|
self.pill.set_kind("declined")
|
||||||
|
else:
|
||||||
|
self.pill.set_kind("running")
|
||||||
|
self.answered.emit(value)
|
||||||
|
|
||||||
|
def toggle(self) -> None:
|
||||||
|
if self._output:
|
||||||
|
self._expanded = not self._expanded
|
||||||
|
self._render_output()
|
||||||
|
|
||||||
|
def _render_output(self) -> None:
|
||||||
|
lines = align_columns(self._output.splitlines())
|
||||||
|
if not lines:
|
||||||
|
self.output.hide()
|
||||||
|
self.chevron.hide()
|
||||||
|
return
|
||||||
|
long = len(lines) > COLLAPSED_LINES
|
||||||
|
shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES]
|
||||||
|
text = "\n".join(shown)
|
||||||
|
if long and not self._expanded:
|
||||||
|
text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand"
|
||||||
|
self.output.setText(text)
|
||||||
|
self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "")
|
||||||
|
self.output.show()
|
||||||
|
self.chevron.setVisible(long)
|
||||||
|
|
||||||
|
|
||||||
|
def align_columns(lines):
|
||||||
|
"""Tab-separated output (filectl list's "name<TAB>size") into padded
|
||||||
|
columns — a QLabel's tab stops don't line up with anything."""
|
||||||
|
rows = [line.split(" ") for line in lines]
|
||||||
|
if not any(len(r) > 1 for r in rows):
|
||||||
|
return lines
|
||||||
|
width = max(len(r[0]) for r in rows if len(r) > 1)
|
||||||
|
return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingIndicator(QWidget):
|
||||||
|
"""Three dots breathing in sequence, next to a quiet 'Thinking'."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedHeight(34)
|
||||||
|
self._phase = 0.0
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(40)
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self.label = "Thinking"
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._phase = 0.0
|
||||||
|
self.show()
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._timer.stop()
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
self._phase += 0.12
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, _event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
x, y = 50.0, self.height() / 2
|
||||||
|
for i in range(3):
|
||||||
|
level = (math.sin(self._phase - i * 0.9) + 1) / 2
|
||||||
|
colour = QColor(theme.ACCENT)
|
||||||
|
colour.setAlphaF(0.25 + 0.75 * level)
|
||||||
|
p.setBrush(colour)
|
||||||
|
r = 2.6 + 1.0 * level
|
||||||
|
p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r))
|
||||||
|
p.setPen(QColor(theme.TEXT_FAINT))
|
||||||
|
p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label)
|
||||||
|
|
||||||
|
|
||||||
|
# ── composer ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SendButton(QPushButton):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedSize(34, 34)
|
||||||
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.setToolTip("Send (Enter)")
|
||||||
|
self.setFlat(True)
|
||||||
|
|
||||||
|
def paintEvent(self, _event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
enabled = self.isEnabled()
|
||||||
|
fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT)
|
||||||
|
if not enabled:
|
||||||
|
fill = QColor(theme.BORDER_HI)
|
||||||
|
p.setBrush(fill)
|
||||||
|
p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1))
|
||||||
|
icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8),
|
||||||
|
theme.ACCENT_INK if enabled else theme.TEXT_FAINT)
|
||||||
|
|
||||||
|
|
||||||
|
class MicButton(QPushButton):
|
||||||
|
"""Dictation toggle. idle: a quiet mic outline. recording: red, with a
|
||||||
|
ring that swells with your voice, and a stop square. finishing: an amber
|
||||||
|
arc while the last words come back. unavailable: faint, and the tooltip
|
||||||
|
says what's missing."""
|
||||||
|
|
||||||
|
TIPS = {
|
||||||
|
"idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop",
|
||||||
|
"recording": "Listening. Click or pause to stop, Esc to cancel",
|
||||||
|
"finishing": "Transcribing...",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedSize(34, 34)
|
||||||
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.setFlat(True)
|
||||||
|
self.state = "idle"
|
||||||
|
self._level = 0.0
|
||||||
|
self._phase = 0.0
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(33)
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self.set_state("idle")
|
||||||
|
|
||||||
|
def set_state(self, state: str, reason: str = "") -> None:
|
||||||
|
self.state = state
|
||||||
|
self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, ""))
|
||||||
|
if state in ("recording", "finishing"):
|
||||||
|
self._timer.start()
|
||||||
|
else:
|
||||||
|
self._timer.stop()
|
||||||
|
self._level = 0.0
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_level(self, level: float) -> None:
|
||||||
|
# Fast attack, slow release, so the ring breathes instead of flickering.
|
||||||
|
self._level = level if level > self._level else self._level * 0.85 + level * 0.15
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
self._phase += 0.2
|
||||||
|
self._level *= 0.97
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, _event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
|
||||||
|
centre = rect.center()
|
||||||
|
if self.state == "recording":
|
||||||
|
ring = QColor(theme.RED)
|
||||||
|
ring.setAlphaF(0.22 + 0.3 * self._level)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(ring)
|
||||||
|
p.drawEllipse(centre, 16.0, 16.0)
|
||||||
|
p.setBrush(QColor(theme.RED))
|
||||||
|
radius = 10.5 + 3.5 * self._level
|
||||||
|
p.drawEllipse(centre, radius, radius)
|
||||||
|
icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff")
|
||||||
|
return
|
||||||
|
if self.state == "finishing":
|
||||||
|
pen = QColor(theme.ACCENT)
|
||||||
|
p.setPen(icons._pen(pen, 2.2))
|
||||||
|
p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16)
|
||||||
|
icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM)
|
||||||
|
return
|
||||||
|
if self.state == "idle" and self.underMouse():
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QColor(theme.SURFACE_HI))
|
||||||
|
p.drawEllipse(rect)
|
||||||
|
colour = theme.TEXT_FAINT if self.state == "unavailable" else (
|
||||||
|
theme.TEXT if self.underMouse() else theme.TEXT_DIM)
|
||||||
|
icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour)
|
||||||
|
|
||||||
|
|
||||||
|
class _ComposerEdit(QPlainTextEdit):
|
||||||
|
submitted = Signal()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event) -> None:
|
||||||
|
if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier):
|
||||||
|
self.submitted.emit()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class Composer(QFrame):
|
||||||
|
"""Rounded input that grows with its text. Enter sends, Shift+Enter
|
||||||
|
starts a new line. While a turn runs you can keep typing; sending waits."""
|
||||||
|
|
||||||
|
submitted = Signal(str)
|
||||||
|
MAX_LINES = 9
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("Composer")
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(18, 10, 10, 10)
|
||||||
|
row.setSpacing(10)
|
||||||
|
self.edit = _ComposerEdit()
|
||||||
|
self.edit.setObjectName("ComposerEdit")
|
||||||
|
self.edit.setPlaceholderText("Ask Bolt to read, change or run something…")
|
||||||
|
self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
|
||||||
|
self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||||
|
self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.edit.document().setDocumentMargin(2)
|
||||||
|
self.edit.textChanged.connect(self._resize)
|
||||||
|
self.edit.submitted.connect(self._submit)
|
||||||
|
self.edit.installEventFilter(self)
|
||||||
|
row.addWidget(self.edit, 1, Qt.AlignVCenter)
|
||||||
|
self.mic = MicButton()
|
||||||
|
row.addWidget(self.mic, 0, Qt.AlignBottom)
|
||||||
|
self._dictation_prefix = None # composer text from before a recording
|
||||||
|
self.send = _SendButton()
|
||||||
|
self.send.clicked.connect(self._submit)
|
||||||
|
row.addWidget(self.send, 0, Qt.AlignBottom)
|
||||||
|
self._busy = False
|
||||||
|
self._resize()
|
||||||
|
self._sync_button()
|
||||||
|
self.edit.textChanged.connect(self._sync_button)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event) -> bool:
|
||||||
|
if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut):
|
||||||
|
self.setProperty("focused", event.type() == QEvent.FocusIn)
|
||||||
|
repolish(self)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_busy(self, busy: bool) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
self._sync_button()
|
||||||
|
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
self.edit.setPlainText(text)
|
||||||
|
cursor = self.edit.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||||
|
self.edit.setTextCursor(cursor)
|
||||||
|
self.edit.setFocus()
|
||||||
|
|
||||||
|
# ── dictation: the words appear in the box as you speak ──────────────
|
||||||
|
|
||||||
|
def begin_dictation(self) -> None:
|
||||||
|
self._dictation_prefix = self.edit.toPlainText().rstrip()
|
||||||
|
self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost
|
||||||
|
self._sync_button()
|
||||||
|
|
||||||
|
def show_dictation(self, final: str, interim: str = "") -> None:
|
||||||
|
if self._dictation_prefix is None:
|
||||||
|
return
|
||||||
|
spoken = " ".join(part for part in (final, interim) if part)
|
||||||
|
self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part))
|
||||||
|
|
||||||
|
def end_dictation(self, text: str) -> None:
|
||||||
|
if self._dictation_prefix is None:
|
||||||
|
return
|
||||||
|
self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part))
|
||||||
|
self._dictation_prefix = None
|
||||||
|
self.edit.setReadOnly(False)
|
||||||
|
self.edit.setFocus()
|
||||||
|
self._sync_button()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dictating(self) -> bool:
|
||||||
|
return self._dictation_prefix is not None
|
||||||
|
|
||||||
|
def _show(self, text: str) -> None:
|
||||||
|
self.edit.setPlainText(text)
|
||||||
|
cursor = self.edit.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||||
|
self.edit.setTextCursor(cursor)
|
||||||
|
|
||||||
|
def _sync_button(self) -> None:
|
||||||
|
ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip())
|
||||||
|
self.send.setEnabled(ready)
|
||||||
|
self.send.update()
|
||||||
|
|
||||||
|
def _resize(self) -> None:
|
||||||
|
line = self.edit.fontMetrics().lineSpacing()
|
||||||
|
lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height())))
|
||||||
|
self.edit.setFixedHeight(int(lines * line + 8))
|
||||||
|
|
||||||
|
def _submit(self) -> None:
|
||||||
|
text = self.edit.toPlainText().strip()
|
||||||
|
if not text or self._busy or self.dictating:
|
||||||
|
return
|
||||||
|
self.edit.clear()
|
||||||
|
self.submitted.emit(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── header ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class StatusDot(QWidget):
|
||||||
|
COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED}
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedSize(QSize(10, 10))
|
||||||
|
self._state = "unknown"
|
||||||
|
|
||||||
|
def set_state(self, state: str, tooltip: str) -> None:
|
||||||
|
self._state = state
|
||||||
|
self.setToolTip(tooltip)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, _event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT)))
|
||||||
|
p.drawEllipse(QRectF(1, 1, 8, 8))
|
||||||
|
|
||||||
|
|
||||||
|
class FolderChip(QPushButton):
|
||||||
|
"""A pill button with a painted icon on the left (the folder by default)."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, icon_name: str = "folder", text: str = ""):
|
||||||
|
super().__init__(text, parent)
|
||||||
|
self._icon_name = icon_name
|
||||||
|
self.setObjectName("Chip")
|
||||||
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.setFixedHeight(30)
|
||||||
|
|
||||||
|
def set_path(self, path: str) -> None:
|
||||||
|
name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path
|
||||||
|
self.setText(name)
|
||||||
|
self.setToolTip(f"Working directory: {path}\nClick to change")
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None:
|
||||||
|
super().paintEvent(event)
|
||||||
|
p = QPainter(self)
|
||||||
|
paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM
|
||||||
|
if not self.isEnabled():
|
||||||
|
paint_colour = theme.TEXT_FAINT
|
||||||
|
icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour)
|
||||||
|
|
||||||
|
|
||||||
|
class ModeSwitch(QFrame):
|
||||||
|
"""Segmented control: ask before changes, or run everything."""
|
||||||
|
|
||||||
|
changed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, mode: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("Segment")
|
||||||
|
self.setFixedHeight(30)
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(2, 2, 2, 2)
|
||||||
|
row.setSpacing(2)
|
||||||
|
self._group = QButtonGroup(self)
|
||||||
|
self.buttons = {}
|
||||||
|
for value, text, tip in (
|
||||||
|
("ask", "Ask first", "Confirm shell commands, writes and edits"),
|
||||||
|
("auto", "Auto", "Run everything Bolt asks for without confirming"),
|
||||||
|
):
|
||||||
|
button = QPushButton(text)
|
||||||
|
button.setObjectName("SegmentButton")
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
button.setToolTip(tip)
|
||||||
|
if value == "auto":
|
||||||
|
button.setProperty("danger", True)
|
||||||
|
self._group.addButton(button)
|
||||||
|
row.addWidget(button)
|
||||||
|
self.buttons[value] = button
|
||||||
|
button.clicked.connect(lambda _=False, v=value: self.changed.emit(v))
|
||||||
|
self.set_mode(mode)
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self.buttons.get(mode, self.buttons["ask"]).setChecked(True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── empty state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SUGGESTIONS = (
|
||||||
|
"What's in this folder?",
|
||||||
|
"Summarise the README",
|
||||||
|
"Git status and recent commits",
|
||||||
|
"Find TODO comments in this project",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyState(QWidget):
|
||||||
|
picked = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
col = QVBoxLayout(self)
|
||||||
|
col.setContentsMargins(0, 0, 0, 0)
|
||||||
|
col.addStretch(3)
|
||||||
|
col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter)
|
||||||
|
col.addSpacing(18)
|
||||||
|
hero = _label("What are we working on?", "Hero")
|
||||||
|
hero.setAlignment(Qt.AlignHCenter)
|
||||||
|
col.addWidget(hero)
|
||||||
|
self.sub = _label("", "HeroSub")
|
||||||
|
self.sub.setAlignment(Qt.AlignHCenter)
|
||||||
|
col.addWidget(self.sub)
|
||||||
|
col.addSpacing(26)
|
||||||
|
grid = QVBoxLayout()
|
||||||
|
grid.setSpacing(10)
|
||||||
|
for i in range(0, len(SUGGESTIONS), 2):
|
||||||
|
pair = QHBoxLayout()
|
||||||
|
pair.setSpacing(10)
|
||||||
|
for text in SUGGESTIONS[i:i + 2]:
|
||||||
|
button = QPushButton(text)
|
||||||
|
button.setObjectName("Suggestion")
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
button.clicked.connect(lambda _=False, t=text: self.picked.emit(t))
|
||||||
|
pair.addWidget(button)
|
||||||
|
grid.addLayout(pair)
|
||||||
|
wrap = QHBoxLayout()
|
||||||
|
wrap.addStretch(1)
|
||||||
|
holder = QWidget()
|
||||||
|
holder.setLayout(grid)
|
||||||
|
holder.setMaximumWidth(560)
|
||||||
|
holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
wrap.addWidget(holder, 4)
|
||||||
|
wrap.addStretch(1)
|
||||||
|
col.addLayout(wrap)
|
||||||
|
col.addStretch(4)
|
||||||
|
|
||||||
|
def set_folder(self, path: str) -> None:
|
||||||
|
self.sub.setText(f"Bolt can read, edit and run things in {path}")
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"""The main window and the worker thread a turn runs on.
|
||||||
|
|
||||||
|
Turn logic is `console.Session` — the same object the terminal uses — driven
|
||||||
|
on a QThread with a `Bridge` standing in for the terminal. The window only
|
||||||
|
ever reacts to Bridge signals, so nothing here knows about HTTP or tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
|
||||||
|
from PySide6.QtGui import QFont, QKeySequence, QShortcut
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow,
|
||||||
|
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import __version__, config, dictation, server_client
|
||||||
|
from ..console import Session
|
||||||
|
from ..tools import Tools
|
||||||
|
from . import icons, theme
|
||||||
|
from .bridge import Bridge
|
||||||
|
from .chat_list_window import ChatListDialog, ChatSignals, run_in_pool
|
||||||
|
from .widgets import (
|
||||||
|
AssistantMessage, Composer, EmptyState, FolderChip, HoldingLine, ModeSwitch,
|
||||||
|
Notice, StatusDot, ThinkingIndicator, ToolCard, UserMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TurnRunner(QObject):
|
||||||
|
"""Lives on the worker thread; runs one Session.send per request."""
|
||||||
|
|
||||||
|
finished = Signal()
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
super().__init__()
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def run(self, text: str) -> None:
|
||||||
|
try:
|
||||||
|
self.session.send(text)
|
||||||
|
except Exception as exc: # never let a bug kill the worker thread
|
||||||
|
self.session.ui.error(f"Unexpected error: {exc}")
|
||||||
|
self.session.ui.stop_spinner()
|
||||||
|
finally:
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthProbe(QObject):
|
||||||
|
done = Signal(bool, str)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
threading.Thread(target=self._probe, daemon=True).start()
|
||||||
|
|
||||||
|
def _probe(self) -> None:
|
||||||
|
try:
|
||||||
|
server_client.check_health(timeout=8)
|
||||||
|
self.done.emit(True, f"Connected to {config.SERVER_URL}")
|
||||||
|
except Exception as exc:
|
||||||
|
self.done.emit(False, f"Can't reach {config.SERVER_URL}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
class DictationSignals(QObject):
|
||||||
|
"""Dictation calls back on background threads; these queue it onto the
|
||||||
|
GUI thread."""
|
||||||
|
|
||||||
|
update = Signal(str, str) # final text so far, current interim guess
|
||||||
|
level = Signal(float) # 0..1 mic loudness
|
||||||
|
done = Signal(str, str) # text, error ("" when none)
|
||||||
|
stopping = Signal() # recording over, transcript on its way
|
||||||
|
|
||||||
|
|
||||||
|
class Column(QWidget):
|
||||||
|
"""Centres its content in a reading column of at most COLUMN_WIDTH."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
row = QHBoxLayout(self)
|
||||||
|
row.setContentsMargins(24, 0, 24, 0)
|
||||||
|
row.addStretch(1)
|
||||||
|
self.inner = QWidget()
|
||||||
|
self.inner.setMaximumWidth(theme.COLUMN_WIDTH)
|
||||||
|
self.inner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
row.addWidget(self.inner, 100)
|
||||||
|
row.addStretch(1)
|
||||||
|
self.layout_ = QVBoxLayout(self.inner)
|
||||||
|
self.layout_.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
submit_turn = Signal(str)
|
||||||
|
HINT = "Enter to send · Shift+Enter for a new line · Ctrl+M dictate · Ctrl+N new chat"
|
||||||
|
|
||||||
|
def __init__(self, mode: Optional[str] = None, session: Optional[Session] = None,
|
||||||
|
dictation_factory=None, list_chats=None, create_chat=None):
|
||||||
|
super().__init__()
|
||||||
|
# Injectable so tests never hit the network; the real ones run on the pool.
|
||||||
|
self._list_chats = list_chats or server_client.list_chats
|
||||||
|
self._create_chat = create_chat or server_client.create_chat
|
||||||
|
self._dictation_factory = dictation_factory or dictation.Dictation
|
||||||
|
self._dictation = None
|
||||||
|
self.setWindowTitle("Bolt")
|
||||||
|
self.setWindowIcon(icons.app_icon())
|
||||||
|
self.resize(1040, 800)
|
||||||
|
self.setMinimumSize(620, 480)
|
||||||
|
|
||||||
|
self.bridge = Bridge()
|
||||||
|
if session is None:
|
||||||
|
session = Session(self.bridge, tools=Tools(self.bridge, mode=mode or config.APPROVAL_MODE))
|
||||||
|
self.session = session
|
||||||
|
self._cards = {}
|
||||||
|
self._assistant: Optional[AssistantMessage] = None
|
||||||
|
self._busy = False
|
||||||
|
self._stick_to_bottom = True
|
||||||
|
|
||||||
|
root = QWidget()
|
||||||
|
root.setObjectName("Root")
|
||||||
|
self.setCentralWidget(root)
|
||||||
|
page = QVBoxLayout(root)
|
||||||
|
page.setContentsMargins(0, 0, 0, 0)
|
||||||
|
page.setSpacing(0)
|
||||||
|
page.addWidget(self._build_header())
|
||||||
|
|
||||||
|
# transcript
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
transcript = QWidget()
|
||||||
|
transcript.setObjectName("Transcript")
|
||||||
|
body = QVBoxLayout(transcript)
|
||||||
|
body.setContentsMargins(0, 18, 0, 18)
|
||||||
|
self.column = Column()
|
||||||
|
body.addWidget(self.column)
|
||||||
|
body.addStretch(1)
|
||||||
|
self.entries = self.column.layout_
|
||||||
|
self.entries.setSpacing(6)
|
||||||
|
self.empty = EmptyState()
|
||||||
|
self.empty.picked.connect(self._send)
|
||||||
|
self.entries.addWidget(self.empty)
|
||||||
|
self.thinking = ThinkingIndicator()
|
||||||
|
self.thinking.hide()
|
||||||
|
self.entries.addWidget(self.thinking)
|
||||||
|
self.scroll.setWidget(transcript)
|
||||||
|
self.scroll.verticalScrollBar().rangeChanged.connect(self._follow)
|
||||||
|
self.scroll.verticalScrollBar().valueChanged.connect(self._track_scroll)
|
||||||
|
page.addWidget(self.scroll, 1)
|
||||||
|
|
||||||
|
# composer
|
||||||
|
dock = Column()
|
||||||
|
dock.layout_.setContentsMargins(0, 6, 0, 14)
|
||||||
|
dock.layout_.setSpacing(6)
|
||||||
|
self.composer = Composer()
|
||||||
|
self.composer.submitted.connect(self._send)
|
||||||
|
dock.layout_.addWidget(self.composer)
|
||||||
|
self.hint = QLabel(self.HINT)
|
||||||
|
self.hint.setObjectName("Hint")
|
||||||
|
self.hint.setAlignment(Qt.AlignHCenter)
|
||||||
|
dock.layout_.addWidget(self.hint)
|
||||||
|
self._hint_timer = QTimer(self)
|
||||||
|
self._hint_timer.setSingleShot(True)
|
||||||
|
self._hint_timer.timeout.connect(lambda: self.hint.setText(self.HINT))
|
||||||
|
page.addWidget(dock)
|
||||||
|
|
||||||
|
QShortcut(QKeySequence("Ctrl+L"), self, activated=self.clear_transcript)
|
||||||
|
QShortcut(QKeySequence("Ctrl+O"), self, activated=self.choose_folder)
|
||||||
|
QShortcut(QKeySequence("Ctrl+N"), self, activated=self.start_new_chat)
|
||||||
|
QShortcut(QKeySequence("Ctrl+K"), self, activated=self.open_chats)
|
||||||
|
self.chat_signals = ChatSignals()
|
||||||
|
self.chat_signals.created.connect(lambda session_id: self.switch_chat(session_id, "New chat"))
|
||||||
|
self.chat_signals.failed.connect(self._new_chat_failed)
|
||||||
|
self._creating_chat = False
|
||||||
|
QShortcut(QKeySequence("Ctrl+M"), self, activated=self.toggle_dictation)
|
||||||
|
QShortcut(QKeySequence("Escape"), self, activated=self.cancel_dictation)
|
||||||
|
|
||||||
|
self.dictation_signals = DictationSignals()
|
||||||
|
self.dictation_signals.update.connect(self.composer.show_dictation)
|
||||||
|
self.dictation_signals.level.connect(self.composer.mic.set_level)
|
||||||
|
self.dictation_signals.done.connect(self._dictation_done)
|
||||||
|
self.dictation_signals.stopping.connect(
|
||||||
|
lambda: self._dictation is not None and self.composer.mic.set_state("finishing"))
|
||||||
|
self.composer.mic.clicked.connect(self.toggle_dictation)
|
||||||
|
self._mic_unavailable = dictation.unavailable_reason()
|
||||||
|
if self._mic_unavailable:
|
||||||
|
self.composer.mic.set_state("unavailable", self._mic_unavailable)
|
||||||
|
|
||||||
|
self._wire_bridge()
|
||||||
|
self._start_worker()
|
||||||
|
self._refresh_folder()
|
||||||
|
self.health = HealthProbe()
|
||||||
|
self.health.done.connect(self._health_done)
|
||||||
|
QTimer.singleShot(0, self.health.start)
|
||||||
|
self.composer.edit.setFocus()
|
||||||
|
|
||||||
|
# ── construction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_header(self) -> QWidget:
|
||||||
|
header = QFrame()
|
||||||
|
header.setObjectName("Header")
|
||||||
|
header.setFixedHeight(56)
|
||||||
|
row = QHBoxLayout(header)
|
||||||
|
row.setContentsMargins(18, 0, 18, 0)
|
||||||
|
row.setSpacing(10)
|
||||||
|
row.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT))
|
||||||
|
word = QLabel("Bolt")
|
||||||
|
word.setObjectName("Wordmark")
|
||||||
|
row.addWidget(word)
|
||||||
|
sub = QLabel(f"Console {__version__}")
|
||||||
|
sub.setObjectName("WordmarkSub")
|
||||||
|
row.addWidget(sub)
|
||||||
|
row.addSpacing(6)
|
||||||
|
self.status = StatusDot()
|
||||||
|
self.status.set_state("unknown", "Checking the server…")
|
||||||
|
row.addWidget(self.status)
|
||||||
|
row.addStretch(1)
|
||||||
|
self.chats = FolderChip(icon_name="chats", text="Chats")
|
||||||
|
self.chats.setToolTip("All your chats with Bolt, from every device (Ctrl+K)")
|
||||||
|
self.chats.clicked.connect(self.open_chats)
|
||||||
|
row.addWidget(self.chats)
|
||||||
|
self.new_chat = FolderChip(icon_name="plus", text="New chat")
|
||||||
|
self.new_chat.setToolTip("Start a fresh conversation (Ctrl+N)\n"
|
||||||
|
"Fresh history; Bolt still remembers you")
|
||||||
|
self.new_chat.clicked.connect(self.start_new_chat)
|
||||||
|
row.addWidget(self.new_chat)
|
||||||
|
self.folder = FolderChip()
|
||||||
|
self.folder.clicked.connect(self.choose_folder)
|
||||||
|
row.addWidget(self.folder)
|
||||||
|
self.mode = ModeSwitch(self.session.tools.mode)
|
||||||
|
self.mode.changed.connect(self._set_mode)
|
||||||
|
row.addWidget(self.mode)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _wire_bridge(self) -> None:
|
||||||
|
b = self.bridge
|
||||||
|
b.busy.connect(self._set_busy)
|
||||||
|
b.chunk.connect(self._on_chunk)
|
||||||
|
b.reply_ended.connect(self._end_assistant)
|
||||||
|
b.reply.connect(self._on_reply)
|
||||||
|
b.holding_line.connect(lambda text: self._add(HoldingLine(text)))
|
||||||
|
b.tool_started.connect(self._on_tool_started)
|
||||||
|
b.tool_output.connect(self._on_tool_output)
|
||||||
|
b.tool_diff.connect(self._on_tool_diff)
|
||||||
|
b.approval_needed.connect(self._on_approval)
|
||||||
|
b.notice.connect(lambda level, text: self._add(Notice(level, text)))
|
||||||
|
|
||||||
|
def _start_worker(self) -> None:
|
||||||
|
self.thread_ = QThread(self)
|
||||||
|
self.runner = TurnRunner(self.session)
|
||||||
|
self.runner.moveToThread(self.thread_)
|
||||||
|
self.submit_turn.connect(self.runner.run)
|
||||||
|
self.runner.finished.connect(self._turn_finished)
|
||||||
|
self.thread_.start()
|
||||||
|
|
||||||
|
# ── transcript helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add(self, widget: QWidget) -> None:
|
||||||
|
if self.empty.isVisible():
|
||||||
|
self.empty.hide()
|
||||||
|
# Always insert above the thinking indicator, which stays last.
|
||||||
|
self.entries.insertWidget(self.entries.count() - 1, widget)
|
||||||
|
|
||||||
|
def _track_scroll(self, value: int) -> None:
|
||||||
|
bar = self.scroll.verticalScrollBar()
|
||||||
|
self._stick_to_bottom = value >= bar.maximum() - 40
|
||||||
|
|
||||||
|
def _follow(self, _min: int, maximum: int) -> None:
|
||||||
|
if self._stick_to_bottom:
|
||||||
|
self.scroll.verticalScrollBar().setValue(maximum)
|
||||||
|
|
||||||
|
def clear_transcript(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
keep = (self.empty, self.thinking)
|
||||||
|
for i in reversed(range(self.entries.count())):
|
||||||
|
widget = self.entries.itemAt(i).widget()
|
||||||
|
if widget is not None and widget not in keep:
|
||||||
|
self.entries.takeAt(i)
|
||||||
|
widget.deleteLater()
|
||||||
|
self._cards.clear()
|
||||||
|
self._assistant = None
|
||||||
|
self.empty.show()
|
||||||
|
|
||||||
|
# ── user actions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── chats ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_new_chat(self) -> None:
|
||||||
|
"""Ask the server for a fresh chat id, off the UI thread; the switch
|
||||||
|
happens when it arrives (switch_chat)."""
|
||||||
|
if self._busy or self._creating_chat:
|
||||||
|
return
|
||||||
|
self._creating_chat = True
|
||||||
|
self.new_chat.setEnabled(False)
|
||||||
|
self._flash_hint("Starting a new chat...")
|
||||||
|
run_in_pool("create", self._create_chat, self.chat_signals)
|
||||||
|
|
||||||
|
def _new_chat_failed(self, _operation: str, message: str) -> None:
|
||||||
|
self._creating_chat = False
|
||||||
|
self.new_chat.setEnabled(not self._busy)
|
||||||
|
self._flash_hint(f"Couldn't start a new chat: {message}")
|
||||||
|
|
||||||
|
def open_chats(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
self._flash_hint("Wait for Bolt to finish before switching chats.")
|
||||||
|
return
|
||||||
|
dialog = ChatListDialog(self, list_chats=self._list_chats, create_chat=self._create_chat)
|
||||||
|
dialog.chosen.connect(self.switch_chat)
|
||||||
|
self.chat_dialog = dialog # kept for tests; the dialog is modal
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
def switch_chat(self, session_id: str, label: str = "") -> None:
|
||||||
|
"""Point every server call at *session_id* and start the local
|
||||||
|
transcript over: there's no endpoint for a past chat's messages, so
|
||||||
|
the window can't show them (Bolt still has them server-side)."""
|
||||||
|
self._creating_chat = False
|
||||||
|
self.new_chat.setEnabled(not self._busy)
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
note = self.session.switch_to(session_id)
|
||||||
|
self.clear_transcript()
|
||||||
|
self.chats.setToolTip(f"All your chats with Bolt, from every device (Ctrl+K)\n{note}")
|
||||||
|
self._flash_hint(f"Now in: {label or session_id}")
|
||||||
|
|
||||||
|
def _send(self, text: str) -> None:
|
||||||
|
text = text.strip()
|
||||||
|
if not text or self._busy:
|
||||||
|
return
|
||||||
|
self._stick_to_bottom = True
|
||||||
|
self._add(UserMessage(text))
|
||||||
|
self._assistant = None
|
||||||
|
self._set_busy(True)
|
||||||
|
self.submit_turn.emit(text)
|
||||||
|
|
||||||
|
# ── dictation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def toggle_dictation(self) -> None:
|
||||||
|
if self._mic_unavailable:
|
||||||
|
self._flash_hint(self._mic_unavailable)
|
||||||
|
return
|
||||||
|
if self._dictation is not None:
|
||||||
|
if self.composer.mic.state == "recording":
|
||||||
|
self.composer.mic.set_state("finishing")
|
||||||
|
self._dictation.stop()
|
||||||
|
return
|
||||||
|
sig = self.dictation_signals
|
||||||
|
recording = self._dictation_factory(
|
||||||
|
sig.update.emit, sig.level.emit, lambda text, error: sig.done.emit(text, error or ""),
|
||||||
|
silence_seconds=config.DICTATION_SILENCE_SECONDS, on_stopping=sig.stopping.emit,
|
||||||
|
)
|
||||||
|
self.composer.begin_dictation()
|
||||||
|
try:
|
||||||
|
recording.start()
|
||||||
|
except Exception as exc:
|
||||||
|
self.composer.end_dictation("")
|
||||||
|
self._flash_hint(f"Couldn't open the microphone: {exc}")
|
||||||
|
return
|
||||||
|
self._dictation = recording
|
||||||
|
self.composer.mic.set_state("recording")
|
||||||
|
if not recording.live:
|
||||||
|
self._flash_hint("Live transcription is unavailable; the words will appear when you stop.")
|
||||||
|
|
||||||
|
def cancel_dictation(self) -> None:
|
||||||
|
if self._dictation is not None:
|
||||||
|
self._dictation.cancel()
|
||||||
|
|
||||||
|
def _dictation_done(self, text: str, error: str) -> None:
|
||||||
|
self._dictation = None
|
||||||
|
self.composer.mic.set_state("unavailable" if self._mic_unavailable else "idle",
|
||||||
|
self._mic_unavailable or "")
|
||||||
|
self.composer.end_dictation(text)
|
||||||
|
if error:
|
||||||
|
self._flash_hint(error)
|
||||||
|
|
||||||
|
def _flash_hint(self, text: str, seconds: float = 6.0) -> None:
|
||||||
|
self.hint.setText(text)
|
||||||
|
self._hint_timer.start(int(seconds * 1000))
|
||||||
|
|
||||||
|
def choose_folder(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
path = QFileDialog.getExistingDirectory(self, "Working directory for Bolt", os.getcwd())
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
os.chdir(path)
|
||||||
|
except OSError as exc:
|
||||||
|
self._add(Notice("error", f"Couldn't switch to {path}: {exc}"))
|
||||||
|
return
|
||||||
|
self._refresh_folder()
|
||||||
|
if not self.empty.isVisible():
|
||||||
|
self._add(Notice("info", f"Working directory is now {os.getcwd()}"))
|
||||||
|
|
||||||
|
def _refresh_folder(self) -> None:
|
||||||
|
cwd = os.getcwd()
|
||||||
|
self.folder.set_path(cwd)
|
||||||
|
self.empty.set_folder(cwd)
|
||||||
|
|
||||||
|
def _set_mode(self, mode: str) -> None:
|
||||||
|
self.session.tools.mode = mode
|
||||||
|
|
||||||
|
# ── bridge slots ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
self.composer.set_busy(busy)
|
||||||
|
self.folder.setEnabled(not busy)
|
||||||
|
self.new_chat.setEnabled(not busy and not self._creating_chat)
|
||||||
|
self.chats.setEnabled(not busy)
|
||||||
|
if busy:
|
||||||
|
self.thinking.start()
|
||||||
|
else:
|
||||||
|
self.thinking.stop()
|
||||||
|
|
||||||
|
def _turn_finished(self) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
self._assistant = None
|
||||||
|
# Pick up a mode switched by "Always allow" on the worker side.
|
||||||
|
self.mode.set_mode(self.session.tools.mode)
|
||||||
|
self.composer.edit.setFocus()
|
||||||
|
|
||||||
|
def _on_chunk(self, sentence: str) -> None:
|
||||||
|
if self._assistant is None:
|
||||||
|
self._assistant = AssistantMessage()
|
||||||
|
self._add(self._assistant)
|
||||||
|
self._assistant.append(sentence)
|
||||||
|
|
||||||
|
def _end_assistant(self) -> None:
|
||||||
|
self._assistant = None
|
||||||
|
|
||||||
|
def _on_reply(self, text: str) -> None:
|
||||||
|
self._add(AssistantMessage(text))
|
||||||
|
self._assistant = None
|
||||||
|
|
||||||
|
def _on_tool_started(self, tool_id: int, name: str, argument: str) -> None:
|
||||||
|
self._assistant = None # text after a tool call starts a new block
|
||||||
|
card = ToolCard(name, argument)
|
||||||
|
card.answered.connect(self._on_answer)
|
||||||
|
self._cards[tool_id] = card
|
||||||
|
self._add(card)
|
||||||
|
|
||||||
|
def _on_tool_output(self, tool_id: int, text: str, error: bool) -> None:
|
||||||
|
card = self._cards.get(tool_id)
|
||||||
|
if card is not None:
|
||||||
|
card.set_result(text, error)
|
||||||
|
|
||||||
|
def _on_tool_diff(self, tool_id: int, old: str, new: str) -> None:
|
||||||
|
card = self._cards.get(tool_id)
|
||||||
|
if card is not None:
|
||||||
|
card.set_diff(old, new)
|
||||||
|
|
||||||
|
def _on_approval(self, tool_id: int, question: str) -> None:
|
||||||
|
card = self._cards.get(tool_id)
|
||||||
|
if card is None:
|
||||||
|
self.bridge.answer("no")
|
||||||
|
return
|
||||||
|
self.thinking.stop()
|
||||||
|
card.ask(question)
|
||||||
|
self._stick_to_bottom = True
|
||||||
|
QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(card, 0, 40))
|
||||||
|
QApplication.alert(self) # flash the taskbar if the window is behind others
|
||||||
|
|
||||||
|
def _on_answer(self, value: str) -> None:
|
||||||
|
if value == "always":
|
||||||
|
self.mode.set_mode("auto")
|
||||||
|
self.thinking.start()
|
||||||
|
self.bridge.answer(value)
|
||||||
|
|
||||||
|
def _health_done(self, ok: bool, detail: str) -> None:
|
||||||
|
self.status.set_state("ok" if ok else "error", detail)
|
||||||
|
|
||||||
|
# ── window chrome ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
# The transcript grows from the top; give the empty state the whole
|
||||||
|
# viewport so its stretches can centre it.
|
||||||
|
self.empty.setMinimumHeight(max(0, self.scroll.viewport().height() - 40))
|
||||||
|
|
||||||
|
def showEvent(self, event) -> None:
|
||||||
|
super().showEvent(event)
|
||||||
|
theme.dark_title_bar(self)
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
self.cancel_dictation()
|
||||||
|
self.bridge.close()
|
||||||
|
self.thread_.quit()
|
||||||
|
self.thread_.wait(1500)
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(argv=None) -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication(argv or sys.argv)
|
||||||
|
app.setApplicationName("Bolt Console")
|
||||||
|
app.setWindowIcon(icons.app_icon())
|
||||||
|
app.setStyle("Fusion") # a neutral base, so the stylesheet decides the look
|
||||||
|
app.setPalette(theme.palette())
|
||||||
|
font = QFont(theme.ui_font_family())
|
||||||
|
font.setPointSizeF(10.5)
|
||||||
|
font.setHintingPreference(QFont.PreferNoHinting)
|
||||||
|
app.setFont(font)
|
||||||
|
app.setStyleSheet(theme.stylesheet())
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run(argv=None) -> int:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try: # own taskbar group + icon instead of python.exe's
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("TheMajesticNetwork.BoltConsole")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
app = make_app()
|
||||||
|
if not config.is_configured():
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
QMessageBox.critical(
|
||||||
|
None, "Bolt Console",
|
||||||
|
"Missing " + ", ".join(config.missing_config())
|
||||||
|
+ ".\n\nCopy .env.example to .env next to the app and fill them in.")
|
||||||
|
return 2
|
||||||
|
window = MainWindow(mode="auto" if "--auto" in argv else None)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Lenient JSON for the relayed command channel — and honest about it.
|
||||||
|
|
||||||
|
`filectl` and `dialoguectl` both take a single line of compact JSON, hand-typed
|
||||||
|
by a language model into a tool marker. Models get that *nearly* right and then
|
||||||
|
get it wrong in a small, boringly repeatable set of ways:
|
||||||
|
|
||||||
|
{"op":"list","path":"/home/x","recursive":false"} ← stray quote after a literal
|
||||||
|
{"op": "read", "path": "/tmp/a.txt",} ← trailing comma
|
||||||
|
{'op': 'read', 'path': '/tmp/a.txt'} ← single quotes
|
||||||
|
{“op”: “read”, “path”: “/tmp/a.txt”} ← smart quotes
|
||||||
|
{"op": "list", "recursive": False} ← Python literals
|
||||||
|
```json {"op": "list"} ``` ← fenced
|
||||||
|
|
||||||
|
Observed live (2026-07-30): a stray quote after `false` cost an entire desk
|
||||||
|
turn — the call was rejected, the model re-sent the *identical* line, was
|
||||||
|
rejected again, and then gave up and told the user "I'll check now" without
|
||||||
|
ever calling anything. The user got a promise instead of an answer because of
|
||||||
|
one character.
|
||||||
|
|
||||||
|
Strict parsing is the wrong trade here. Nothing about a misplaced quote is
|
||||||
|
ambiguous, the payload is machine-written and machine-read, and the cost of
|
||||||
|
refusing is a wasted round trip that the model has already demonstrated it
|
||||||
|
won't recover from. So: try strict first, then apply narrow repairs, and
|
||||||
|
accept a repair **only if the result parses**.
|
||||||
|
|
||||||
|
Two rules keep this from becoming "guess what they meant":
|
||||||
|
|
||||||
|
1. **Repairs are conservative and named.** Each one fixes a known malformation,
|
||||||
|
is applied in isolation, and is reported by name.
|
||||||
|
2. **Repairs are never silent.** The caller appends the repair note to the tool
|
||||||
|
result, so the model is told it sent broken JSON *while it still has the
|
||||||
|
turn* — the fix works today and teaches within the conversation. Hiding it
|
||||||
|
would trade a visible failure for an invisible one.
|
||||||
|
|
||||||
|
When nothing parses, the error points at the exact character with a caret,
|
||||||
|
because "Expecting ',' delimiter: char 74" is not something a model can act on
|
||||||
|
and a pointed-at fragment is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
# Ordered, cheapest and safest first. Each entry is (name, transform); after
|
||||||
|
# each one the payload is re-parsed, so the first repair that works wins and
|
||||||
|
# nothing more aggressive gets applied than the input actually needed.
|
||||||
|
_REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = (
|
||||||
|
(
|
||||||
|
"stripped a markdown code fence",
|
||||||
|
lambda text: re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"replaced smart quotes with straight ones",
|
||||||
|
lambda text: text.translate(str.maketrans({"“": '"', "”": '"',
|
||||||
|
"‘": "'", "’": "'"})),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubled backslashes that weren't valid JSON escapes",
|
||||||
|
# {"path": "C:\Users\x"} -> {"path": "C:\\Users\\x"} — a Windows path
|
||||||
|
# typed raw. Only *invalid* escapes are touched, and `\\` pairs are
|
||||||
|
# consumed whole, so nothing that already parsed can change meaning.
|
||||||
|
lambda text: re.sub(
|
||||||
|
r'\\([\\/"bfnrtu])|\\',
|
||||||
|
lambda m: m.group(0) if m.group(1) else "\\\\", text),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"removed a stray quote after a bare value",
|
||||||
|
# {"recursive":false"} -> {"recursive":false}
|
||||||
|
lambda text: re.sub(
|
||||||
|
r"(:\s*(?:true|false|null|-?\d+(?:\.\d+)?))\s*\"(\s*[,}\]])", r"\1\2", text),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"removed a trailing comma",
|
||||||
|
lambda text: re.sub(r",(\s*[}\]])", r"\1", text),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"converted Python literals (True/False/None) to JSON",
|
||||||
|
lambda text: re.sub(r"(:\s*)(True|False|None)\b",
|
||||||
|
lambda m: m.group(1) + {"True": "true", "False": "false",
|
||||||
|
"None": "null"}[m.group(2)], text),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"converted single-quoted strings to double-quoted",
|
||||||
|
lambda text: re.sub(r"'([^'\"]*)'", r'"\1"', text),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RelayJsonError(ValueError):
|
||||||
|
"""Unparseable even after repairs — carries a pointed-at fragment."""
|
||||||
|
|
||||||
|
|
||||||
|
def loads(payload: str) -> tuple[Any, list[str]]:
|
||||||
|
"""Parse *payload*, repairing common model mistakes.
|
||||||
|
|
||||||
|
Returns (data, repairs) where *repairs* names what had to be fixed — empty
|
||||||
|
when the input was already valid. Raises RelayJsonError with a caret at the
|
||||||
|
offending character when nothing works."""
|
||||||
|
text = str(payload or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise RelayJsonError("empty payload")
|
||||||
|
try:
|
||||||
|
return json.loads(text), []
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
# Bound to a plain name: Python deletes the `as` target at the end of
|
||||||
|
# the except block, so referring to it further down would raise
|
||||||
|
# UnboundLocalError instead of reporting the parse failure.
|
||||||
|
first_error = exc
|
||||||
|
|
||||||
|
applied: list[str] = []
|
||||||
|
candidate = text
|
||||||
|
for name, repair in _REPAIRS:
|
||||||
|
repaired = repair(candidate)
|
||||||
|
if repaired == candidate:
|
||||||
|
continue
|
||||||
|
candidate = repaired
|
||||||
|
applied.append(name)
|
||||||
|
try:
|
||||||
|
return json.loads(candidate), applied
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue # keep going: a payload can be broken in more than one way
|
||||||
|
|
||||||
|
raise RelayJsonError(point_at(text, first_error))
|
||||||
|
|
||||||
|
|
||||||
|
def point_at(text: str, error: json.JSONDecodeError, width: int = 28) -> str:
|
||||||
|
"""Show the failure where it happened.
|
||||||
|
|
||||||
|
A model can act on "you wrote `false\"}` here"; it cannot act on
|
||||||
|
"Expecting ',' delimiter: line 1 column 75"."""
|
||||||
|
position = max(0, min(len(text), getattr(error, "pos", 0)))
|
||||||
|
start = max(0, position - width)
|
||||||
|
end = min(len(text), position + width)
|
||||||
|
fragment = text[start:end]
|
||||||
|
caret = " " * (position - start) + "^"
|
||||||
|
lead = "…" if start > 0 else ""
|
||||||
|
tail = "…" if end < len(text) else ""
|
||||||
|
return (
|
||||||
|
f"{error.msg} at character {position}:\n"
|
||||||
|
f" {lead}{fragment}{tail}\n"
|
||||||
|
f" {' ' * len(lead)}{caret}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_note(repairs: list[str]) -> str:
|
||||||
|
"""The line appended to a tool result when repairs were needed.
|
||||||
|
|
||||||
|
Phrased as feedback rather than an apology: the model is the author of the
|
||||||
|
broken JSON and is the one who can stop sending it."""
|
||||||
|
if not repairs:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
" (note: your JSON was malformed — I " + "; ".join(repairs)
|
||||||
|
+ " and ran it anyway. Send valid single-line JSON next time.)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""HTTP client for Bolt's desk API (ai/desk_api.py on the server).
|
||||||
|
|
||||||
|
Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo —
|
||||||
|
the console is just another desk client, so it gets the exact same brain,
|
||||||
|
memory, tools, and persona as Discord chat and the other desk clients:
|
||||||
|
|
||||||
|
text -> POST /desk/converse (or /desk/converse_stream)
|
||||||
|
[server may relay a command back to run on THIS machine]
|
||||||
|
... -> POST /desk/tool_result (repeat until the server sends a reply)
|
||||||
|
reply <- returned to caller
|
||||||
|
|
||||||
|
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable, NamedTuple, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
_MAX_RELAY_HOPS = 16
|
||||||
|
_MAX_OUTPUT_CHARS = 6000
|
||||||
|
|
||||||
|
|
||||||
|
class ServerError(Exception):
|
||||||
|
"""Raised when the server responds with an error payload or unreachable."""
|
||||||
|
|
||||||
|
|
||||||
|
class Reply(NamedTuple):
|
||||||
|
"""One final reply from the desk API."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
# True when the text already reached the screen sentence by sentence while
|
||||||
|
# it streamed in — the caller must not print it a second time.
|
||||||
|
streamed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {"X-Desk-Api-Key": config.API_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
def check_health(timeout: float = 10.0) -> dict:
|
||||||
|
response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def run_local_command(command: str, timeout: Optional[int] = None) -> str:
|
||||||
|
"""Execute a relayed shell command as the current user, in the console's
|
||||||
|
working directory (see /cd). Output is captured for the server, so stdin
|
||||||
|
is closed rather than inherited — a command waiting on input would
|
||||||
|
otherwise hang on a prompt nobody can see. `sudo` still works: it asks on
|
||||||
|
the terminal itself, not on stdin."""
|
||||||
|
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command, shell=True, capture_output=True, text=True, errors="replace",
|
||||||
|
stdin=subprocess.DEVNULL, timeout=timeout, cwd=os.getcwd(),
|
||||||
|
# From the window (pythonw, no console) every cmd.exe would
|
||||||
|
# otherwise flash a console window. Output is captured either way.
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
output = (completed.stdout or "") + (completed.stderr or "")
|
||||||
|
return f"[exit {completed.returncode}]\n{output}"[:_MAX_OUTPUT_CHARS]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"[command timed out after {timeout}s]"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"[command failed: {exc}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _reply_from(payload: dict, streamed: bool = False) -> Reply:
|
||||||
|
if payload.get("type") == "reply":
|
||||||
|
return Reply(text=str(payload.get("text") or ""), streamed=streamed)
|
||||||
|
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||||
|
|
||||||
|
|
||||||
|
def _relay(payload: dict, on_command, on_say) -> dict:
|
||||||
|
"""Run every command the server hands back, posting each output to
|
||||||
|
/desk/tool_result, until the payload is no longer a command."""
|
||||||
|
for _ in range(_MAX_RELAY_HOPS):
|
||||||
|
if payload.get("type") != "command":
|
||||||
|
break
|
||||||
|
holding = str(payload.get("say") or "").strip()
|
||||||
|
if holding and on_say is not None:
|
||||||
|
# Shown *before* the command runs — that's the point of it.
|
||||||
|
try:
|
||||||
|
on_say(holding)
|
||||||
|
except Exception:
|
||||||
|
pass # a failed acknowledgement must not cost the tool call
|
||||||
|
output = on_command(str(payload.get("command") or ""))
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{config.SERVER_URL}/desk/tool_result",
|
||||||
|
json={"session_id": config.SESSION_ID,
|
||||||
|
"token": payload.get("token"), "output": output},
|
||||||
|
headers=_headers(), timeout=180,
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def converse(
|
||||||
|
text: str,
|
||||||
|
on_command: Callable[[str], str] = run_local_command,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
on_say: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> Reply:
|
||||||
|
"""Send one turn of conversation, relaying any commands the server sends
|
||||||
|
back until it produces a final reply.
|
||||||
|
|
||||||
|
*on_say* receives the short holding line ("give me a sec") the server can
|
||||||
|
send alongside a command."""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{config.SERVER_URL}/desk/converse",
|
||||||
|
json={"session_id": config.SESSION_ID, "text": text},
|
||||||
|
headers=_headers(), timeout=timeout,
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't reach the server: {exc}") from exc
|
||||||
|
return _reply_from(_relay(payload, on_command, on_say))
|
||||||
|
|
||||||
|
|
||||||
|
def converse_stream(
|
||||||
|
text: str,
|
||||||
|
on_say: Callable[[str], None],
|
||||||
|
on_command: Callable[[str], str] = run_local_command,
|
||||||
|
timeout: float = 180.0,
|
||||||
|
) -> Reply:
|
||||||
|
"""Same turn as converse(), but handing each sentence to *on_say* as it
|
||||||
|
arrives, so the wait is time-to-first-sentence.
|
||||||
|
|
||||||
|
Raises ServerError if the stream fails before anything was shown — the
|
||||||
|
caller then retries with converse(). Once a sentence *has* been shown,
|
||||||
|
a late failure ends the turn with what was said rather than repeating it.
|
||||||
|
"""
|
||||||
|
shown_anything = False
|
||||||
|
said = []
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{config.SERVER_URL}/desk/converse_stream",
|
||||||
|
json={"session_id": config.SESSION_ID, "text": text},
|
||||||
|
headers=_headers(), timeout=timeout, stream=True,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
for raw in response.iter_lines(decode_unicode=True):
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
kind = str(event.get("type") or "")
|
||||||
|
|
||||||
|
if kind == "say":
|
||||||
|
line = str(event.get("text") or "").strip()
|
||||||
|
if line:
|
||||||
|
shown_anything = True
|
||||||
|
said.append(line)
|
||||||
|
on_say(line)
|
||||||
|
elif kind == "command":
|
||||||
|
# The tool loop is request/response, so the rest of the turn
|
||||||
|
# finishes through the ordinary relay rather than the stream.
|
||||||
|
response.close()
|
||||||
|
return _reply_from(_relay(event, on_command, on_say))
|
||||||
|
elif kind == "reply":
|
||||||
|
if event.get("already_spoken"):
|
||||||
|
return _reply_from(event, streamed=True)
|
||||||
|
return _unsaid(_reply_from(event), said)
|
||||||
|
elif kind == "error":
|
||||||
|
raise ServerError(str(event.get("error") or "stream failed"))
|
||||||
|
except ServerError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if shown_anything:
|
||||||
|
return Reply(text="", streamed=True)
|
||||||
|
raise ServerError(f"streaming failed: {exc}") from exc
|
||||||
|
raise ServerError("stream ended without a reply")
|
||||||
|
|
||||||
|
|
||||||
|
def _unsaid(reply: Reply, said: list) -> Reply:
|
||||||
|
"""The part of *reply* not already shown as streamed sentences.
|
||||||
|
|
||||||
|
Seen live: the server streams the sentences, then sends the whole text
|
||||||
|
again in the final reply *without* `already_spoken`, so everything showed
|
||||||
|
twice. Compared ignoring whitespace, since the stream splits on sentences
|
||||||
|
and drops the line breaks the full text keeps."""
|
||||||
|
if not said:
|
||||||
|
return reply
|
||||||
|
target = len("".join("".join(said).split())) # non-whitespace characters shown
|
||||||
|
seen, cut = 0, None
|
||||||
|
for index, char in enumerate(reply.text):
|
||||||
|
if not char.isspace():
|
||||||
|
seen += 1
|
||||||
|
if seen == target:
|
||||||
|
cut = index + 1
|
||||||
|
break
|
||||||
|
if cut is None or "".join(reply.text[:cut].split()) != "".join("".join(said).split()):
|
||||||
|
return reply # a different text, not a repeat: show all of it
|
||||||
|
rest = reply.text[cut:].strip()
|
||||||
|
return Reply(text=rest, streamed=not rest)
|
||||||
|
|
||||||
|
|
||||||
|
def list_chats(timeout: float = 15.0) -> list:
|
||||||
|
"""This user's conversations across every client (console, pet, phone),
|
||||||
|
newest first — each {session_id, label, last_message, last_active}. A
|
||||||
|
chat only appears once it's been used, so a just-minted one isn't here."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return list(response.json().get("chats") or [])
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't list chats: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat(timeout: float = 15.0) -> str:
|
||||||
|
"""Mint a brand-new, never-used session id on the server. Bolt's memory is
|
||||||
|
scoped to the user, not the session, so a new chat is fresh history, not
|
||||||
|
a memory reset."""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
session_id = str(response.json().get("session_id") or "")
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't create a chat: {exc}") from exc
|
||||||
|
if not session_id:
|
||||||
|
raise ServerError("the server didn't return a session_id for the new chat")
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
def list_outbox_files(timeout: float = 15.0) -> list:
|
||||||
|
"""Files the server has queued for this session via its deliver_files
|
||||||
|
tool — each entry has id/name/size. Downloading one dequeues it
|
||||||
|
server-side, so a file is only ever handed out once."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{config.SERVER_URL}/desk/files",
|
||||||
|
params={"session_id": config.SESSION_ID},
|
||||||
|
headers=_headers(), timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return list(response.json().get("files") or [])
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't list delivered files: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes:
|
||||||
|
"""Fetches and dequeues one file listed by list_outbox_files()."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{config.SERVER_URL}/desk/files/{file_id}",
|
||||||
|
params={"session_id": config.SESSION_ID},
|
||||||
|
headers=_headers(), timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""What happens to a command the server relays back to this machine.
|
||||||
|
|
||||||
|
The desk API has one tool channel — a `command` string — and this module is
|
||||||
|
where the console decides what that string means:
|
||||||
|
|
||||||
|
filectl <json> file list/read/write/edit (file_ops.py), no shell
|
||||||
|
petctl / dialoguectl the pet's body and voice — answered with an
|
||||||
|
explanation instead of being run, since there is
|
||||||
|
no pet here and `petctl` isn't a real program
|
||||||
|
anything else a real shell command, in the console's cwd
|
||||||
|
|
||||||
|
Reading never asks. Anything that changes the machine (a shell command, a
|
||||||
|
write, an edit) asks first unless the session is in auto mode — the pet ran
|
||||||
|
everything unasked because nobody was sitting at a prompt to answer; a
|
||||||
|
console has someone right there, so the default is to use them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from . import file_ops, server_client
|
||||||
|
|
||||||
|
# Pseudo-commands from the pet's vocabulary. The server may still emit them —
|
||||||
|
# the session is a pet session as far as it knows — so they get an answer the
|
||||||
|
# model can act on rather than "'petctl' is not recognized as a command".
|
||||||
|
_PET_PREFIXES = ("petctl", "bolt-pet", "dialoguectl")
|
||||||
|
|
||||||
|
PET_UNAVAILABLE = (
|
||||||
|
"[console] {name} isn't available: this is Bolt Console, a text-only terminal "
|
||||||
|
"client with no pet body, voice, or screen reading. Use filectl for files and "
|
||||||
|
"ordinary shell commands for everything else."
|
||||||
|
)
|
||||||
|
|
||||||
|
DECLINED = (
|
||||||
|
"[console] the user declined this {what}, so it did not run. Don't retry it "
|
||||||
|
"as-is; ask what they'd like instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_pet_command(command: str) -> bool:
|
||||||
|
parts = (command or "").split(None, 1)
|
||||||
|
return bool(parts) and parts[0].lower() in _PET_PREFIXES
|
||||||
|
|
||||||
|
|
||||||
|
# The model sometimes writes the tool marker's own label into its argument
|
||||||
|
# ("command: powershell -Command ...") or wraps the whole thing in backticks.
|
||||||
|
# Neither is ever part of a real command — cmd.exe answers "'command:' is not
|
||||||
|
# recognized" — so they're stripped, and the model is told, like relay_json's
|
||||||
|
# repairs: a silent fix works today and guarantees the same mistake tomorrow.
|
||||||
|
_LABEL = re.compile(r"^(?:command|cmd)\s*:\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_command(command: str):
|
||||||
|
"""Returns (command, repairs) with a stray marker label and a wrapping
|
||||||
|
pair of backticks removed."""
|
||||||
|
command = (command or "").strip()
|
||||||
|
repairs = []
|
||||||
|
stripped = _LABEL.sub("", command, count=1)
|
||||||
|
if stripped != command:
|
||||||
|
repairs.append('removed a leading "command:" label')
|
||||||
|
command = stripped.strip()
|
||||||
|
if len(command) > 2 and command[0] == command[-1] == "`" and "`" not in command[1:-1]:
|
||||||
|
repairs.append("removed wrapping backticks")
|
||||||
|
command = command[1:-1].strip()
|
||||||
|
return command, repairs
|
||||||
|
|
||||||
|
|
||||||
|
def repair_note(repairs) -> str:
|
||||||
|
if not repairs:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
"\n(note: your command was malformed — I " + "; ".join(repairs)
|
||||||
|
+ " and ran it anyway. Send only the command itself next time.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tools:
|
||||||
|
"""The `on_command` callable handed to server_client.converse*.
|
||||||
|
|
||||||
|
*ui* is a ui.Terminal (or anything with tool_call/tool_result/diff/
|
||||||
|
confirm); *run_shell* is injectable so tests never spawn a process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ui,
|
||||||
|
mode: str = "ask",
|
||||||
|
run_shell: Callable[[str], str] = server_client.run_local_command,
|
||||||
|
):
|
||||||
|
self.ui = ui
|
||||||
|
self.mode = "auto" if mode == "auto" else "ask"
|
||||||
|
self.run_shell = run_shell
|
||||||
|
# Commands that actually reached this machine — the console compares
|
||||||
|
# it with what a reply *claims* happened (see console.fabricated).
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def __call__(self, command: str) -> str:
|
||||||
|
return self.handle(command)
|
||||||
|
|
||||||
|
def handle(self, command: str) -> str:
|
||||||
|
self.calls += 1
|
||||||
|
command, repairs = clean_command(command)
|
||||||
|
if not command:
|
||||||
|
return "[console] empty command"
|
||||||
|
return self._dispatch(command) + repair_note(repairs)
|
||||||
|
|
||||||
|
def _dispatch(self, command: str) -> str:
|
||||||
|
|
||||||
|
if is_pet_command(command):
|
||||||
|
name = command.split(None, 1)[0]
|
||||||
|
self.ui.tool_call("Pet", command)
|
||||||
|
output = PET_UNAVAILABLE.format(name=name)
|
||||||
|
self.ui.tool_result("not available in the console", error=True)
|
||||||
|
return output
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = file_ops.parse(command)
|
||||||
|
except file_ops.FileOpError as exc:
|
||||||
|
self.ui.tool_call("File", command)
|
||||||
|
self.ui.tool_result(str(exc), error=True)
|
||||||
|
return f"[filectl] {exc}"
|
||||||
|
if action is not None:
|
||||||
|
return self._file(action)
|
||||||
|
|
||||||
|
return self._shell(command)
|
||||||
|
|
||||||
|
# ── approval ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _approved(self, question: str) -> bool:
|
||||||
|
if self.mode == "auto":
|
||||||
|
return True
|
||||||
|
answer = self.ui.confirm(question)
|
||||||
|
if answer == "always":
|
||||||
|
self.mode = "auto"
|
||||||
|
return True
|
||||||
|
return answer == "yes"
|
||||||
|
|
||||||
|
# ── shell ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _shell(self, command: str) -> str:
|
||||||
|
self.ui.tool_call("Shell", command)
|
||||||
|
if not self._approved("Run this command?"):
|
||||||
|
self.ui.tool_result("declined", error=True)
|
||||||
|
return DECLINED.format(what="command")
|
||||||
|
output = self.run_shell(command)
|
||||||
|
status, _, body = output.partition("\n")
|
||||||
|
failed = status != "[exit 0]"
|
||||||
|
# A clean exit shows just the output; a failure keeps its status line.
|
||||||
|
shown = body.strip() or status
|
||||||
|
if failed and body.strip():
|
||||||
|
shown = f"{status}\n{body.strip()}"
|
||||||
|
self.ui.tool_result(shown, error=failed)
|
||||||
|
return output
|
||||||
|
|
||||||
|
# ── filectl ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _file(self, action: dict) -> str:
|
||||||
|
kind = action["action"]
|
||||||
|
path = action.get("path", "")
|
||||||
|
if kind == "help":
|
||||||
|
return file_ops.execute(action)
|
||||||
|
|
||||||
|
if kind == "list":
|
||||||
|
label = path if action["pattern"] == "*" else f"{path}, {action['pattern']}"
|
||||||
|
self.ui.tool_call("List", label + (", recursive" if action["recursive"] else ""))
|
||||||
|
elif kind == "read":
|
||||||
|
span = ""
|
||||||
|
if action.get("start") or action.get("end"):
|
||||||
|
span = f", lines {action.get('start') or 1}-{action.get('end') or 'end'}"
|
||||||
|
self.ui.tool_call("Read", path + span)
|
||||||
|
elif kind == "write":
|
||||||
|
lines = action["content"].count("\n") + 1
|
||||||
|
self.ui.tool_call("Write", path)
|
||||||
|
self.ui.tool_result(f"{lines} lines, {len(action['content'])} chars")
|
||||||
|
if not self._approved("Write this file?"):
|
||||||
|
return self._declined("write")
|
||||||
|
elif kind == "edit":
|
||||||
|
self.ui.tool_call("Edit", path)
|
||||||
|
self.ui.diff(action["old"], action["new"])
|
||||||
|
if not self._approved("Make this edit?"):
|
||||||
|
return self._declined("edit")
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
except file_ops.FileOpError as exc:
|
||||||
|
self.ui.tool_result(str(exc), error=True)
|
||||||
|
return f"[filectl] {exc}"
|
||||||
|
|
||||||
|
if kind == "read":
|
||||||
|
count = output.count("\n") + 1 if output != "[empty file]" else 0
|
||||||
|
self.ui.tool_result(f"Read {count} lines")
|
||||||
|
elif kind == "list":
|
||||||
|
self.ui.tool_result(output)
|
||||||
|
else:
|
||||||
|
self.ui.tool_result(output.replace("[filectl] ", ""))
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _declined(self, what: str) -> str:
|
||||||
|
self.ui.tool_result("declined", error=True)
|
||||||
|
return DECLINED.format(what=what)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Terminal rendering: colour, the thinking spinner, tool-call lines, prompts.
|
||||||
|
|
||||||
|
Plain ANSI and the standard library only, so the console has no dependency
|
||||||
|
beyond `requests`. Everything writes through `Terminal._emit`, which holds the
|
||||||
|
same lock the spinner thread draws under — that is what keeps a spinner frame
|
||||||
|
from landing in the middle of a line of output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional, TextIO
|
||||||
|
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
_STYLES = {
|
||||||
|
"bold": "\033[1m",
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"red": "\033[31m",
|
||||||
|
"green": "\033[32m",
|
||||||
|
"yellow": "\033[33m",
|
||||||
|
"blue": "\033[34m",
|
||||||
|
"magenta": "\033[35m",
|
||||||
|
"cyan": "\033[36m",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output lines shown under a tool call before collapsing to "… +N lines".
|
||||||
|
TOOL_PREVIEW_LINES = 4
|
||||||
|
|
||||||
|
|
||||||
|
def enable_windows_ansi() -> None:
|
||||||
|
"""Turn on VT escape processing for the Windows console (off by default
|
||||||
|
for older hosts). Harmless everywhere else."""
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
||||||
|
mode = ctypes.c_uint32()
|
||||||
|
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
||||||
|
kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_unicode(stream: TextIO) -> bool:
|
||||||
|
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
||||||
|
return encoding.startswith("utf")
|
||||||
|
|
||||||
|
|
||||||
|
class Glyphs:
|
||||||
|
def __init__(self, unicode: bool):
|
||||||
|
self.bullet = "●" if unicode else "*"
|
||||||
|
self.elbow = "⎿" if unicode else "L"
|
||||||
|
self.ellipsis = "…" if unicode else "..."
|
||||||
|
self.prompt = "›" if unicode else ">"
|
||||||
|
self.spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" if unicode else "|/-\\"
|
||||||
|
|
||||||
|
|
||||||
|
def preview(text: str, limit: int = TOOL_PREVIEW_LINES) -> List[str]:
|
||||||
|
"""The first *limit* lines of *text*, plus a count of what was cut."""
|
||||||
|
lines = text.rstrip("\n").splitlines() or [""]
|
||||||
|
if len(lines) <= limit:
|
||||||
|
return lines
|
||||||
|
return lines[:limit] + [f"... +{len(lines) - limit} lines"]
|
||||||
|
|
||||||
|
|
||||||
|
class Terminal:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
out: Optional[TextIO] = None,
|
||||||
|
color: bool = True,
|
||||||
|
input_fn: Callable[[str], str] = input,
|
||||||
|
animate: Optional[bool] = None,
|
||||||
|
):
|
||||||
|
self.out = out or sys.stdout
|
||||||
|
tty = bool(getattr(self.out, "isatty", lambda: False)())
|
||||||
|
self.color = color and tty
|
||||||
|
self.animate = tty if animate is None else animate
|
||||||
|
self.glyphs = Glyphs(_supports_unicode(self.out))
|
||||||
|
self._input = input_fn
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._spinner_label = ""
|
||||||
|
self._spinner_started = 0.0
|
||||||
|
self._spinner_line = False # a spinner frame is currently on screen
|
||||||
|
self._spinner_thread: Optional[threading.Thread] = None
|
||||||
|
self._spinner_stop = threading.Event()
|
||||||
|
self._spinner_paused = False
|
||||||
|
self._in_reply = False # streamed sentences continue one bulleted block
|
||||||
|
|
||||||
|
# ── primitives ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def style(self, text: str, *names: str) -> str:
|
||||||
|
if not self.color or not names:
|
||||||
|
return text
|
||||||
|
return "".join(_STYLES[n] for n in names) + text + _RESET
|
||||||
|
|
||||||
|
def _clear_spinner_line(self) -> None:
|
||||||
|
if self._spinner_line:
|
||||||
|
self.out.write("\r\033[2K" if self.color else "\r" + " " * 60 + "\r")
|
||||||
|
self._spinner_line = False
|
||||||
|
|
||||||
|
def _emit(self, text: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._clear_spinner_line()
|
||||||
|
self.out.write(text)
|
||||||
|
self.out.flush()
|
||||||
|
|
||||||
|
def line(self, text: str = "") -> None:
|
||||||
|
self._emit(text + "\n")
|
||||||
|
|
||||||
|
# ── spinner ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_spinner(self, label: str = "Thinking") -> None:
|
||||||
|
self._spinner_label = label
|
||||||
|
self._spinner_started = time.monotonic()
|
||||||
|
self._spinner_paused = False
|
||||||
|
if not self.animate or self._spinner_thread is not None:
|
||||||
|
return
|
||||||
|
self._spinner_stop.clear()
|
||||||
|
self._spinner_thread = threading.Thread(target=self._spin, daemon=True)
|
||||||
|
self._spinner_thread.start()
|
||||||
|
|
||||||
|
def stop_spinner(self) -> None:
|
||||||
|
thread = self._spinner_thread
|
||||||
|
if thread is not None:
|
||||||
|
self._spinner_stop.set()
|
||||||
|
thread.join(timeout=1)
|
||||||
|
self._spinner_thread = None
|
||||||
|
with self._lock:
|
||||||
|
self._clear_spinner_line()
|
||||||
|
self.out.flush()
|
||||||
|
|
||||||
|
def _spin(self) -> None:
|
||||||
|
for frame in itertools.cycle(self.glyphs.spinner):
|
||||||
|
if self._spinner_stop.wait(0.1):
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if self._spinner_paused:
|
||||||
|
continue
|
||||||
|
elapsed = int(time.monotonic() - self._spinner_started)
|
||||||
|
text = f"{frame} {self._spinner_label}{self.glyphs.ellipsis} ({elapsed}s, ctrl+c to interrupt)"
|
||||||
|
self.out.write("\r" + self.style(text, "magenta"))
|
||||||
|
self.out.flush()
|
||||||
|
self._spinner_line = True
|
||||||
|
|
||||||
|
# ── conversation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _bulleted(self, text: str, *bullet_style: str, body_style=()) -> None:
|
||||||
|
self._in_reply = False
|
||||||
|
lines = text.splitlines() or [""]
|
||||||
|
first, rest = lines[0], lines[1:]
|
||||||
|
out = [self.style(self.glyphs.bullet, *bullet_style) + " " + self.style(first, *body_style)]
|
||||||
|
out += [" " + self.style(line, *body_style) for line in rest]
|
||||||
|
self.line("\n".join(out))
|
||||||
|
|
||||||
|
def assistant(self, text: str) -> None:
|
||||||
|
if text.strip():
|
||||||
|
self._bulleted(text.strip())
|
||||||
|
self.line()
|
||||||
|
|
||||||
|
def assistant_chunk(self, sentence: str) -> None:
|
||||||
|
"""One streamed sentence. The first opens a bulleted block and the
|
||||||
|
rest continue it on their own lines — never mid-line, because the
|
||||||
|
spinner redraws by erasing the current line."""
|
||||||
|
sentence = sentence.strip()
|
||||||
|
if not sentence:
|
||||||
|
return
|
||||||
|
if self._in_reply:
|
||||||
|
self.line("\n".join(" " + part for part in sentence.splitlines()))
|
||||||
|
else:
|
||||||
|
self._bulleted(sentence)
|
||||||
|
self._in_reply = True
|
||||||
|
|
||||||
|
def end_reply(self) -> None:
|
||||||
|
self._in_reply = False
|
||||||
|
self.line()
|
||||||
|
|
||||||
|
def holding(self, text: str) -> None:
|
||||||
|
self._bulleted(text.strip(), "dim", body_style=("dim",))
|
||||||
|
|
||||||
|
def tool_call(self, name: str, argument: str) -> None:
|
||||||
|
argument = argument if len(argument) <= 160 else argument[:157] + "..."
|
||||||
|
self._bulleted(self.style(name, "bold") + self.style(f"({argument})", "dim"), "green")
|
||||||
|
|
||||||
|
def tool_result(self, text: str, error: bool = False) -> None:
|
||||||
|
lines = preview(text)
|
||||||
|
elbow = self.glyphs.elbow
|
||||||
|
rendered = [f" {elbow} " + self.style(lines[0], "red" if error else "dim")]
|
||||||
|
rendered += [" " + self.style(line, "red" if error else "dim") for line in lines[1:]]
|
||||||
|
self.line("\n".join(rendered))
|
||||||
|
|
||||||
|
def diff(self, old: str, new: str, limit: int = 12) -> None:
|
||||||
|
rows = []
|
||||||
|
for sign, text, colour in (("-", old, "red"), ("+", new, "green")):
|
||||||
|
lines = text.splitlines() or [""]
|
||||||
|
for line in lines[:limit]:
|
||||||
|
rows.append(" " + self.style(f"{sign} {line}", colour))
|
||||||
|
if len(lines) > limit:
|
||||||
|
rows.append(" " + self.style(f" ... +{len(lines) - limit} lines", "dim"))
|
||||||
|
self.line("\n".join(rows))
|
||||||
|
|
||||||
|
def info(self, text: str) -> None:
|
||||||
|
self.line(self.style(text, "dim"))
|
||||||
|
|
||||||
|
def warn(self, text: str) -> None:
|
||||||
|
self.line(self.style(text, "yellow"))
|
||||||
|
|
||||||
|
def error(self, text: str) -> None:
|
||||||
|
self.line(self.style(text, "red"))
|
||||||
|
|
||||||
|
# ── input ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def ask(self, prompt: str) -> str:
|
||||||
|
"""Read one line with the spinner held off the screen. EOF and
|
||||||
|
Ctrl+C both come back as an empty answer — the caller decides what
|
||||||
|
an empty answer means."""
|
||||||
|
with self._lock:
|
||||||
|
self._spinner_paused = True
|
||||||
|
self._clear_spinner_line()
|
||||||
|
self.out.flush()
|
||||||
|
try:
|
||||||
|
return self._input(prompt)
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
self.line()
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._spinner_paused = False
|
||||||
|
self._spinner_started = time.monotonic()
|
||||||
|
|
||||||
|
def confirm(self, question: str) -> str:
|
||||||
|
"""y / n / a(lways). Anything unrecognised — including Ctrl+C — is no."""
|
||||||
|
answer = self.ask(
|
||||||
|
" " + self.style(question, "yellow") + " "
|
||||||
|
+ self.style("[y]es / [n]o / [a]lways this session", "dim") + " "
|
||||||
|
).strip().lower()
|
||||||
|
if answer in ("y", "yes"):
|
||||||
|
return "yes"
|
||||||
|
if answer in ("a", "always"):
|
||||||
|
return "always"
|
||||||
|
return "no"
|
||||||
|
|
||||||
|
def read_message(self) -> str:
|
||||||
|
"""The main prompt. A trailing backslash continues onto the next line.
|
||||||
|
Raises EOFError/KeyboardInterrupt for the REPL to handle."""
|
||||||
|
prompt = self.style(self.glyphs.prompt + " ", "bold", "cyan")
|
||||||
|
lines = [self._input(prompt)]
|
||||||
|
while lines[-1].endswith("\\"):
|
||||||
|
lines[-1] = lines[-1][:-1]
|
||||||
|
lines.append(self._input(self.style(self.glyphs.ellipsis + " ", "dim")))
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""Bolt desktop pet.
|
|
||||||
|
|
||||||
__version__ is what the auto-updater compares against the newest tag on the
|
|
||||||
Gitea releases page (see updater.py), so bump it in the same commit you tag.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Entry point: python -m bolt_pet"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .ui.app import run
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(run())
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Sprite assets
|
|
||||||
|
|
||||||
Art: [Kenney's Robot Pack](https://kenney.nl/assets/robot-pack) (CC0 — no
|
|
||||||
attribution required, credited here anyway), the green side-view robot.
|
|
||||||
Source pack lives at `~/Documents/kenney_robot-pack`; only the frames listed
|
|
||||||
below were copied in.
|
|
||||||
|
|
||||||
Convention the loader (`bolt_pet/ui/sprite.py`) expects:
|
|
||||||
|
|
||||||
```
|
|
||||||
assets/sprites/
|
|
||||||
idle/ frame_00.png robot_greenBody (standing)
|
|
||||||
listening/ frame_00.png, frame_01.png robot_greenDrive1/2 (tracks rolling — "leaning in")
|
|
||||||
thinking/ frame_00.png, frame_01.png robot_greenDamage1/2 (flicker — "processing")
|
|
||||||
talking/ frame_00.png, frame_01.png robot_greenBody, robot_greenJump (bounce)
|
|
||||||
error/ frame_00.png robot_greenHurt
|
|
||||||
```
|
|
||||||
|
|
||||||
- One subfolder per pet state (matches `bolt_pet.state.PetState`).
|
|
||||||
- Any `*.png` filenames work — they're played back in alphabetical-sort
|
|
||||||
order, looping, at `IDLE_ANIMATION_FPS` (see `.env`).
|
|
||||||
- Frames are scaled to fit within `PET_SIZE` (default 160px), keeping aspect
|
|
||||||
ratio, and centered in the (square) pet window — the source art here isn't
|
|
||||||
square, so don't assume it fills the frame edge-to-edge.
|
|
||||||
- A state directory with no frames in it falls back to a small
|
|
||||||
procedurally-drawn placeholder blob (see `_placeholder_frames` in
|
|
||||||
`sprite.py`).
|
|
||||||
|
|
||||||
## Swapping in different art
|
|
||||||
|
|
||||||
Replace any state's PNGs (same alphabetical-order-loops convention) to
|
|
||||||
change its look — no code changes needed. If your source is a single grid
|
|
||||||
spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame,
|
|
||||||
use `scripts/slice_spritesheet.py` to cut it into this folder-of-frames
|
|
||||||
convention:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/slice_spritesheet.py path/to/idle_sheet.png assets/sprites/idle \
|
|
||||||
--cols 6 --rows 1
|
|
||||||
```
|
|
||||||
|
|
||||||
If your format is something else entirely (a single animated GIF/APNG, a
|
|
||||||
Spine/DragonBones skeletal export, an Aseprite `.json` atlas, etc.) — tell me
|
|
||||||
the format and I'll adapt `sprite.py`'s loader rather than making you convert
|
|
||||||
by hand.
|
|
||||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,190 +0,0 @@
|
|||||||
"""Barge-in: notice that the user wants to interrupt *while the pet is
|
|
||||||
talking* so playback can be cut short mid-sentence.
|
|
||||||
|
|
||||||
Two detectors, picked by BARGE_IN_MODE:
|
|
||||||
|
|
||||||
- **wake** (default) — the interruption has to be the wake word. Every mic
|
|
||||||
frame goes through the same openWakeWord model the idle listener uses, so
|
|
||||||
a sneeze, a door, or the TV can't cut Bolt off mid-sentence; only saying
|
|
||||||
"thunderbolt" does.
|
|
||||||
- **energy** — the original behaviour: N consecutive frames above
|
|
||||||
BARGE_IN_RMS_THRESHOLD. Faster to trigger and needs no model inference,
|
|
||||||
but it fires on any sustained noise. Deliberately dumber than the
|
|
||||||
utterance VAD in mic.py, since the mic hears the pet's own voice coming
|
|
||||||
back out of the speakers, so the threshold defaults to 4x the VAD one.
|
|
||||||
|
|
||||||
Both take the same injectable stream shape as mic.record_utterance and expose
|
|
||||||
the same reset()/check() pair, so tests feed them fake frames instead of real
|
|
||||||
audio hardware and controller.py doesn't care which one it holds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Optional, Union
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from .. import config
|
|
||||||
from .mic import AudioStream, rms
|
|
||||||
from .wake_word import WakeModel, _default_model, hard_reset
|
|
||||||
|
|
||||||
|
|
||||||
def _read_frame(stream: AudioStream, frame_len: int) -> Optional[np.ndarray]:
|
|
||||||
"""One mono frame, or None if the mic hiccuped or gave us nothing. Never
|
|
||||||
raises: a bad frame mid-playback should mean "no barge-in this frame",
|
|
||||||
not a dead reply."""
|
|
||||||
try:
|
|
||||||
chunk, _ = stream.read(frame_len)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
frame = np.asarray(chunk)
|
|
||||||
if frame.ndim > 1:
|
|
||||||
frame = frame[:, 0]
|
|
||||||
return frame if frame.size else None
|
|
||||||
|
|
||||||
|
|
||||||
class BargeInDetector:
|
|
||||||
"""Energy mode. Poll-driven: call check() repeatedly while audio plays.
|
|
||||||
Each call consumes exactly one mic frame (80ms at the default frame
|
|
||||||
length), which is also what paces the playback loop's polling."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
stream: AudioStream,
|
|
||||||
threshold: int = None,
|
|
||||||
required_frames: int = None,
|
|
||||||
frame_len: int = config.FRAME_LEN,
|
|
||||||
):
|
|
||||||
self._stream = stream
|
|
||||||
self._threshold = config.BARGE_IN_RMS_THRESHOLD if threshold is None else threshold
|
|
||||||
self._required = max(1, config.BARGE_IN_FRAMES if required_frames is None else required_frames)
|
|
||||||
self._frame_len = frame_len
|
|
||||||
self._loud_frames = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loud_frames(self) -> int:
|
|
||||||
return self._loud_frames
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
self._loud_frames = 0
|
|
||||||
|
|
||||||
def check(self) -> bool:
|
|
||||||
"""True once the user has been loud for long enough to count as an
|
|
||||||
interruption."""
|
|
||||||
frame = _read_frame(self._stream, self._frame_len)
|
|
||||||
if frame is None:
|
|
||||||
return False
|
|
||||||
if rms(frame) >= self._threshold:
|
|
||||||
self._loud_frames += 1
|
|
||||||
else:
|
|
||||||
self._loud_frames = 0 # a single thump/cough shouldn't count
|
|
||||||
return self._loud_frames >= self._required
|
|
||||||
|
|
||||||
|
|
||||||
class WakeWordBargeIn:
|
|
||||||
"""Wake-word mode: only "thunderbolt" interrupts.
|
|
||||||
|
|
||||||
Same per-frame predict() loop as listen_for_wake_word, just driven by the
|
|
||||||
playback poll instead of its own read loop. The model instance is shared
|
|
||||||
with the idle listener by default — the two never run at the same time
|
|
||||||
(the pipeline is either speaking or listening), and reusing it avoids
|
|
||||||
loading a second copy of the ONNX graph.
|
|
||||||
|
|
||||||
Two wrinkles the energy detector doesn't have:
|
|
||||||
|
|
||||||
- The mic hears the pet's own voice, so the model is scoring Bolt's
|
|
||||||
speech too. That's harmless unless Bolt says its own wake word, which
|
|
||||||
is why the threshold can be raised independently
|
|
||||||
(BARGE_IN_WAKE_THRESHOLD) without desensitizing the idle listener.
|
|
||||||
- reset() has to be a *hard* reset. openwakeword keeps ~10s of audio
|
|
||||||
history in its preprocessor, so the "thunderbolt" that started this
|
|
||||||
turn is still in the model's window when playback begins — feed it one
|
|
||||||
new frame and it fires on the old phrase, cutting the reply off a word
|
|
||||||
in. Clearing that window is what makes wake-mode barge-in work at all.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
stream: AudioStream,
|
|
||||||
model: Optional[WakeModel] = None,
|
|
||||||
threshold: Union[float, Callable[[], float], None] = None,
|
|
||||||
frame_len: int = config.FRAME_LEN,
|
|
||||||
on_score: Optional[Callable[[float, float], None]] = None,
|
|
||||||
):
|
|
||||||
self._stream = stream
|
|
||||||
self._model = model if model is not None else _default_model
|
|
||||||
if threshold is None:
|
|
||||||
threshold = config.BARGE_IN_WAKE_THRESHOLD or config.WAKE_WORD_THRESHOLD
|
|
||||||
self._resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
|
|
||||||
self._frame_len = frame_len
|
|
||||||
self._on_score = on_score
|
|
||||||
self._frames = 0
|
|
||||||
self._peak = 0.0
|
|
||||||
self._last = 0.0
|
|
||||||
self._last_threshold = 0.0
|
|
||||||
|
|
||||||
# Scoring history for the current reply. Without this an interruption is
|
|
||||||
# indistinguishable from a crash in the logs — you can't tell a genuine
|
|
||||||
# "thunderbolt" from the model firing on Bolt's own voice, or on the first
|
|
||||||
# frame (a stale window) versus halfway through (something it heard).
|
|
||||||
@property
|
|
||||||
def frames_checked(self) -> int:
|
|
||||||
return self._frames
|
|
||||||
|
|
||||||
@property
|
|
||||||
def seconds_checked(self) -> float:
|
|
||||||
return self._frames * self._frame_len / config.SAMPLE_RATE
|
|
||||||
|
|
||||||
@property
|
|
||||||
def peak_score(self) -> float:
|
|
||||||
return self._peak
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_score(self) -> float:
|
|
||||||
return self._last
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_threshold(self) -> float:
|
|
||||||
return self._last_threshold
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
hard_reset(self._model) # never raises
|
|
||||||
self._frames = 0
|
|
||||||
self._peak = 0.0
|
|
||||||
self._last = 0.0
|
|
||||||
|
|
||||||
def check(self) -> bool:
|
|
||||||
frame = _read_frame(self._stream, self._frame_len)
|
|
||||||
if frame is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
scores = self._model.predict(frame)
|
|
||||||
except Exception:
|
|
||||||
return False # same contract as a mic hiccup: no barge-in, no crash
|
|
||||||
threshold = self._resolve_threshold()
|
|
||||||
best = max(scores.values()) if scores else 0.0
|
|
||||||
self._frames += 1
|
|
||||||
self._last = best
|
|
||||||
self._peak = max(self._peak, best)
|
|
||||||
self._last_threshold = threshold
|
|
||||||
if self._on_score is not None:
|
|
||||||
self._on_score(best, threshold)
|
|
||||||
if scores and best >= threshold:
|
|
||||||
self.reset()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def make_detector(
|
|
||||||
stream: AudioStream,
|
|
||||||
mode: str = None,
|
|
||||||
wake_threshold: Union[float, Callable[[], float], None] = None,
|
|
||||||
on_score: Optional[Callable[[float, float], None]] = None,
|
|
||||||
):
|
|
||||||
"""Build whichever detector BARGE_IN_MODE asks for. An unrecognized mode
|
|
||||||
falls back to energy rather than raising — a typo in .env shouldn't stop
|
|
||||||
the pet from starting."""
|
|
||||||
mode = (config.BARGE_IN_MODE if mode is None else mode).strip().lower()
|
|
||||||
if mode in ("wake", "wakeword", "wake_word"):
|
|
||||||
return WakeWordBargeIn(stream, threshold=wake_threshold, on_score=on_score)
|
|
||||||
return BargeInDetector(stream)
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Mic capture + simple energy-based VAD utterance recording.
|
|
||||||
|
|
||||||
Ported from desk_client/bolt_desk.py's record_utterance() — same tuning
|
|
||||||
knobs, same behavior. Kept independent of any UI/threading model so it can
|
|
||||||
be unit tested by feeding it a fake "stream" object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import wave
|
|
||||||
from typing import Optional, Protocol
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from .. import config
|
|
||||||
|
|
||||||
|
|
||||||
class AudioStream(Protocol):
|
|
||||||
"""Minimal shape of the object record_utterance() needs — matches
|
|
||||||
sounddevice.InputStream's .read(frames) -> (data, overflowed)."""
|
|
||||||
|
|
||||||
def read(self, frames: int): ...
|
|
||||||
|
|
||||||
|
|
||||||
def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int = config.SAMPLE_RATE) -> bytes:
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with wave.open(buf, "wb") as wf:
|
|
||||||
wf.setnchannels(1)
|
|
||||||
wf.setsampwidth(2)
|
|
||||||
wf.setframerate(sample_rate)
|
|
||||||
wf.writeframes(pcm.tobytes())
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def rms(frame: np.ndarray) -> float:
|
|
||||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
|
||||||
|
|
||||||
|
|
||||||
def record_utterance(
|
|
||||||
stream: AudioStream,
|
|
||||||
should_continue=lambda: True,
|
|
||||||
rms_threshold: int = None,
|
|
||||||
silence_end_sec: float = None,
|
|
||||||
max_utterance_s: float = None,
|
|
||||||
min_utterance_s: float = None,
|
|
||||||
grace_s: float = None,
|
|
||||||
frame_len: int = config.FRAME_LEN,
|
|
||||||
sample_rate: int = config.SAMPLE_RATE,
|
|
||||||
) -> Optional[np.ndarray]:
|
|
||||||
"""Capture one utterance from *stream*: wait for speech to start, stop
|
|
||||||
after trailing silence. Returns None if nothing usable was heard.
|
|
||||||
|
|
||||||
*should_continue* is polled each frame so a caller can cancel recording
|
|
||||||
(e.g. the pet window was closed) without needing threading primitives
|
|
||||||
baked into this function.
|
|
||||||
|
|
||||||
*grace_s* is how long to wait for speech to *begin* before giving up.
|
|
||||||
The controller stretches it for follow-up questions, where you're being
|
|
||||||
asked something and need a moment to think rather than having just said
|
|
||||||
the wake word on purpose.
|
|
||||||
"""
|
|
||||||
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
|
|
||||||
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
|
|
||||||
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
|
|
||||||
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
|
|
||||||
grace_s = config.GRACE_SECONDS if grace_s is None else grace_s
|
|
||||||
|
|
||||||
frames: list[np.ndarray] = []
|
|
||||||
started = False
|
|
||||||
silence_frames = 0
|
|
||||||
silence_limit = int(silence_end_sec * sample_rate / frame_len)
|
|
||||||
max_frames = int(max_utterance_s * sample_rate / frame_len)
|
|
||||||
grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin
|
|
||||||
waited = 0
|
|
||||||
|
|
||||||
while should_continue():
|
|
||||||
chunk, _ = stream.read(frame_len)
|
|
||||||
frame = np.asarray(chunk)[:, 0].copy()
|
|
||||||
frame_rms = rms(frame)
|
|
||||||
if not started:
|
|
||||||
waited += 1
|
|
||||||
if frame_rms >= rms_threshold:
|
|
||||||
started = True
|
|
||||||
frames.append(frame)
|
|
||||||
elif waited > grace_frames:
|
|
||||||
return None # woke it up but said nothing
|
|
||||||
continue
|
|
||||||
frames.append(frame)
|
|
||||||
if frame_rms < rms_threshold:
|
|
||||||
silence_frames += 1
|
|
||||||
if silence_frames >= silence_limit:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
silence_frames = 0
|
|
||||||
if len(frames) >= max_frames:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
return None
|
|
||||||
pcm = np.concatenate(frames)
|
|
||||||
if len(pcm) < min_utterance_s * sample_rate:
|
|
||||||
return None
|
|
||||||
return pcm
|
|
||||||
|
|
||||||
|
|
||||||
def open_input_stream():
|
|
||||||
"""Real sounddevice input stream, imported lazily so pure-logic tests
|
|
||||||
(record_utterance with a fake stream) don't need PortAudio installed."""
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
return sd.InputStream(
|
|
||||||
samplerate=config.SAMPLE_RATE,
|
|
||||||
channels=1,
|
|
||||||
dtype="int16",
|
|
||||||
blocksize=config.FRAME_LEN,
|
|
||||||
device=config.MIC_DEVICE,
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Speech-to-text for the actual query, after the wake word fires.
|
|
||||||
|
|
||||||
Deepgram, same as desk_client/bolt_desk.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .. import config
|
|
||||||
from .mic import pcm_to_wav_bytes
|
|
||||||
|
|
||||||
|
|
||||||
class SttError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe(pcm) -> str:
|
|
||||||
if not config.DEEPGRAM_API_KEY:
|
|
||||||
raise SttError("DEEPGRAM_API_KEY is not set")
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
"https://api.deepgram.com/v1/listen",
|
|
||||||
params={"model": config.DEEPGRAM_MODEL, "language": "en", "smart_format": "true"},
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Token {config.DEEPGRAM_API_KEY}",
|
|
||||||
"Content-Type": "audio/wav",
|
|
||||||
},
|
|
||||||
data=pcm_to_wav_bytes(pcm),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as exc:
|
|
||||||
raise SttError(f"transcription request failed: {exc}") from exc
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
response.json()
|
|
||||||
.get("results", {}).get("channels", [{}])[0]
|
|
||||||
.get("alternatives", [{}])[0].get("transcript", "")
|
|
||||||
).strip()
|
|
||||||
except Exception as exc:
|
|
||||||
raise SttError(f"couldn't parse transcription response: {exc}") from exc
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""Text-to-speech: ElevenLabs, requested as raw PCM so playback is just
|
|
||||||
sounddevice — no external player binary (mpv/ffplay), unlike
|
|
||||||
desk_client/bolt_desk.py which shells out because it only targets Linux.
|
|
||||||
|
|
||||||
Falls back to pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech
|
|
||||||
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
|
|
||||||
fails, so the pet can still talk with zero cloud config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Iterable, Iterator
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .. import config, speech_text
|
|
||||||
|
|
||||||
|
|
||||||
class TtsError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
|
|
||||||
"""Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure —
|
|
||||||
callers should fall back to speak_offline() rather than treating this
|
|
||||||
as fatal."""
|
|
||||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
|
||||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}",
|
|
||||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
|
||||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
|
||||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as exc:
|
|
||||||
raise TtsError(f"ElevenLabs request failed: {exc}") from exc
|
|
||||||
pcm = np.frombuffer(response.content, dtype=np.int16)
|
|
||||||
if pcm.size == 0:
|
|
||||||
raise TtsError("ElevenLabs returned no audio")
|
|
||||||
return pcm, config.TTS_SAMPLE_RATE
|
|
||||||
|
|
||||||
|
|
||||||
def stream_pcm(text: str, chunk_bytes: int = 4096) -> Iterator[np.ndarray]:
|
|
||||||
"""Same audio as synthesize_pcm(), but yielded as it arrives from
|
|
||||||
ElevenLabs' /stream endpoint so playback can start on the first chunk
|
|
||||||
(~300ms) instead of after the whole clip is synthesized. Raises TtsError
|
|
||||||
before yielding anything if the request itself fails, so callers can fall
|
|
||||||
back cleanly; a mid-stream failure just ends the generator."""
|
|
||||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
|
||||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}/stream",
|
|
||||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
|
||||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
|
||||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
|
||||||
timeout=60,
|
|
||||||
stream=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as exc:
|
|
||||||
raise TtsError(f"ElevenLabs stream request failed: {exc}") from exc
|
|
||||||
return chunks_to_int16(response.iter_content(chunk_size=chunk_bytes))
|
|
||||||
|
|
||||||
|
|
||||||
def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]:
|
|
||||||
"""Reassemble a byte stream into int16 frames. HTTP chunk boundaries fall
|
|
||||||
wherever they like, including *inside* a 16-bit sample, so a trailing odd
|
|
||||||
byte has to be carried into the next chunk — otherwise every chunk after
|
|
||||||
the first is shifted by one byte and plays as static."""
|
|
||||||
carry = b""
|
|
||||||
for chunk in byte_chunks:
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
data = carry + chunk
|
|
||||||
usable = len(data) - (len(data) % 2)
|
|
||||||
carry = data[usable:]
|
|
||||||
if usable:
|
|
||||||
yield np.frombuffer(data[:usable], dtype=np.int16)
|
|
||||||
|
|
||||||
|
|
||||||
def play_pcm(pcm: np.ndarray, sample_rate: int, blocking: bool = True, should_stop=None) -> bool:
|
|
||||||
"""Play a whole clip. Returns True if it finished, False if *should_stop*
|
|
||||||
(barge-in) cut it short. *should_stop* is polled while audio plays — each
|
|
||||||
poll consumes one mic frame, which is what paces this loop."""
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
sd.play(pcm, samplerate=sample_rate, device=config.SPEAKER_DEVICE)
|
|
||||||
if not blocking:
|
|
||||||
return True
|
|
||||||
if should_stop is None:
|
|
||||||
sd.wait()
|
|
||||||
return True
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
if not sd.get_stream().active:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
break # stream already torn down — playback is over
|
|
||||||
if should_stop():
|
|
||||||
sd.stop()
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None) -> bool:
|
|
||||||
"""Play int16 chunks as they arrive. Returns False if interrupted."""
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
with sd.OutputStream(
|
|
||||||
samplerate=sample_rate, channels=1, dtype="int16", device=config.SPEAKER_DEVICE
|
|
||||||
) as out:
|
|
||||||
for chunk in chunks:
|
|
||||||
if should_stop is not None and should_stop():
|
|
||||||
# abort() rather than draining: barge-in should stop the voice
|
|
||||||
# now, not at the end of the buffered chunk.
|
|
||||||
out.abort()
|
|
||||||
return False
|
|
||||||
out.write(chunk)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def speak_offline(text: str) -> None:
|
|
||||||
try:
|
|
||||||
import pyttsx3
|
|
||||||
except ImportError:
|
|
||||||
return # no TTS available at all — caller already logs the text
|
|
||||||
engine = pyttsx3.init()
|
|
||||||
engine.say(text)
|
|
||||||
engine.runAndWait()
|
|
||||||
|
|
||||||
|
|
||||||
def speak(text: str, on_error=None, should_stop=None) -> bool:
|
|
||||||
"""Speak *text*, preferring streaming ElevenLabs, then whole-clip
|
|
||||||
ElevenLabs, then offline TTS. *on_error*, if given, is called with the
|
|
||||||
exception when ElevenLabs fails (useful for logging) — a fallback still
|
|
||||||
runs either way. Returns False if barge-in interrupted playback.
|
|
||||||
|
|
||||||
The text is sanitized first (speech_text.for_speech): server replies are
|
|
||||||
written for a chat window, and a voice reads markdown/emoji literally
|
|
||||||
("asterisk asterisk"). Sanitizing here rather than at the call sites means
|
|
||||||
every path to the speakers — reply, heartbeat announcement — is covered."""
|
|
||||||
text = speech_text.for_speech(text)
|
|
||||||
if not text:
|
|
||||||
return True
|
|
||||||
if config.TTS_STREAMING:
|
|
||||||
try:
|
|
||||||
return play_stream(stream_pcm(text), config.TTS_SAMPLE_RATE, should_stop=should_stop)
|
|
||||||
except TtsError as exc:
|
|
||||||
if on_error is not None:
|
|
||||||
on_error(exc)
|
|
||||||
try:
|
|
||||||
pcm, sample_rate = synthesize_pcm(text)
|
|
||||||
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
|
||||||
except TtsError as exc:
|
|
||||||
if on_error is not None:
|
|
||||||
on_error(exc)
|
|
||||||
speak_offline(text)
|
|
||||||
return True
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"""Wake-word detection via a custom-trained openWakeWord model.
|
|
||||||
|
|
||||||
Uses `thunderbolt.onnx` — trained specifically for "thunderbolt", the same
|
|
||||||
way the main repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey
|
|
||||||
bolt". Same runtime (openWakeWord, ONNX inference), same per-frame
|
|
||||||
predict()/reset() pattern; the only difference is the model file
|
|
||||||
(WAKE_MODEL_FILE) and threshold (WAKE_WORD_THRESHOLD), both configurable via
|
|
||||||
.env if a differently-trained model is swapped in later.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
from typing import Callable, Optional, Protocol, Union
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from .. import config
|
|
||||||
|
|
||||||
|
|
||||||
class WakeModel(Protocol):
|
|
||||||
def predict(self, frame: np.ndarray) -> dict: ...
|
|
||||||
def reset(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class NearMissLog:
|
|
||||||
"""Rolling record of frames that *almost* fired the wake word.
|
|
||||||
|
|
||||||
WAKE_WORD_THRESHOLD is otherwise tuned by guessing at a number in .env
|
|
||||||
and seeing whether the pet ignores you. Keeping the near misses (scores
|
|
||||||
within WAKE_NEAR_MISS_MARGIN below the threshold) turns that into
|
|
||||||
evidence: the tray's wake-word tuner shows what your actual "thunderbolt"
|
|
||||||
scores, so you can set the threshold just under it.
|
|
||||||
|
|
||||||
Pure bookkeeping — the caller supplies timestamps, so it's testable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, limit: int = None, margin: float = None):
|
|
||||||
self._entries: deque[tuple[float, float, float]] = deque( # (timestamp, score, threshold)
|
|
||||||
maxlen=max(1, config.WAKE_NEAR_MISS_LIMIT if limit is None else limit)
|
|
||||||
)
|
|
||||||
self._margin = config.WAKE_NEAR_MISS_MARGIN if margin is None else margin
|
|
||||||
self._peak = 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def peak(self) -> float:
|
|
||||||
"""Highest score seen since the last reset — the "how close did I
|
|
||||||
get?" readout while you test the wake phrase."""
|
|
||||||
return self._peak
|
|
||||||
|
|
||||||
def observe(self, score: float, threshold: float, timestamp: float) -> bool:
|
|
||||||
"""Record *score*; returns True if it counted as a near miss."""
|
|
||||||
self._peak = max(self._peak, score)
|
|
||||||
if score >= threshold or score < threshold - self._margin:
|
|
||||||
return False
|
|
||||||
self._entries.append((timestamp, score, threshold))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def entries(self) -> list[tuple[float, float, float]]:
|
|
||||||
return list(self._entries)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
self._entries.clear()
|
|
||||||
self._peak = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# Where the cached blank state is stashed — on the preprocessor itself
|
|
||||||
# rather than in a dict keyed by id(), which CPython reuses after garbage
|
|
||||||
# collection and would hand one model another's buffers.
|
|
||||||
_BLANK_STATE_ATTR = "_bolt_blank_state"
|
|
||||||
|
|
||||||
|
|
||||||
def _blank_state(preprocessor) -> Optional[tuple]:
|
|
||||||
"""(feature_buffer, melspectrogram_buffer) as they are on a freshly
|
|
||||||
constructed model — i.e. "having heard nothing". Computing it costs an
|
|
||||||
ONNX pass over 10s of silence, so it's cached on the preprocessor and
|
|
||||||
copied from thereafter. Returns None if openwakeword's internals don't
|
|
||||||
look the way we expect, in which case callers leave the state alone
|
|
||||||
rather than corrupting it."""
|
|
||||||
cached = getattr(preprocessor, _BLANK_STATE_ATTR, None)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
try:
|
|
||||||
# Same call the AudioFeatures constructor uses to prime the buffer.
|
|
||||||
state = (preprocessor._get_embeddings(np.zeros(160000).astype(np.int16)), np.ones((76, 32)))
|
|
||||||
setattr(preprocessor, _BLANK_STATE_ATTR, state)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def hard_reset(model) -> None:
|
|
||||||
"""Make the model forget the audio it has already heard — not just its
|
|
||||||
predictions.
|
|
||||||
|
|
||||||
openwakeword's ``Model.reset()`` clears the *prediction* buffer only.
|
|
||||||
The rolling audio window the classifier actually scores lives in
|
|
||||||
``model.preprocessor`` (raw_data_buffer / melspectrogram_buffer /
|
|
||||||
feature_buffer, ~10s of history) and has no reset method of its own. So
|
|
||||||
after a detection the wake word is still sitting in that window, and the
|
|
||||||
next frame fed to the model re-fires on it — which is exactly what made
|
|
||||||
the pet interrupt itself a word into every reply: the "thunderbolt" that
|
|
||||||
started the turn was still in the buffer when barge-in resumed feeding it.
|
|
||||||
|
|
||||||
Never raises. A model whose internals don't match (a fake in tests, a
|
|
||||||
future openwakeword release) just gets the plain reset()."""
|
|
||||||
try:
|
|
||||||
model.reset()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
preprocessor = getattr(model, "preprocessor", None)
|
|
||||||
if preprocessor is None:
|
|
||||||
return
|
|
||||||
blank = _blank_state(preprocessor)
|
|
||||||
try:
|
|
||||||
if blank is not None:
|
|
||||||
features, melspectrogram = blank
|
|
||||||
preprocessor.feature_buffer = features.copy()
|
|
||||||
preprocessor.melspectrogram_buffer = melspectrogram.copy()
|
|
||||||
preprocessor.raw_data_buffer.clear()
|
|
||||||
preprocessor.accumulated_samples = 0
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _construct_model(model_cls, model_path: str):
|
|
||||||
"""openwakeword's Model() constructor keyword has drifted across
|
|
||||||
releases (wakeword_models -> wakeword_model_paths) and some builds
|
|
||||||
reject inference_framework entirely — the main repo's ai/wake_word.py
|
|
||||||
hit the same drift and works around it the same way: try each known
|
|
||||||
calling convention in turn."""
|
|
||||||
attempts = [
|
|
||||||
lambda: model_cls(wakeword_model_paths=[model_path], inference_framework="onnx"),
|
|
||||||
lambda: model_cls(wakeword_model_paths=[model_path]),
|
|
||||||
lambda: model_cls(wakeword_models=[model_path], inference_framework="onnx"),
|
|
||||||
lambda: model_cls(wakeword_models=[model_path]),
|
|
||||||
lambda: model_cls([model_path]),
|
|
||||||
]
|
|
||||||
last_exc: Optional[TypeError] = None
|
|
||||||
for attempt in attempts:
|
|
||||||
try:
|
|
||||||
return attempt()
|
|
||||||
except TypeError as exc:
|
|
||||||
last_exc = exc
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Could not construct openwakeword.Model with any known calling convention "
|
|
||||||
f"(last error: {last_exc})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _OpenWakeWordModel:
|
|
||||||
"""Lazily loads the ONNX model on first use so importing this module
|
|
||||||
(and unit-testing listen_for_wake_word with a fake model) never requires
|
|
||||||
onnxruntime/openwakeword or the model file to be present."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._model = None
|
|
||||||
|
|
||||||
def _ensure_model(self):
|
|
||||||
if self._model is None:
|
|
||||||
from openwakeword.model import Model
|
|
||||||
# from openwakeword.utils import download_models
|
|
||||||
|
|
||||||
# # The pip package doesn't bundle its melspectrogram/embedding
|
|
||||||
# # feature-extraction sub-models — fetch them once on first use
|
|
||||||
# # (no-op if already cached in openwakeword's own resources dir).
|
|
||||||
# # A non-empty, non-matching model_names list keeps this from
|
|
||||||
# # also pulling every official pretrained wakeword model.
|
|
||||||
# download_models(model_names=["thunderbolt"])
|
|
||||||
|
|
||||||
self._model = _construct_model(Model, config.WAKE_MODEL_PATH)
|
|
||||||
return self._model
|
|
||||||
|
|
||||||
@property
|
|
||||||
def preprocessor(self):
|
|
||||||
"""Proxy the wrapped model's audio-feature buffers.
|
|
||||||
|
|
||||||
Without this, hard_reset() sees a wrapper with no `preprocessor` and
|
|
||||||
silently degrades to openwakeword's shallow reset() — which leaves the
|
|
||||||
previous detection sitting in the audio window, i.e. exactly the bug
|
|
||||||
hard_reset exists to fix. Returns None before the model is loaded, so
|
|
||||||
a reset that happens first is a no-op rather than a load."""
|
|
||||||
return getattr(self._model, "preprocessor", None)
|
|
||||||
|
|
||||||
def predict(self, frame: np.ndarray) -> dict:
|
|
||||||
return self._ensure_model().predict(frame)
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
if self._model is not None:
|
|
||||||
self._model.reset()
|
|
||||||
|
|
||||||
|
|
||||||
_default_model = _OpenWakeWordModel()
|
|
||||||
|
|
||||||
|
|
||||||
def listen_for_wake_word(
|
|
||||||
stream,
|
|
||||||
should_continue=lambda: True,
|
|
||||||
model: Optional[WakeModel] = None,
|
|
||||||
threshold: Union[float, Callable[[], float], None] = None,
|
|
||||||
on_tick=None,
|
|
||||||
on_score: Optional[Callable[[float, float], None]] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Block until the wake word fires (returns True) or *should_continue*
|
|
||||||
goes false (returns False).
|
|
||||||
|
|
||||||
Feeds every frame to *model* (the thunderbolt openWakeWord model by
|
|
||||||
default) and treats any class score >= *threshold* as a detection,
|
|
||||||
clearing the model's internal state (audio window included, see
|
|
||||||
hard_reset) afterward so the next call starts clean — same pattern as
|
|
||||||
desk_client/bolt_desk.py's main loop.
|
|
||||||
|
|
||||||
*threshold* may be a number or a zero-argument callable. The callable
|
|
||||||
form exists because this function blocks for minutes at a time: the
|
|
||||||
tray's wake-word tuner slider has to be able to change sensitivity
|
|
||||||
*during* a listen, not only at the start of the next one.
|
|
||||||
|
|
||||||
*on_tick*, if given, is called once per ``WAKE_CHECK_INTERVAL_SECONDS``
|
|
||||||
(not every frame — prediction is cheap enough to run on every frame, but
|
|
||||||
this is the only point control returns to the caller while otherwise
|
|
||||||
blocked here for a possibly long time, so it's how a caller drives
|
|
||||||
periodic work, e.g. the heartbeat/announcement poll in controller.py,
|
|
||||||
during quiet stretches with no wake word).
|
|
||||||
|
|
||||||
*on_score*, if given, gets ``(best_score, threshold)`` every frame — used
|
|
||||||
to log near misses for threshold tuning.
|
|
||||||
"""
|
|
||||||
model = model or _default_model
|
|
||||||
if threshold is None:
|
|
||||||
threshold = config.WAKE_WORD_THRESHOLD
|
|
||||||
resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
|
|
||||||
|
|
||||||
frame_len = config.FRAME_LEN
|
|
||||||
check_every_frames = max(1, int(config.WAKE_CHECK_INTERVAL_SECONDS * config.SAMPLE_RATE / frame_len))
|
|
||||||
frames_since_tick = 0
|
|
||||||
|
|
||||||
while should_continue():
|
|
||||||
chunk, _ = stream.read(frame_len)
|
|
||||||
frame = np.asarray(chunk)[:, 0]
|
|
||||||
scores = model.predict(frame)
|
|
||||||
current_threshold = resolve_threshold()
|
|
||||||
best = max(scores.values()) if scores else 0.0
|
|
||||||
|
|
||||||
frames_since_tick += 1
|
|
||||||
if frames_since_tick >= check_every_frames:
|
|
||||||
frames_since_tick = 0
|
|
||||||
if on_tick is not None:
|
|
||||||
on_tick()
|
|
||||||
|
|
||||||
if on_score is not None:
|
|
||||||
on_score(best, current_threshold)
|
|
||||||
|
|
||||||
if scores and best >= current_threshold:
|
|
||||||
# hard_reset, not reset: the phrase has to leave the model's audio
|
|
||||||
# window too, or the very next frame we feed it re-fires on the
|
|
||||||
# same "thunderbolt" (see hard_reset's docstring).
|
|
||||||
hard_reset(model)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
"""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"))
|
|
||||||
|
|
||||||
# ── 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
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
"""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 typing import Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
config, history as history_mod, notifications, pet_actions, quiet,
|
|
||||||
screen_context, server_client, speech_text, updater,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
|
||||||
self._notification_gate = notifications.NotificationGate(
|
|
||||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
|
||||||
)
|
|
||||||
self._pending_notifications: list[notifications.Notification] = []
|
|
||||||
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 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.")
|
|
||||||
|
|
||||||
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._loop()
|
|
||||||
if self._notification_watcher is not None:
|
|
||||||
self._notification_watcher.stop()
|
|
||||||
self.finished.emit()
|
|
||||||
|
|
||||||
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._handle_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
|
|
||||||
on_tick=self._maybe_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())
|
|
||||||
|
|
||||||
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(
|
|
||||||
screen_context.context_for(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._speak(reply)
|
|
||||||
self._state.transition(PetState.IDLE)
|
|
||||||
|
|
||||||
def _handle_command(self, command: str) -> str:
|
|
||||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
|
||||||
never reaches 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 None:
|
|
||||||
return server_client.run_local_command(command)
|
|
||||||
self.log.emit(f"Pet action: {action}")
|
|
||||||
if action["action"] == "nap":
|
|
||||||
self.set_napping(bool(action["enabled"]))
|
|
||||||
self.action.emit(action)
|
|
||||||
return pet_actions.describe(action)
|
|
||||||
|
|
||||||
def _speak(self, text: str) -> 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}")
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
# 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 ''})."
|
|
||||||
)
|
|
||||||
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."""
|
|
||||||
if not self._notification_gate.should_forward(notification, time.monotonic()):
|
|
||||||
return
|
|
||||||
with self._notification_lock:
|
|
||||||
self._pending_notifications.append(notification)
|
|
||||||
|
|
||||||
def _drain_notifications(self) -> None:
|
|
||||||
with self._notification_lock:
|
|
||||||
pending, self._pending_notifications = self._pending_notifications, []
|
|
||||||
for notification in pending:
|
|
||||||
if not self._running or self._napping:
|
|
||||||
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:
|
|
||||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
|
||||||
return
|
|
||||||
if reply.strip():
|
|
||||||
self._speak(reply)
|
|
||||||
self._state.transition(PetState.IDLE)
|
|
||||||
|
|
||||||
# ── 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._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)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Rolling transcript of the conversation.
|
|
||||||
|
|
||||||
The speech bubble hides itself after a few seconds, which is fine for chat
|
|
||||||
but bad for anything you actually needed to read (a command's output, a
|
|
||||||
number, a URL). This keeps the last HISTORY_LIMIT turns so the tray's
|
|
||||||
History window — and click-to-copy on the bubble — have something to show.
|
|
||||||
|
|
||||||
Pure logic, no Qt: the UI half is ui/history_window.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Iterable, Optional
|
|
||||||
|
|
||||||
USER = "you"
|
|
||||||
PET = "bolt"
|
|
||||||
SYSTEM = "system"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Entry:
|
|
||||||
role: str
|
|
||||||
text: str
|
|
||||||
timestamp: Optional[float] = None # time.time(); None when not recorded
|
|
||||||
|
|
||||||
def formatted(self, clock=None) -> str:
|
|
||||||
label = {USER: "You", PET: "Bolt", SYSTEM: "—"}.get(self.role, self.role)
|
|
||||||
stamp = clock(self.timestamp) if (clock and self.timestamp) else None
|
|
||||||
return f"[{stamp}] {label}: {self.text}" if stamp else f"{label}: {self.text}"
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationHistory:
|
|
||||||
def __init__(self, limit: int = 100):
|
|
||||||
self._entries: deque[Entry] = deque(maxlen=max(1, limit))
|
|
||||||
|
|
||||||
def add(self, role: str, text: str, timestamp: Optional[float] = None) -> Optional[Entry]:
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
entry = Entry(role=role, text=text, timestamp=timestamp)
|
|
||||||
self._entries.append(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def entries(self) -> list[Entry]:
|
|
||||||
return list(self._entries)
|
|
||||||
|
|
||||||
def last(self, role: Optional[str] = None) -> Optional[Entry]:
|
|
||||||
for entry in reversed(self._entries):
|
|
||||||
if role is None or entry.role == role:
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
self._entries.clear()
|
|
||||||
|
|
||||||
def as_text(self, clock=None, entries: Optional[Iterable[Entry]] = None) -> str:
|
|
||||||
return "\n".join(e.formatted(clock) for e in (entries if entries is not None else self._entries))
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self._entries)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""Global push-to-talk hotkey.
|
|
||||||
|
|
||||||
The wake word is the primary trigger, but it misfires in a noisy room and
|
|
||||||
won't fire at all if you're on a call — so there's a keyboard fallback that
|
|
||||||
works even when the pet has no focus (it's a frameless Qt.Tool window with no
|
|
||||||
taskbar entry, so an ordinary QShortcut would never see the key).
|
|
||||||
|
|
||||||
Needs `pynput`, which needs an X11/Win32/macOS input hook: on most Wayland
|
|
||||||
sessions it can't grab global keys, and on macOS it needs Accessibility
|
|
||||||
permission. All of that is a soft failure — start() reports the reason and
|
|
||||||
the wake word / tray keep working.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
# Aliases for the names people actually type in a .env file.
|
|
||||||
_ALIASES = {
|
|
||||||
"control": "ctrl",
|
|
||||||
"ctl": "ctrl",
|
|
||||||
"option": "alt",
|
|
||||||
"opt": "alt",
|
|
||||||
"win": "cmd",
|
|
||||||
"windows": "cmd",
|
|
||||||
"super": "cmd",
|
|
||||||
"meta": "cmd",
|
|
||||||
"command": "cmd",
|
|
||||||
"return": "enter",
|
|
||||||
"escape": "esc",
|
|
||||||
"del": "delete",
|
|
||||||
"ins": "insert",
|
|
||||||
"pgup": "page_up",
|
|
||||||
"pgdn": "page_down",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class HotkeyError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def to_pynput_spec(spec: str) -> str:
|
|
||||||
""""ctrl+alt+space" -> "<ctrl>+<alt>+<space>" (pynput's GlobalHotKeys
|
|
||||||
syntax: named keys in angle brackets, literal characters bare)."""
|
|
||||||
tokens = [t.strip().lower() for t in (spec or "").split("+")]
|
|
||||||
tokens = [t for t in tokens if t]
|
|
||||||
if not tokens:
|
|
||||||
raise HotkeyError("empty hotkey")
|
|
||||||
parts = []
|
|
||||||
for token in tokens:
|
|
||||||
token = _ALIASES.get(token, token)
|
|
||||||
parts.append(token if len(token) == 1 else f"<{token}>")
|
|
||||||
return "+".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalHotkey:
|
|
||||||
"""Fires *callback* whenever the hotkey is pressed, anywhere. Safe to
|
|
||||||
construct unconditionally — nothing happens until start(), and start()
|
|
||||||
reports failure instead of raising into the UI thread."""
|
|
||||||
|
|
||||||
def __init__(self, spec: str, callback: Callable[[], None]):
|
|
||||||
self.spec = spec
|
|
||||||
self._callback = callback
|
|
||||||
self._listener = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def running(self) -> bool:
|
|
||||||
return self._listener is not None
|
|
||||||
|
|
||||||
def start(self) -> Optional[str]:
|
|
||||||
"""None on success, otherwise a human-readable reason it's off."""
|
|
||||||
if not (self.spec or "").strip():
|
|
||||||
return None # explicitly disabled — not an error worth reporting
|
|
||||||
try:
|
|
||||||
pynput_spec = to_pynput_spec(self.spec)
|
|
||||||
except HotkeyError as exc:
|
|
||||||
return f"push-to-talk hotkey {self.spec!r} is invalid: {exc}"
|
|
||||||
try:
|
|
||||||
from pynput import keyboard
|
|
||||||
except Exception as exc: # ImportError, or a backend that won't load
|
|
||||||
return f"push-to-talk needs pynput ({exc}) — wake word still works"
|
|
||||||
try:
|
|
||||||
listener = keyboard.GlobalHotKeys({pynput_spec: self._safe_callback})
|
|
||||||
listener.daemon = True
|
|
||||||
listener.start()
|
|
||||||
except Exception as exc:
|
|
||||||
return f"push-to-talk unavailable on this session ({exc}) — wake word still works"
|
|
||||||
self._listener = listener
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _safe_callback(self) -> None:
|
|
||||||
# This runs on pynput's listener thread; an exception there would
|
|
||||||
# silently kill the listener for the rest of the session.
|
|
||||||
try:
|
|
||||||
self._callback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
if self._listener is not None:
|
|
||||||
try:
|
|
||||||
self._listener.stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._listener = None
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""Desktop notification bridge (Linux/D-Bus).
|
|
||||||
|
|
||||||
Lets Bolt react to things that happen without you: a build finishing, a
|
|
||||||
calendar alert, a message arriving. Notifications are tailed from
|
|
||||||
`dbus-monitor`, filtered, rate-limited, and handed to the controller, which
|
|
||||||
forwards them through the normal converse() path — so the pet can say
|
|
||||||
"your deploy just went green" instead of only ever answering questions.
|
|
||||||
|
|
||||||
Off by default (NOTIFICATION_BRIDGE): every forwarded notification is a
|
|
||||||
round trip to the server, and an unfiltered desktop can be very chatty.
|
|
||||||
NOTIFICATION_FILTER (a regex) is the main knob for keeping it useful.
|
|
||||||
|
|
||||||
The dbus-monitor *parsing* is pure and unit tested; only the subprocess
|
|
||||||
plumbing needs a real session bus.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable, Iterable, Iterator, Optional
|
|
||||||
|
|
||||||
_STRING_LINE = re.compile(r'^\s*string\s+"(.*)"\s*$')
|
|
||||||
_BLOCK_START = re.compile(r"^(method call|signal|method return|error)\b")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Notification:
|
|
||||||
app: str
|
|
||||||
summary: str
|
|
||||||
body: str
|
|
||||||
|
|
||||||
def as_text(self) -> str:
|
|
||||||
parts = [p for p in (self.summary, self.body) if p]
|
|
||||||
joined = " — ".join(parts)
|
|
||||||
return f"{self.app}: {joined}" if self.app else joined
|
|
||||||
|
|
||||||
|
|
||||||
def iter_notifications(lines: Iterable[str]) -> Iterator[Notification]:
|
|
||||||
"""Pull Notification records out of a `dbus-monitor` line stream.
|
|
||||||
|
|
||||||
A Notify call prints its arguments one per line after the header; the
|
|
||||||
string arguments arrive in the order app_name, app_icon, summary, body
|
|
||||||
(replaces_id is a uint32, so it isn't in the string list). Anything
|
|
||||||
that doesn't look like that is skipped rather than guessed at.
|
|
||||||
"""
|
|
||||||
collecting = False
|
|
||||||
strings: list[str] = []
|
|
||||||
|
|
||||||
def _emit() -> Optional[Notification]:
|
|
||||||
if len(strings) < 3:
|
|
||||||
return None
|
|
||||||
return Notification(app=strings[0].strip(), summary=strings[2].strip(),
|
|
||||||
body=(strings[3].strip() if len(strings) > 3 else ""))
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if _BLOCK_START.match(line):
|
|
||||||
if collecting:
|
|
||||||
notification = _emit()
|
|
||||||
if notification is not None:
|
|
||||||
yield notification
|
|
||||||
collecting = "member=Notify" in line
|
|
||||||
strings = []
|
|
||||||
continue
|
|
||||||
if not collecting:
|
|
||||||
continue
|
|
||||||
match = _STRING_LINE.match(line)
|
|
||||||
if match:
|
|
||||||
strings.append(match.group(1))
|
|
||||||
if collecting:
|
|
||||||
notification = _emit()
|
|
||||||
if notification is not None:
|
|
||||||
yield notification
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationGate:
|
|
||||||
"""Filter + rate limit. Clock is passed in (monotonic seconds) so the
|
|
||||||
rate limiting is testable without sleeping."""
|
|
||||||
|
|
||||||
def __init__(self, pattern: str = "", min_interval: float = 60.0):
|
|
||||||
self._min_interval = max(0.0, min_interval)
|
|
||||||
self._last_forwarded = None
|
|
||||||
self._pattern = None
|
|
||||||
if (pattern or "").strip():
|
|
||||||
try:
|
|
||||||
self._pattern = re.compile(pattern, re.IGNORECASE)
|
|
||||||
except re.error:
|
|
||||||
self._pattern = None # a broken regex shouldn't mute everything
|
|
||||||
|
|
||||||
def matches(self, notification: Notification) -> bool:
|
|
||||||
if self._pattern is None:
|
|
||||||
return True
|
|
||||||
return bool(self._pattern.search(notification.as_text()))
|
|
||||||
|
|
||||||
def should_forward(self, notification: Notification, now: float) -> bool:
|
|
||||||
if not notification.as_text().strip():
|
|
||||||
return False
|
|
||||||
if not self.matches(notification):
|
|
||||||
return False
|
|
||||||
if self._last_forwarded is not None and now - self._last_forwarded < self._min_interval:
|
|
||||||
return False
|
|
||||||
self._last_forwarded = now
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def available() -> bool:
|
|
||||||
return platform.system() == "Linux" and shutil.which("dbus-monitor") is not None
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationWatcher:
|
|
||||||
"""Tails dbus-monitor on a daemon thread, calling *callback* per
|
|
||||||
notification. Best-effort: if the session bus isn't reachable it reports
|
|
||||||
why via start() and stays off."""
|
|
||||||
|
|
||||||
_ARGS = [
|
|
||||||
"dbus-monitor", "--session",
|
|
||||||
"interface='org.freedesktop.Notifications',member='Notify'",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, callback: Callable[[Notification], None]):
|
|
||||||
self._callback = callback
|
|
||||||
self._process: Optional[subprocess.Popen] = None
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
def start(self) -> Optional[str]:
|
|
||||||
"""None on success, otherwise the reason the bridge is off."""
|
|
||||||
if not available():
|
|
||||||
return "notification bridge needs Linux + dbus-monitor — skipping"
|
|
||||||
try:
|
|
||||||
self._process = subprocess.Popen(
|
|
||||||
self._ARGS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
||||||
text=True, bufsize=1,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return f"couldn't start dbus-monitor ({exc}) — notification bridge off"
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._pump, name="notification-bridge", daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _pump(self) -> None:
|
|
||||||
assert self._process is not None and self._process.stdout is not None
|
|
||||||
try:
|
|
||||||
for notification in iter_notifications(self._process.stdout):
|
|
||||||
if not self._running:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._callback(notification)
|
|
||||||
except Exception:
|
|
||||||
pass # one bad notification shouldn't end the bridge
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._running = False
|
|
||||||
if self._process is not None:
|
|
||||||
try:
|
|
||||||
self._process.terminate()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._process = None
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Commands that drive the pet's *body* instead of the shell.
|
|
||||||
|
|
||||||
The server relays shell commands to this machine (see server_client.
|
|
||||||
run_local_command). Rather than inventing a new payload type the desk API
|
|
||||||
doesn't speak — this client can't change the server — a small `petctl`
|
|
||||||
pseudo-command is intercepted before it ever reaches `subprocess`: if Bolt
|
|
||||||
emits `petctl move top-left` or `petctl emote wave`, the pet does it and
|
|
||||||
returns a normal-looking command output string, so from the server's side
|
|
||||||
it's just another tool call that worked.
|
|
||||||
|
|
||||||
Pure parsing logic — no Qt, no subprocess — so it's cheap to unit test. The
|
|
||||||
UI half lives in ui/pet_window.py (apply_action).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shlex
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# What Bolt is allowed to type. Anything else falls through to a real shell.
|
|
||||||
_PREFIXES = ("petctl", "bolt-pet", "pet")
|
|
||||||
|
|
||||||
ANCHORS = (
|
|
||||||
"top-left", "top", "top-right",
|
|
||||||
"left", "center", "right",
|
|
||||||
"bottom-left", "bottom", "bottom-right",
|
|
||||||
"cursor", "random",
|
|
||||||
)
|
|
||||||
|
|
||||||
EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle")
|
|
||||||
|
|
||||||
HELP = (
|
|
||||||
"petctl move <x> <y> | <" + "|".join(ANCHORS) + ">\n"
|
|
||||||
"petctl emote <" + "|".join(EMOTES) + ">\n"
|
|
||||||
"petctl say <text>\n"
|
|
||||||
"petctl wander on|off\n"
|
|
||||||
"petctl nap on|off"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActionError(Exception):
|
|
||||||
"""Bad petctl syntax — reported back to the server as command output."""
|
|
||||||
|
|
||||||
|
|
||||||
def is_pet_command(command: str) -> bool:
|
|
||||||
parts = (command or "").strip().split()
|
|
||||||
return bool(parts) and parts[0].lower() in _PREFIXES
|
|
||||||
|
|
||||||
|
|
||||||
def _bool_arg(value: str) -> bool:
|
|
||||||
value = value.lower()
|
|
||||||
if value in ("on", "true", "yes", "1", "start", "enable"):
|
|
||||||
return True
|
|
||||||
if value in ("off", "false", "no", "0", "stop", "disable"):
|
|
||||||
return False
|
|
||||||
raise ActionError(f"expected on/off, got {value!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def parse(command: str) -> Optional[dict]:
|
|
||||||
"""Parse a `petctl ...` string into an action dict, or None if this isn't
|
|
||||||
a pet command at all (caller should run it as a real shell command).
|
|
||||||
Raises ActionError on a pet command that doesn't make sense."""
|
|
||||||
if not is_pet_command(command):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parts = shlex.split(command.strip())
|
|
||||||
except ValueError as exc: # unbalanced quotes
|
|
||||||
raise ActionError(f"couldn't parse arguments: {exc}") from exc
|
|
||||||
verb = (parts[1].lower() if len(parts) > 1 else "help")
|
|
||||||
args = parts[2:]
|
|
||||||
|
|
||||||
if verb in ("help", "-h", "--help"):
|
|
||||||
return {"action": "help"}
|
|
||||||
|
|
||||||
if verb in ("move", "goto", "walk"):
|
|
||||||
if not args:
|
|
||||||
raise ActionError("move needs a target: " + ", ".join(ANCHORS) + ", or x y")
|
|
||||||
if len(args) >= 2 and _looks_numeric(args[0]) and _looks_numeric(args[1]):
|
|
||||||
return {"action": "move", "x": int(float(args[0])), "y": int(float(args[1]))}
|
|
||||||
anchor = args[0].lower().replace("_", "-")
|
|
||||||
if anchor not in ANCHORS:
|
|
||||||
raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS))
|
|
||||||
return {"action": "move", "anchor": anchor}
|
|
||||||
|
|
||||||
if verb in ("emote", "do"):
|
|
||||||
if not args:
|
|
||||||
raise ActionError("emote needs a name: " + ", ".join(EMOTES))
|
|
||||||
emote = args[0].lower()
|
|
||||||
if emote not in EMOTES:
|
|
||||||
raise ActionError(f"unknown emote {args[0]!r}; try one of: " + ", ".join(EMOTES))
|
|
||||||
return {"action": "emote", "emote": emote}
|
|
||||||
|
|
||||||
if verb == "say":
|
|
||||||
text = " ".join(args).strip()
|
|
||||||
if not text:
|
|
||||||
raise ActionError("say needs something to say")
|
|
||||||
return {"action": "say", "text": text}
|
|
||||||
|
|
||||||
if verb == "wander":
|
|
||||||
if not args:
|
|
||||||
raise ActionError("wander needs on or off")
|
|
||||||
return {"action": "wander", "enabled": _bool_arg(args[0])}
|
|
||||||
|
|
||||||
if verb in ("nap", "sleep", "dnd"):
|
|
||||||
if not args:
|
|
||||||
raise ActionError("nap needs on or off")
|
|
||||||
return {"action": "nap", "enabled": _bool_arg(args[0])}
|
|
||||||
|
|
||||||
raise ActionError(f"unknown petctl verb {verb!r}\n{HELP}")
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_numeric(value: str) -> bool:
|
|
||||||
try:
|
|
||||||
float(value)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def describe(action: dict) -> str:
|
|
||||||
"""The text handed back to the server as this "command"'s output. Phrased
|
|
||||||
as a completed fact so the model doesn't narrate the mechanics of it."""
|
|
||||||
kind = action.get("action")
|
|
||||||
if kind == "move":
|
|
||||||
where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})"
|
|
||||||
return f"[pet] walking to {where}"
|
|
||||||
if kind == "emote":
|
|
||||||
return f"[pet] {action['emote']}"
|
|
||||||
if kind == "say":
|
|
||||||
return "[pet] showing that in the speech bubble"
|
|
||||||
if kind == "wander":
|
|
||||||
return "[pet] wandering " + ("enabled" if action["enabled"] else "disabled")
|
|
||||||
if kind == "nap":
|
|
||||||
return "[pet] " + ("napping" if action["enabled"] else "awake")
|
|
||||||
if kind == "help":
|
|
||||||
return HELP
|
|
||||||
return "[pet] ok"
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""Quiet hours — when the pet is allowed to make noise on its own.
|
|
||||||
|
|
||||||
Napping only ever suppresses *proactive* noise (heartbeat announcements,
|
|
||||||
forwarded notifications) and wandering. The wake word, click-to-talk and
|
|
||||||
push-to-talk still work: telling it to be quiet shouldn't mean it stops
|
|
||||||
answering when spoken to.
|
|
||||||
|
|
||||||
Pure logic (parsing + a time comparison) so it's testable without waiting
|
|
||||||
for 11pm.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import time as dtime
|
|
||||||
from typing import Iterable, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class QuietHoursError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_clock(value: str) -> dtime:
|
|
||||||
parts = value.strip().split(":")
|
|
||||||
if len(parts) != 2:
|
|
||||||
raise QuietHoursError(f"expected HH:MM, got {value!r}")
|
|
||||||
try:
|
|
||||||
hour, minute = int(parts[0]), int(parts[1])
|
|
||||||
except ValueError as exc:
|
|
||||||
raise QuietHoursError(f"expected HH:MM, got {value!r}") from exc
|
|
||||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
||||||
raise QuietHoursError(f"{value!r} is not a real time of day")
|
|
||||||
return dtime(hour, minute)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ranges(spec: str) -> list[tuple[dtime, dtime]]:
|
|
||||||
""""23:00-08:00, 13:00-14:00" -> [(23:00, 08:00), (13:00, 14:00)].
|
|
||||||
Empty/blank spec means "no quiet hours"."""
|
|
||||||
ranges: list[tuple[dtime, dtime]] = []
|
|
||||||
for chunk in (spec or "").split(","):
|
|
||||||
chunk = chunk.strip()
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
start, sep, end = chunk.partition("-")
|
|
||||||
if not sep:
|
|
||||||
raise QuietHoursError(f"expected HH:MM-HH:MM, got {chunk!r}")
|
|
||||||
ranges.append((_parse_clock(start), _parse_clock(end)))
|
|
||||||
return ranges
|
|
||||||
|
|
||||||
|
|
||||||
def in_ranges(now: dtime, ranges: Iterable[tuple[dtime, dtime]]) -> bool:
|
|
||||||
for start, end in ranges:
|
|
||||||
if start == end:
|
|
||||||
continue # a zero-length range is a typo, not "all day"
|
|
||||||
if start < end:
|
|
||||||
if start <= now < end:
|
|
||||||
return True
|
|
||||||
elif now >= start or now < end: # wraps past midnight
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_quiet(spec: str, now: Optional[dtime] = None, on_error=None) -> bool:
|
|
||||||
"""True if *now* (defaults to the local wall clock) falls inside *spec*.
|
|
||||||
A malformed spec is reported via *on_error* and treated as "not quiet" —
|
|
||||||
a config typo shouldn't silently mute the pet forever."""
|
|
||||||
if not (spec or "").strip():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
ranges = parse_ranges(spec)
|
|
||||||
except QuietHoursError as exc:
|
|
||||||
if on_error is not None:
|
|
||||||
on_error(exc)
|
|
||||||
return False
|
|
||||||
if now is None:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
now = datetime.now().time()
|
|
||||||
return in_ranges(now, ranges)
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""What's on screen right now — the active window's title, and whether
|
|
||||||
something is running fullscreen.
|
|
||||||
|
|
||||||
Two consumers:
|
|
||||||
* annotate() tacks the focused window's title onto what you said, so
|
|
||||||
"what's this error?" has a referent without you describing it (the desk
|
|
||||||
API takes text only, so this is a text annotation — no screenshot upload).
|
|
||||||
* is_fullscreen_active() feeds do-not-disturb: the pet shouldn't announce
|
|
||||||
anything over a call or a fullscreen game.
|
|
||||||
|
|
||||||
Everything here is best-effort and must never raise: on a locked-down Wayland
|
|
||||||
session none of it is available, and the correct behaviour is simply "no
|
|
||||||
context", not a crashed pipeline. The subprocess *parsing* is split into pure
|
|
||||||
functions so it can be tested without a display server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
_TIMEOUT = 2.0
|
|
||||||
_MAX_TITLE_CHARS = 160
|
|
||||||
|
|
||||||
# Titles that are just the desktop itself — annotating with these is noise.
|
|
||||||
_BORING_TITLES = {"", "desktop", "@!0,0;bdib", "plasmashell", "gnome-shell", "xfdesktop"}
|
|
||||||
|
|
||||||
|
|
||||||
def _run(args: list[str]) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(args, capture_output=True, text=True, timeout=_TIMEOUT)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if completed.returncode != 0:
|
|
||||||
return None
|
|
||||||
return completed.stdout
|
|
||||||
|
|
||||||
|
|
||||||
# ── pure parsing helpers (unit tested; no display server needed) ─────────────
|
|
||||||
|
|
||||||
def parse_xprop_window_id(output: str) -> Optional[str]:
|
|
||||||
"""`xprop -root _NET_ACTIVE_WINDOW` -> '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007'"""
|
|
||||||
match = re.search(r"(0x[0-9a-fA-F]+)", output or "")
|
|
||||||
if not match or int(match.group(1), 16) == 0:
|
|
||||||
return None
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xprop_window_name(output: str) -> Optional[str]:
|
|
||||||
"""`xprop -id <id> _NET_WM_NAME` -> '_NET_WM_NAME(UTF8_STRING) = "Firefox"'"""
|
|
||||||
match = re.search(r'=\s*"(.*)"\s*$', (output or "").strip(), re.DOTALL)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return match.group(1).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xprop_fullscreen(output: str) -> bool:
|
|
||||||
return "_NET_WM_STATE_FULLSCREEN" in (output or "")
|
|
||||||
|
|
||||||
|
|
||||||
def clean_title(title: Optional[str]) -> Optional[str]:
|
|
||||||
title = (title or "").strip().replace("\n", " ")
|
|
||||||
if title.lower() in _BORING_TITLES:
|
|
||||||
return None
|
|
||||||
if len(title) > _MAX_TITLE_CHARS:
|
|
||||||
title = title[: _MAX_TITLE_CHARS - 1].rstrip() + "…"
|
|
||||||
return title or None
|
|
||||||
|
|
||||||
|
|
||||||
def annotate(text: str, title: Optional[str]) -> str:
|
|
||||||
"""Attach the window title as an explicit aside rather than splicing it
|
|
||||||
into the sentence, so the model can ignore it when it's irrelevant."""
|
|
||||||
text = (text or "").strip()
|
|
||||||
title = clean_title(title)
|
|
||||||
if not text or not title:
|
|
||||||
return text
|
|
||||||
return f"{text}\n\n[on screen right now: {title}]"
|
|
||||||
|
|
||||||
|
|
||||||
# ── platform probes ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _linux_active_window_id() -> Optional[str]:
|
|
||||||
if not shutil.which("xprop"):
|
|
||||||
return None
|
|
||||||
output = _run(["xprop", "-root", "_NET_ACTIVE_WINDOW"])
|
|
||||||
return parse_xprop_window_id(output or "")
|
|
||||||
|
|
||||||
|
|
||||||
def _linux_title() -> Optional[str]:
|
|
||||||
if shutil.which("xdotool"):
|
|
||||||
output = _run(["xdotool", "getactivewindow", "getwindowname"])
|
|
||||||
if output and output.strip():
|
|
||||||
return output.strip()
|
|
||||||
window_id = _linux_active_window_id()
|
|
||||||
if window_id is None:
|
|
||||||
return None
|
|
||||||
for prop in ("_NET_WM_NAME", "WM_NAME"):
|
|
||||||
output = _run(["xprop", "-id", window_id, prop])
|
|
||||||
title = parse_xprop_window_name(output or "")
|
|
||||||
if title:
|
|
||||||
return title
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _windows_title() -> Optional[str]:
|
|
||||||
try:
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
user32 = ctypes.windll.user32
|
|
||||||
handle = user32.GetForegroundWindow()
|
|
||||||
if not handle:
|
|
||||||
return None
|
|
||||||
length = user32.GetWindowTextLengthW(handle)
|
|
||||||
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
||||||
user32.GetWindowTextW(handle, buffer, length + 1)
|
|
||||||
return buffer.value or None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _macos_title() -> Optional[str]:
|
|
||||||
script = (
|
|
||||||
'tell application "System Events" to get name of first application process '
|
|
||||||
"whose frontmost is true"
|
|
||||||
)
|
|
||||||
output = _run(["osascript", "-e", script])
|
|
||||||
return (output or "").strip() or None
|
|
||||||
|
|
||||||
|
|
||||||
def active_window_title() -> Optional[str]:
|
|
||||||
"""Focused window's title, or None if the platform won't tell us."""
|
|
||||||
try:
|
|
||||||
system = platform.system()
|
|
||||||
if system == "Linux":
|
|
||||||
return clean_title(_linux_title())
|
|
||||||
if system == "Windows":
|
|
||||||
return clean_title(_windows_title())
|
|
||||||
if system == "Darwin":
|
|
||||||
return clean_title(_macos_title())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_fullscreen_active() -> bool:
|
|
||||||
"""True when the focused window is fullscreen (call, game, presentation).
|
|
||||||
False whenever we can't tell — do-not-disturb should be something you opt
|
|
||||||
into, not something a failed probe turns on."""
|
|
||||||
try:
|
|
||||||
system = platform.system()
|
|
||||||
if system == "Linux":
|
|
||||||
window_id = _linux_active_window_id()
|
|
||||||
if window_id is None:
|
|
||||||
return False
|
|
||||||
return parse_xprop_fullscreen(_run(["xprop", "-id", window_id, "_NET_WM_STATE"]) or "")
|
|
||||||
if system == "Windows":
|
|
||||||
import ctypes
|
|
||||||
from ctypes import wintypes
|
|
||||||
|
|
||||||
user32 = ctypes.windll.user32
|
|
||||||
handle = user32.GetForegroundWindow()
|
|
||||||
if not handle:
|
|
||||||
return False
|
|
||||||
rect = wintypes.RECT()
|
|
||||||
user32.GetWindowRect(handle, ctypes.byref(rect))
|
|
||||||
screen_w = user32.GetSystemMetrics(0)
|
|
||||||
screen_h = user32.GetSystemMetrics(1)
|
|
||||||
return (rect.right - rect.left) >= screen_w and (rect.bottom - rect.top) >= screen_h
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def context_for(text: str) -> str:
|
|
||||||
"""What the controller sends: *text* plus the active window title, when
|
|
||||||
SCREEN_CONTEXT is on and there's a title worth mentioning."""
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
if not config.SCREEN_CONTEXT:
|
|
||||||
return text
|
|
||||||
return annotate(text, active_window_title())
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"""HTTP client for Bolt's desk API (ai/desk_api.py on the server).
|
|
||||||
|
|
||||||
Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo —
|
|
||||||
this pet is just another desk client, so it gets the exact same brain,
|
|
||||||
memory, tools, and persona as Discord chat and the Linux voice client:
|
|
||||||
|
|
||||||
text -> POST /desk/converse
|
|
||||||
[server may relay a shell command back to run on THIS machine]
|
|
||||||
... -> POST /desk/tool_result (repeat until the server sends a reply)
|
|
||||||
reply <- returned to caller
|
|
||||||
|
|
||||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from . import config, sudo_askpass
|
|
||||||
|
|
||||||
_MAX_RELAY_HOPS = 16
|
|
||||||
|
|
||||||
|
|
||||||
class ServerError(Exception):
|
|
||||||
"""Raised when the server responds with an error payload or unreachable."""
|
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict:
|
|
||||||
return {"X-Desk-Api-Key": config.API_KEY}
|
|
||||||
|
|
||||||
|
|
||||||
def check_health(timeout: float = 10.0) -> dict:
|
|
||||||
response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
def run_local_command(command: str, timeout: int = None) -> str:
|
|
||||||
"""Execute a command relayed by the server, exactly as bolt_desk.py does —
|
|
||||||
"full desktop control" for things like "open firefox" or "how full is my
|
|
||||||
disk". Runs as the current desktop user. See README security notes.
|
|
||||||
|
|
||||||
`sudo` gets special handling: the pet has no terminal, so sudo would sit
|
|
||||||
waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on,
|
|
||||||
bare `sudo` becomes `sudo -A` and the password is collected in a desktop
|
|
||||||
dialog you have to answer — which also gives those commands a longer
|
|
||||||
timeout, since a human has to notice the window and type."""
|
|
||||||
env = None
|
|
||||||
if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command):
|
|
||||||
helper = sudo_askpass.find_helper()
|
|
||||||
if helper:
|
|
||||||
env = sudo_askpass.environment(helper)
|
|
||||||
command = sudo_askpass.add_askpass_flag(command)
|
|
||||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
|
||||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
command, shell=True, capture_output=True, text=True,
|
|
||||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
|
||||||
)
|
|
||||||
output = (completed.stdout or "") + (completed.stderr or "")
|
|
||||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return f"[command timed out after {timeout}s]"
|
|
||||||
except Exception as exc:
|
|
||||||
return f"[command failed: {exc}]"
|
|
||||||
|
|
||||||
|
|
||||||
def converse(
|
|
||||||
text: str,
|
|
||||||
on_command: Callable[[str], str] = run_local_command,
|
|
||||||
timeout: float = 120.0,
|
|
||||||
) -> str:
|
|
||||||
"""Send one turn of conversation to the desk API, relaying any commands
|
|
||||||
the server sends back until it produces a final reply.
|
|
||||||
|
|
||||||
*on_command* is injectable for tests; defaults to actually running the
|
|
||||||
command locally (matching bolt_desk.py's behavior).
|
|
||||||
"""
|
|
||||||
headers = _headers()
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"{config.SERVER_URL}/desk/converse",
|
|
||||||
json={"session_id": config.SESSION_ID, "text": text},
|
|
||||||
headers=headers, timeout=timeout,
|
|
||||||
)
|
|
||||||
payload = response.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ServerError(f"couldn't reach the server: {exc}") from exc
|
|
||||||
|
|
||||||
for _ in range(_MAX_RELAY_HOPS):
|
|
||||||
if payload.get("type") != "command":
|
|
||||||
break
|
|
||||||
output = on_command(str(payload.get("command") or ""))
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"{config.SERVER_URL}/desk/tool_result",
|
|
||||||
json={
|
|
||||||
"session_id": config.SESSION_ID,
|
|
||||||
"token": payload.get("token"),
|
|
||||||
"output": output,
|
|
||||||
},
|
|
||||||
headers=headers, timeout=180,
|
|
||||||
)
|
|
||||||
payload = response.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
|
||||||
|
|
||||||
if payload.get("type") == "reply":
|
|
||||||
return str(payload.get("text") or "")
|
|
||||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
|
||||||
|
|
||||||
|
|
||||||
def report_status(timeout: float = 15.0) -> Optional[str]:
|
|
||||||
"""Heartbeat — lets the desk API attach a pending spoken announcement
|
|
||||||
(proactive nudges, reminders fired since the last heartbeat) that the pet
|
|
||||||
can speak unprompted, exactly like the phone/desk clients. A pet has no
|
|
||||||
battery/GPS to report, so the device-status fields
|
|
||||||
(battery/is_charging/latitude/longitude/address, all optional server-side)
|
|
||||||
are simply omitted.
|
|
||||||
|
|
||||||
Returns the announcement text to speak, or None if there's nothing pending.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"{config.SERVER_URL}/desk/report_status",
|
|
||||||
json={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ServerError(f"heartbeat failed: {exc}") from exc
|
|
||||||
reply = data.get("reply")
|
|
||||||
return str(reply) if reply else None
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Turn a server reply into something worth *hearing*.
|
|
||||||
|
|
||||||
The server's persona writes for a chat window: markdown emphasis, bullet
|
|
||||||
lists, emoji, bare URLs. A TTS voice reads those literally ("asterisk
|
|
||||||
asterisk OS colon", "https colon slash slash..."), so everything spoken goes
|
|
||||||
through for_speech() first. Pure string logic, no Qt/audio imports — cheap to
|
|
||||||
unit test (see tests/test_speech_text.py).
|
|
||||||
|
|
||||||
for_display() is the lighter sibling used for the speech bubble: it drops the
|
|
||||||
markdown *syntax* but keeps emoji and punctuation, since those render fine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Pictographs, symbols, flags, dingbats, arrows, box drawing, variation
|
|
||||||
# selectors, ZWJ — anything a voice would either skip or read as a name
|
|
||||||
# ("black right-pointing triangle").
|
|
||||||
_EMOJI = re.compile(
|
|
||||||
"["
|
|
||||||
"\U0001F000-\U0001FAFF" # emoji / pictographs / symbols blocks
|
|
||||||
"\U00002190-\U000021FF" # arrows
|
|
||||||
"\U00002300-\U000023FF" # misc technical (⌘ ⏱ …)
|
|
||||||
"\U000025A0-\U000027BF" # geometric shapes, misc symbols, dingbats
|
|
||||||
"\U00002B00-\U00002BFF" # extra arrows / shapes
|
|
||||||
"\U0000FE00-\U0000FE0F" # variation selectors
|
|
||||||
"\U0001F1E6-\U0001F1FF" # regional indicators (flags)
|
|
||||||
"\U0000200D" # zero-width joiner
|
|
||||||
"]+",
|
|
||||||
flags=re.UNICODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Characters that are markup or decoration rather than speech. Kept out of
|
|
||||||
# the spoken text entirely; ordinary punctuation (. , ! ? ; : ' " ( ) -) is
|
|
||||||
# preserved because it shapes prosody.
|
|
||||||
_UNSPEAKABLE = re.compile(r"[*_#`~^|<>\\{}\[\]/=+@©®™•·–—]+")
|
|
||||||
|
|
||||||
_FENCED_CODE = re.compile(r"```.*?```", re.DOTALL)
|
|
||||||
_INLINE_CODE = re.compile(r"`([^`]*)`")
|
|
||||||
_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
|
||||||
_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
|
||||||
_URL = re.compile(r"\b(?:https?://|www\.)\S+")
|
|
||||||
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.MULTILINE)
|
|
||||||
_BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE)
|
|
||||||
_RULE = re.compile(r"^\s*([-*_=])(?:\s*\1){2,}\s*$", re.MULTILINE)
|
|
||||||
_BULLET = re.compile(r"^\s*(?:[-*+•·]|\d+[.)])\s+", re.MULTILINE)
|
|
||||||
_EMPHASIS = re.compile(r"(\*{1,3}|_{1,3})(\S(?:.*?\S)?)\1", re.DOTALL)
|
|
||||||
_TABLE_PIPE = re.compile(r"[ \t]*\|[ \t]*")
|
|
||||||
|
|
||||||
# Symbols worth saying out loud rather than dropping — a bare "&" read as
|
|
||||||
# nothing turns "R&D" into "RD". Split in two because the non-ASCII ones sit
|
|
||||||
# inside the arrow/symbol blocks _EMOJI strips, so they have to be worded
|
|
||||||
# before that pass; the ASCII ones must wait until *after* markdown parsing
|
|
||||||
# (an "=" turned into " equals " would stop a "====" rule matching _RULE).
|
|
||||||
_PRE_SPOKEN_SYMBOLS = {
|
|
||||||
"→": " to ",
|
|
||||||
"×": " by ",
|
|
||||||
"°": " degrees ",
|
|
||||||
"≈": " about ",
|
|
||||||
}
|
|
||||||
_SPOKEN_SYMBOLS = {
|
|
||||||
"&": " and ",
|
|
||||||
"%": " percent ",
|
|
||||||
"@": " at ",
|
|
||||||
"+": " plus ",
|
|
||||||
"=": " equals ",
|
|
||||||
}
|
|
||||||
|
|
||||||
_MULTI_SPACE = re.compile(r"[ \t]+")
|
|
||||||
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_markdown(text: str, *, keep_emoji: bool) -> str:
|
|
||||||
text = _FENCED_CODE.sub(" (code) ", text)
|
|
||||||
text = _INLINE_CODE.sub(r"\1", text)
|
|
||||||
text = _MD_IMAGE.sub(r"\1", text)
|
|
||||||
text = _MD_LINK.sub(r"\1", text)
|
|
||||||
text = _RULE.sub("", text)
|
|
||||||
text = _HEADING.sub("", text)
|
|
||||||
text = _BLOCKQUOTE.sub("", text)
|
|
||||||
text = _EMPHASIS.sub(r"\2", text)
|
|
||||||
if not keep_emoji:
|
|
||||||
text = _EMOJI.sub(" ", text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _bullets_to_sentences(text: str) -> str:
|
|
||||||
"""A read-aloud list needs pauses where the eye would see line breaks,
|
|
||||||
otherwise "OS Linux Uptime 1 day CPU load moderate" runs together."""
|
|
||||||
lines = [_BULLET.sub("", line).strip() for line in text.splitlines()]
|
|
||||||
lines = [line for line in lines if line]
|
|
||||||
if len(lines) < 2:
|
|
||||||
return lines[0] if lines else ""
|
|
||||||
# A bullet like "OS: Linux" ends mid-thought — give the voice a full stop
|
|
||||||
# so consecutive items don't slur into one run-on sentence.
|
|
||||||
return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines)
|
|
||||||
|
|
||||||
|
|
||||||
def for_speech(text: str) -> str:
|
|
||||||
"""Plain prose for the TTS engine: no markdown, no emoji, no bare URLs,
|
|
||||||
no stray symbols that would be read out character by character."""
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
|
||||||
text = text.replace(symbol, spoken)
|
|
||||||
text = _strip_markdown(text, keep_emoji=False)
|
|
||||||
text = _URL.sub(" link ", text)
|
|
||||||
text = _TABLE_PIPE.sub(", ", text)
|
|
||||||
text = _bullets_to_sentences(text)
|
|
||||||
for symbol, spoken in _SPOKEN_SYMBOLS.items():
|
|
||||||
text = text.replace(symbol, spoken)
|
|
||||||
text = _UNSPEAKABLE.sub(" ", text)
|
|
||||||
text = _MULTI_PUNCT.sub(".", text)
|
|
||||||
text = _MULTI_SPACE.sub(" ", text)
|
|
||||||
text = re.sub(r"\s+([.,!?;:])", r"\1", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def is_question(text: str) -> bool:
|
|
||||||
"""True if the reply *ends* by asking the user something — the cue for
|
|
||||||
the pet to keep listening instead of making you say the wake word again.
|
|
||||||
|
|
||||||
Deliberately only looks at the end. A reply that asks something in
|
|
||||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
|
||||||
whereas one that finishes on a question mark is. The test runs on the
|
|
||||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
|
||||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
|
||||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
|
||||||
spoken = for_speech(text)
|
|
||||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
|
||||||
spoken = spoken[:-1]
|
|
||||||
return spoken.endswith("?")
|
|
||||||
|
|
||||||
|
|
||||||
def for_display(text: str) -> str:
|
|
||||||
"""What the speech bubble shows: markdown syntax removed (the bubble
|
|
||||||
can't render it) but emoji and layout-ish punctuation left alone."""
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
text = _strip_markdown(text, keep_emoji=True)
|
|
||||||
lines = [_BULLET.sub("• ", line).strip() for line in text.splitlines()]
|
|
||||||
text = " ".join(line for line in lines if line)
|
|
||||||
return _MULTI_SPACE.sub(" ", text).strip()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Pet state machine — pure logic, no Qt/audio dependencies, so it's cheap
|
|
||||||
to unit test. The UI layer (ui/pet_window.py) reacts to state changes by
|
|
||||||
swapping the active sprite animation; the worker thread (ui/app.py) drives
|
|
||||||
transitions as the mic/wake/converse/tts pipeline progresses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class PetState(str, Enum):
|
|
||||||
IDLE = "idle" # waiting for the wake phrase (or a click)
|
|
||||||
LISTENING = "listening" # actively recording an utterance
|
|
||||||
THINKING = "thinking" # waiting on the server (STT done, converse in flight)
|
|
||||||
TALKING = "talking" # playing back the TTS reply
|
|
||||||
ERROR = "error" # brief flash state on failure, then back to idle
|
|
||||||
|
|
||||||
|
|
||||||
# States it's valid to move to from each state. Keeps ad-hoc bugs (e.g.
|
|
||||||
# firing TALKING before a reply exists) from silently passing through.
|
|
||||||
#
|
|
||||||
# IDLE -> TALKING is legal (not just IDLE -> LISTENING) because of proactive
|
|
||||||
# announcements: the heartbeat poll (controller.py's _maybe_heartbeat) can
|
|
||||||
# make the pet speak unprompted — a reminder firing, a nudge from the server
|
|
||||||
# — without the user having said anything first, so there's no preceding
|
|
||||||
# LISTENING/THINKING leg for that turn.
|
|
||||||
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
|
||||||
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
|
||||||
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
|
||||||
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
|
||||||
PetState.TALKING: {PetState.IDLE, PetState.ERROR},
|
|
||||||
PetState.ERROR: {PetState.IDLE},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidTransition(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PetStateMachine:
|
|
||||||
def __init__(self, on_change: Optional[Callable[[PetState, PetState], None]] = None):
|
|
||||||
self._state = PetState.IDLE
|
|
||||||
self._on_change = on_change
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> PetState:
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
def transition(self, new_state: PetState) -> None:
|
|
||||||
if new_state == self._state:
|
|
||||||
return
|
|
||||||
allowed = _TRANSITIONS.get(self._state, set())
|
|
||||||
if new_state not in allowed:
|
|
||||||
raise InvalidTransition(f"{self._state} -> {new_state} is not allowed")
|
|
||||||
old_state = self._state
|
|
||||||
self._state = new_state
|
|
||||||
if self._on_change is not None:
|
|
||||||
self._on_change(old_state, new_state)
|
|
||||||
|
|
||||||
def force(self, new_state: PetState) -> None:
|
|
||||||
"""Bypass the transition table — used only for recovering to IDLE
|
|
||||||
from an unexpected/edge-case state (e.g. after an exception mid
|
|
||||||
pipeline). Prefer transition() everywhere else."""
|
|
||||||
old_state = self._state
|
|
||||||
if new_state == old_state:
|
|
||||||
return
|
|
||||||
self._state = new_state
|
|
||||||
if self._on_change is not None:
|
|
||||||
self._on_change(old_state, new_state)
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""Graphical password prompts for server-relayed `sudo` commands.
|
|
||||||
|
|
||||||
The pet has no terminal. When the server relays something like
|
|
||||||
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
|
|
||||||
finds the terminal the pet was launched from, which you're not looking at),
|
|
||||||
and the command fails with no way to answer it.
|
|
||||||
|
|
||||||
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
|
|
||||||
program and reads the password from the helper's stdout instead of a tty.
|
|
||||||
Any GUI prompt that prints what was typed works, so this module finds a real
|
|
||||||
askpass binary if one is installed and otherwise generates a one-line wrapper
|
|
||||||
around zenity/kdialog, which every desktop has one of.
|
|
||||||
|
|
||||||
Worth being clear about what this changes: the prompt is a *feature*, not
|
|
||||||
just plumbing. Server-relayed commands already run as your desktop user (see
|
|
||||||
server_client.run_local_command); this lets them ask to run as root, and the
|
|
||||||
dialog is the only thing standing between "Bolt decided to run sudo" and it
|
|
||||||
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
|
|
||||||
|
|
||||||
The parts that decide *what* to run are pure functions so they're tested
|
|
||||||
without a display, a password, or a working sudo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
# Real askpass binaries, in preference order. These are purpose-built for
|
|
||||||
# this (they grab the keyboard, hide the input, and don't leave the password
|
|
||||||
# in a process argument), so they win over a generated wrapper.
|
|
||||||
_KNOWN_HELPERS = (
|
|
||||||
"/usr/bin/ssh-askpass",
|
|
||||||
"/usr/lib/ssh/ssh-askpass",
|
|
||||||
"/usr/lib/openssh/gnome-ssh-askpass3",
|
|
||||||
"/usr/lib/openssh/gnome-ssh-askpass",
|
|
||||||
"/usr/libexec/openssh/ssh-askpass",
|
|
||||||
"/usr/bin/ksshaskpass",
|
|
||||||
"/usr/bin/lxqt-openssh-askpass",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Dialog tools we can wrap when no askpass binary exists. Each must print the
|
|
||||||
# typed password to stdout and nothing else.
|
|
||||||
_WRAPPABLE = {
|
|
||||||
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
|
|
||||||
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
|
|
||||||
}
|
|
||||||
|
|
||||||
# `sudo` at the start of the command or right after a shell separator, not
|
|
||||||
# already carrying a flag. Deliberately conservative: a `sudo` inside a
|
|
||||||
# quoted string or a heredoc is left alone, because rewriting it could change
|
|
||||||
# what the command means.
|
|
||||||
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
|
|
||||||
|
|
||||||
|
|
||||||
def add_askpass_flag(command: str) -> str:
|
|
||||||
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
|
|
||||||
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
|
|
||||||
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
|
|
||||||
return _SUDO.sub(r"\1\2 -A\3", command or "")
|
|
||||||
|
|
||||||
|
|
||||||
def needs_password_prompt(command: str) -> bool:
|
|
||||||
"""True if *command* has a `sudo` that might sit waiting on a dialog.
|
|
||||||
Used to give those commands a longer timeout — 30 seconds is fine for a
|
|
||||||
shell command and nowhere near enough for a human to notice a window,
|
|
||||||
read it, and type a password."""
|
|
||||||
return bool(_SUDO.search(command or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def helper_script(tool_path: str) -> str:
|
|
||||||
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
|
|
||||||
("[sudo] password for maji:") as $1, which is worth showing — it names
|
|
||||||
the user the password is for."""
|
|
||||||
name = Path(tool_path).name
|
|
||||||
body = _WRAPPABLE[name].format(tool=tool_path)
|
|
||||||
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _default_cache_dir() -> Path:
|
|
||||||
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
||||||
return Path(base) / "bolt-pet"
|
|
||||||
|
|
||||||
|
|
||||||
def find_helper(
|
|
||||||
configured: str = None,
|
|
||||||
is_executable: Callable[[str], bool] = None,
|
|
||||||
which: Callable[[str], Optional[str]] = None,
|
|
||||||
cache_dir: Path = None,
|
|
||||||
write: bool = True,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Path to an askpass helper, or None if the desktop has nothing we can
|
|
||||||
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
|
|
||||||
binary, then a generated wrapper around zenity/kdialog.
|
|
||||||
|
|
||||||
The lookups are injectable so the resolution order is testable on a box
|
|
||||||
with a different set of these installed than yours."""
|
|
||||||
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
|
|
||||||
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
|
|
||||||
which = which or shutil.which
|
|
||||||
|
|
||||||
if configured:
|
|
||||||
return configured if is_executable(configured) else None
|
|
||||||
|
|
||||||
for candidate in _KNOWN_HELPERS:
|
|
||||||
if is_executable(candidate):
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
for tool in _WRAPPABLE:
|
|
||||||
tool_path = which(tool)
|
|
||||||
if not tool_path:
|
|
||||||
continue
|
|
||||||
if not write:
|
|
||||||
return tool_path
|
|
||||||
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
|
|
||||||
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
|
|
||||||
isn't secret, but it's a thing that pops up a password box, so nobody
|
|
||||||
else on the machine gets to edit it."""
|
|
||||||
try:
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
script = cache_dir / "askpass.sh"
|
|
||||||
source = helper_script(tool_path)
|
|
||||||
if not script.exists() or script.read_text(encoding="utf-8") != source:
|
|
||||||
script.write_text(source, encoding="utf-8")
|
|
||||||
script.chmod(stat.S_IRWXU)
|
|
||||||
return str(script)
|
|
||||||
except Exception:
|
|
||||||
return None # no prompt is better than a crashed command relay
|
|
||||||
|
|
||||||
|
|
||||||
def environment(helper: str, base: dict = None) -> dict:
|
|
||||||
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
|
|
||||||
env = dict(os.environ if base is None else base)
|
|
||||||
env["SUDO_ASKPASS"] = helper
|
|
||||||
return env
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""Wires everything together: QApplication, the pet window, the tray icon,
|
|
||||||
the history / wake-tuner windows, the global push-to-talk hotkey, and the
|
|
||||||
background PetController thread that owns the mic/wake/server/TTS pipeline.
|
|
||||||
|
|
||||||
Everything the controller wants the UI to do arrives as a Qt signal, so the
|
|
||||||
worker thread never touches a widget.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from PySide6.QtCore import QThread
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from .. import config, updater
|
|
||||||
from ..controller import PetController
|
|
||||||
from ..hotkey import GlobalHotkey
|
|
||||||
from ..state import PetState
|
|
||||||
from .history_window import HistoryWindow
|
|
||||||
from .pet_window import PetWindow
|
|
||||||
from .tray import PetTray
|
|
||||||
from .wake_tuner import WakeTunerWindow
|
|
||||||
|
|
||||||
|
|
||||||
def _log(message: str) -> None:
|
|
||||||
print(message, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run() -> int:
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
app.setQuitOnLastWindowClosed(False) # tray-driven app; closing the pet isn't "quit"
|
|
||||||
|
|
||||||
window = PetWindow()
|
|
||||||
window.show()
|
|
||||||
|
|
||||||
controller = PetController()
|
|
||||||
thread = QThread()
|
|
||||||
controller.moveToThread(thread)
|
|
||||||
|
|
||||||
thread.started.connect(controller.run)
|
|
||||||
controller.state_changed.connect(lambda value: window.set_state(PetState(value)))
|
|
||||||
controller.said.connect(window.say)
|
|
||||||
controller.log.connect(_log)
|
|
||||||
controller.action.connect(window.apply_action) # petctl move/emote/say/...
|
|
||||||
controller.finished.connect(thread.quit)
|
|
||||||
|
|
||||||
window.talk_requested.connect(controller.request_talk_now)
|
|
||||||
window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
|
|
||||||
|
|
||||||
history_window = HistoryWindow(controller.history)
|
|
||||||
tuner_window = WakeTunerWindow(
|
|
||||||
get_threshold=controller.wake_threshold,
|
|
||||||
set_threshold=controller.set_wake_threshold,
|
|
||||||
get_stats=controller.wake_stats,
|
|
||||||
on_reset=controller.reset_wake_stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _set_nap(napping: bool) -> None:
|
|
||||||
# Clicking the tray item pins the state; the schedule takes over again
|
|
||||||
# only after a restart or a `petctl nap off`.
|
|
||||||
controller.set_napping(napping)
|
|
||||||
window.set_napping(napping)
|
|
||||||
|
|
||||||
tray = PetTray(
|
|
||||||
on_talk_now=controller.request_talk_now,
|
|
||||||
on_toggle_mute=controller.toggle_mute,
|
|
||||||
on_quit=app.quit,
|
|
||||||
on_set_wander=window.set_wander_enabled,
|
|
||||||
wander_enabled=config.PET_WANDER,
|
|
||||||
on_set_click_through=window.set_click_through,
|
|
||||||
click_through_enabled=config.PET_CLICK_THROUGH,
|
|
||||||
on_set_nap=_set_nap,
|
|
||||||
on_show_history=history_window.show_refreshed,
|
|
||||||
on_show_wake_tuner=tuner_window.show_refreshed,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_napping(napping: bool) -> None:
|
|
||||||
window.set_napping(napping)
|
|
||||||
tray.set_napping(napping)
|
|
||||||
|
|
||||||
controller.napping.connect(_handle_napping)
|
|
||||||
|
|
||||||
# Push-to-talk: a global hook, because the pet window never has focus.
|
|
||||||
# request_talk_now() only sets a threading.Event, so it's safe to call
|
|
||||||
# from pynput's listener thread.
|
|
||||||
hotkey = GlobalHotkey(config.PUSH_TO_TALK_HOTKEY, controller.request_talk_now)
|
|
||||||
problem = hotkey.start()
|
|
||||||
if problem:
|
|
||||||
_log(problem)
|
|
||||||
elif hotkey.running:
|
|
||||||
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
|
|
||||||
|
|
||||||
# The updater has already moved the checkout by the time this fires; all
|
|
||||||
# that's left is to let Qt tear down cleanly (so the mic and the tray
|
|
||||||
# icon are released) and then exec the new code. Doing the exec after
|
|
||||||
# app.exec() returns, rather than from the controller thread, is what
|
|
||||||
# guarantees the audio device is free before the new process opens it.
|
|
||||||
pending_restart = {"tag": None}
|
|
||||||
|
|
||||||
def _handle_restart(tag: str) -> None:
|
|
||||||
pending_restart["tag"] = tag
|
|
||||||
app.quit()
|
|
||||||
|
|
||||||
controller.restart_requested.connect(_handle_restart)
|
|
||||||
|
|
||||||
def _shutdown() -> None:
|
|
||||||
hotkey.stop()
|
|
||||||
controller.stop()
|
|
||||||
thread.quit()
|
|
||||||
thread.wait(5000)
|
|
||||||
|
|
||||||
app.aboutToQuit.connect(_shutdown)
|
|
||||||
|
|
||||||
thread.start()
|
|
||||||
status = app.exec()
|
|
||||||
|
|
||||||
if pending_restart["tag"]:
|
|
||||||
_log(f"Restarting into {pending_restart['tag']}…")
|
|
||||||
try:
|
|
||||||
updater.restart() # never returns
|
|
||||||
except Exception as exc:
|
|
||||||
_log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.")
|
|
||||||
return status
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""Scrollback for the speech bubble.
|
|
||||||
|
|
||||||
The bubble is transient by design, so anything Bolt said more than a few
|
|
||||||
seconds ago is gone. This is the "wait, what was that path again?" window:
|
|
||||||
the last HISTORY_LIMIT turns, copyable. Opened from the tray.
|
|
||||||
|
|
||||||
Reads a bolt_pet.history.ConversationHistory (pure logic, tested separately);
|
|
||||||
this file is only presentation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QApplication, QDialog, QHBoxLayout, QPlainTextEdit, QPushButton, QVBoxLayout,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..history import ConversationHistory
|
|
||||||
|
|
||||||
|
|
||||||
def _clock(timestamp: float) -> str:
|
|
||||||
return time.strftime("%H:%M:%S", time.localtime(timestamp))
|
|
||||||
|
|
||||||
|
|
||||||
class HistoryWindow(QDialog):
|
|
||||||
def __init__(self, history: ConversationHistory, on_clear: Optional[Callable[[], None]] = None):
|
|
||||||
super().__init__()
|
|
||||||
self._history = history
|
|
||||||
self._on_clear = on_clear
|
|
||||||
self.setWindowTitle("Bolt — conversation history")
|
|
||||||
self.resize(620, 420)
|
|
||||||
|
|
||||||
self._view = QPlainTextEdit()
|
|
||||||
self._view.setReadOnly(True)
|
|
||||||
self._view.setLineWrapMode(QPlainTextEdit.WidgetWidth)
|
|
||||||
|
|
||||||
copy_button = QPushButton("Copy all")
|
|
||||||
copy_button.clicked.connect(self._copy_all)
|
|
||||||
clear_button = QPushButton("Clear")
|
|
||||||
clear_button.clicked.connect(self._clear)
|
|
||||||
close_button = QPushButton("Close")
|
|
||||||
close_button.clicked.connect(self.close)
|
|
||||||
close_button.setDefault(True)
|
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
|
||||||
buttons.addWidget(copy_button)
|
|
||||||
buttons.addWidget(clear_button)
|
|
||||||
buttons.addStretch(1)
|
|
||||||
buttons.addWidget(close_button)
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.addWidget(self._view)
|
|
||||||
layout.addLayout(buttons)
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
|
||||||
self._view.setPlainText(self._history.as_text(clock=_clock))
|
|
||||||
# Jump to the newest line — that's what you opened this for.
|
|
||||||
scrollbar = self._view.verticalScrollBar()
|
|
||||||
scrollbar.setValue(scrollbar.maximum())
|
|
||||||
|
|
||||||
def show_refreshed(self) -> None:
|
|
||||||
self.refresh()
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
self.activateWindow()
|
|
||||||
|
|
||||||
def _copy_all(self) -> None:
|
|
||||||
QApplication.clipboard().setText(self._history.as_text(clock=_clock))
|
|
||||||
|
|
||||||
def _clear(self) -> None:
|
|
||||||
self._history.clear()
|
|
||||||
if self._on_clear is not None:
|
|
||||||
self._on_clear()
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def keyPressEvent(self, event) -> None:
|
|
||||||
if event.key() == Qt.Key_Escape:
|
|
||||||
self.close()
|
|
||||||
return
|
|
||||||
super().keyPressEvent(event)
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
"""The pet itself: a frameless, translucent, always-on-top window that
|
|
||||||
renders the current sprite animation, wanders the desktop on its own while
|
|
||||||
idle, walks/emotes on command from the server (see pet_actions.py), can be
|
|
||||||
dragged around, dims when napping, and turns a plain (non-drag) click into a
|
|
||||||
"talk now" request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import QPoint, Qt, QTimer, Signal
|
|
||||||
from PySide6.QtGui import (
|
|
||||||
QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion, QTransform,
|
|
||||||
)
|
|
||||||
from PySide6.QtWidgets import QApplication, QWidget
|
|
||||||
|
|
||||||
from .. import config
|
|
||||||
from ..state import PetState
|
|
||||||
from .sprite import SpriteSet
|
|
||||||
|
|
||||||
_DRAG_THRESHOLD_PX = 4
|
|
||||||
# Movement runs on its own ~30fps timer, independent of the (slower) sprite
|
|
||||||
# animation timer, so a stroll looks smooth even at IDLE_ANIMATION_FPS=6.
|
|
||||||
_WANDER_TICK_MS = 33
|
|
||||||
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
|
|
||||||
_NAP_OPACITY = 0.35
|
|
||||||
|
|
||||||
|
|
||||||
def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
|
|
||||||
"""(dx, dy, rotation_degrees, scale) for an emote at *progress* 0..1.
|
|
||||||
|
|
||||||
Pure maths, deliberately separate from paintEvent so the motion curves can
|
|
||||||
be unit tested (and so adding an emote doesn't mean touching painting
|
|
||||||
code). Every emote must return to (0, 0, 0, 1) at progress 1.0, otherwise
|
|
||||||
the pet ends up permanently askew.
|
|
||||||
"""
|
|
||||||
progress = min(max(progress, 0.0), 1.0)
|
|
||||||
fade = math.sin(math.pi * progress) # 0 -> 1 -> 0, so it always lands home
|
|
||||||
tau = 2 * math.pi
|
|
||||||
if emote == "wave":
|
|
||||||
return 0.0, 0.0, 14.0 * fade * math.sin(tau * 2 * progress), 1.0
|
|
||||||
if emote in ("hop", "bounce"):
|
|
||||||
hops = 2 if emote == "hop" else 3
|
|
||||||
return 0.0, -22.0 * fade * abs(math.sin(math.pi * hops * progress)), 0.0, 1.0
|
|
||||||
if emote == "spin":
|
|
||||||
return 0.0, 0.0, 360.0 * progress % 360.0, 1.0
|
|
||||||
if emote == "nod":
|
|
||||||
return 0.0, 10.0 * fade * math.sin(tau * 2 * progress), 0.0, 1.0 - 0.05 * fade
|
|
||||||
if emote in ("shake", "wiggle"):
|
|
||||||
return 14.0 * fade * math.sin(tau * 3 * progress), 0.0, 0.0, 1.0
|
|
||||||
return 0.0, 0.0, 0.0, 1.0
|
|
||||||
|
|
||||||
|
|
||||||
class SpeechBubble(QWidget):
|
|
||||||
"""Small translucent word-bubble shown above the pet while it talks."""
|
|
||||||
|
|
||||||
_MAX_WIDTH = 260
|
|
||||||
_PADDING = 10
|
|
||||||
|
|
||||||
copied = Signal(str)
|
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None):
|
|
||||||
super().__init__(parent, Qt.FramelessWindowHint | Qt.Tool)
|
|
||||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
|
||||||
self._text = ""
|
|
||||||
self._font = QFont()
|
|
||||||
self._font.setPointSize(10)
|
|
||||||
self._flash = "" # transient overlay ("Copied") drawn over the text
|
|
||||||
self._hide_timer = QTimer(self)
|
|
||||||
self._hide_timer.setSingleShot(True)
|
|
||||||
self._hide_timer.timeout.connect(self.hide)
|
|
||||||
self._flash_timer = QTimer(self)
|
|
||||||
self._flash_timer.setSingleShot(True)
|
|
||||||
self._flash_timer.timeout.connect(self._clear_flash)
|
|
||||||
self.setToolTip("Click to copy")
|
|
||||||
self.setCursor(Qt.PointingHandCursor)
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self) -> str:
|
|
||||||
return self._text
|
|
||||||
|
|
||||||
def show_text(self, text: str, duration_ms: int = 6000) -> None:
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
self.hide()
|
|
||||||
return
|
|
||||||
self._text = text
|
|
||||||
self._relayout()
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
self._hide_timer.start(duration_ms)
|
|
||||||
|
|
||||||
# ── click to copy ────────────────────────────────────────────────────
|
|
||||||
# The bubble hides itself after a few seconds, which is fine for chat and
|
|
||||||
# awful for anything you needed to keep (a path, a number, a command's
|
|
||||||
# output). One click puts it on the clipboard; the tray's History window
|
|
||||||
# has the rest.
|
|
||||||
|
|
||||||
def mousePressEvent(self, event) -> None:
|
|
||||||
if event.button() != Qt.LeftButton or not self._text:
|
|
||||||
return
|
|
||||||
QApplication.clipboard().setText(self._text)
|
|
||||||
self.copied.emit(self._text)
|
|
||||||
self._flash = "Copied to clipboard"
|
|
||||||
self.update()
|
|
||||||
self._flash_timer.start(900)
|
|
||||||
self._hide_timer.start(2500) # linger a moment so the flash is visible
|
|
||||||
|
|
||||||
def _clear_flash(self) -> None:
|
|
||||||
self._flash = ""
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def _wrapped_lines(self) -> list[str]:
|
|
||||||
metrics = QFontMetrics(self._font)
|
|
||||||
words = self._text.split()
|
|
||||||
lines: list[str] = []
|
|
||||||
current = ""
|
|
||||||
max_text_width = self._MAX_WIDTH - 2 * self._PADDING
|
|
||||||
for word in words:
|
|
||||||
candidate = f"{current} {word}".strip()
|
|
||||||
if metrics.horizontalAdvance(candidate) <= max_text_width or not current:
|
|
||||||
current = candidate
|
|
||||||
else:
|
|
||||||
lines.append(current)
|
|
||||||
current = word
|
|
||||||
if current:
|
|
||||||
lines.append(current)
|
|
||||||
return lines[:6] # don't let a huge reply turn into a wall of bubble
|
|
||||||
|
|
||||||
def _relayout(self) -> None:
|
|
||||||
metrics = QFontMetrics(self._font)
|
|
||||||
lines = self._wrapped_lines()
|
|
||||||
text_width = max((metrics.horizontalAdvance(line) for line in lines), default=0)
|
|
||||||
width = min(self._MAX_WIDTH, text_width + 2 * self._PADDING)
|
|
||||||
height = metrics.height() * len(lines) + 2 * self._PADDING
|
|
||||||
self.resize(width, height)
|
|
||||||
|
|
||||||
def paintEvent(self, _event) -> None:
|
|
||||||
painter = QPainter(self)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
path = QPainterPath()
|
|
||||||
path.addRoundedRect(0, 0, self.width(), self.height(), 10, 10)
|
|
||||||
painter.fillPath(path, QColor(30, 30, 35, 230))
|
|
||||||
painter.setFont(self._font)
|
|
||||||
metrics = QFontMetrics(self._font)
|
|
||||||
if self._flash:
|
|
||||||
painter.setPen(QColor(150, 230, 170))
|
|
||||||
painter.drawText(self.rect(), Qt.AlignCenter, self._flash)
|
|
||||||
return
|
|
||||||
painter.setPen(QColor(240, 240, 245))
|
|
||||||
y = self._PADDING + metrics.ascent()
|
|
||||||
for line in self._wrapped_lines():
|
|
||||||
painter.drawText(self._PADDING, y, line)
|
|
||||||
y += metrics.height()
|
|
||||||
|
|
||||||
|
|
||||||
class PetWindow(QWidget):
|
|
||||||
talk_requested = Signal()
|
|
||||||
copied = Signal(str) # bubble text the user just put on the clipboard
|
|
||||||
|
|
||||||
def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
|
|
||||||
super().__init__()
|
|
||||||
flags = Qt.FramelessWindowHint | Qt.Tool
|
|
||||||
if config.PET_ALWAYS_ON_TOP:
|
|
||||||
flags |= Qt.WindowStaysOnTopHint
|
|
||||||
self.setWindowFlags(flags)
|
|
||||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
|
||||||
|
|
||||||
self.sprites = SpriteSet(sprite_dir or (Path(__file__).resolve().parent.parent / "assets" / "sprites"),
|
|
||||||
size or config.PET_SIZE)
|
|
||||||
self.resize(self.sprites.size, self.sprites.size)
|
|
||||||
self._current_state = PetState.IDLE
|
|
||||||
|
|
||||||
self._drag_offset: Optional[QPoint] = None
|
|
||||||
self._press_pos: Optional[QPoint] = None
|
|
||||||
self._dragged = False
|
|
||||||
|
|
||||||
self._bubble = SpeechBubble()
|
|
||||||
self._bubble.copied.connect(self.copied)
|
|
||||||
|
|
||||||
self._napping = False
|
|
||||||
self._emote: Optional[str] = None
|
|
||||||
self._emote_tick = 0
|
|
||||||
self._mask_key = None
|
|
||||||
|
|
||||||
self._anim_timer = QTimer(self)
|
|
||||||
self._anim_timer.timeout.connect(self._advance_frame)
|
|
||||||
fps = max(1.0, config.IDLE_ANIMATION_FPS)
|
|
||||||
self._anim_timer.start(int(1000 / fps))
|
|
||||||
|
|
||||||
self._wander_enabled = config.PET_WANDER
|
|
||||||
self._wander_target: Optional[QPoint] = None
|
|
||||||
self._commanded_move = False # a petctl move — happens even mid-conversation
|
|
||||||
self._next_wander_at = 0.0
|
|
||||||
self._bob_offset = 0
|
|
||||||
self._bob_phase = 0.0
|
|
||||||
self._schedule_next_wander()
|
|
||||||
self._wander_timer = QTimer(self)
|
|
||||||
self._wander_timer.timeout.connect(self._movement_tick)
|
|
||||||
self._wander_timer.start(_WANDER_TICK_MS)
|
|
||||||
|
|
||||||
self._click_through = False
|
|
||||||
self.set_click_through(config.PET_CLICK_THROUGH)
|
|
||||||
self._place_start_position()
|
|
||||||
|
|
||||||
# ── placement ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _place_start_position(self) -> None:
|
|
||||||
screen = QApplication.primaryScreen()
|
|
||||||
geo = screen.availableGeometry() if screen else None
|
|
||||||
try:
|
|
||||||
x = int(config.PET_START_X) if config.PET_START_X else None
|
|
||||||
y = int(config.PET_START_Y) if config.PET_START_Y else None
|
|
||||||
except ValueError:
|
|
||||||
x = y = None
|
|
||||||
if geo is not None:
|
|
||||||
x = geo.right() - self.width() - 40 if x is None else x
|
|
||||||
y = geo.bottom() - self.height() - 60 if y is None else y
|
|
||||||
self.move(x or 0, y or 0)
|
|
||||||
self._reposition_bubble()
|
|
||||||
|
|
||||||
def _reposition_bubble(self) -> None:
|
|
||||||
top_left = self.geometry().topLeft()
|
|
||||||
self._bubble.move(
|
|
||||||
top_left.x() + self.width() // 2 - self._bubble.width() // 2,
|
|
||||||
top_left.y() - self._bubble.height() - 8,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── server-driven actions (petctl) ───────────────────────────────────
|
|
||||||
|
|
||||||
def apply_action(self, action: dict) -> None:
|
|
||||||
"""Perform one parsed petctl action (see pet_actions.py). Called on
|
|
||||||
the UI thread via a queued signal from the controller."""
|
|
||||||
kind = action.get("action")
|
|
||||||
if kind == "move":
|
|
||||||
target = self._resolve_move_target(action)
|
|
||||||
if target is not None:
|
|
||||||
self._wander_target = target
|
|
||||||
self._commanded_move = True # overrides the idle-only rule
|
|
||||||
elif kind == "emote":
|
|
||||||
self.start_emote(action["emote"])
|
|
||||||
elif kind == "say":
|
|
||||||
self.say(action["text"])
|
|
||||||
elif kind == "wander":
|
|
||||||
self.set_wander_enabled(bool(action["enabled"]))
|
|
||||||
elif kind == "nap":
|
|
||||||
self.set_napping(bool(action["enabled"]))
|
|
||||||
|
|
||||||
def _resolve_move_target(self, action: dict) -> Optional[QPoint]:
|
|
||||||
geo = self._screen_geometry()
|
|
||||||
if "x" in action and "y" in action:
|
|
||||||
point = QPoint(int(action["x"]), int(action["y"]))
|
|
||||||
return self._clamp_to_screen(point, geo)
|
|
||||||
anchor = action.get("anchor")
|
|
||||||
if anchor == "cursor":
|
|
||||||
cursor = QCursor.pos()
|
|
||||||
return self._clamp_to_screen(
|
|
||||||
QPoint(cursor.x() - self.width() // 2, cursor.y() - self.height() // 2), geo
|
|
||||||
)
|
|
||||||
if geo is None:
|
|
||||||
return None
|
|
||||||
if anchor == "random":
|
|
||||||
return self._pick_wander_target()
|
|
||||||
margin = config.PET_WANDER_MARGIN
|
|
||||||
left, right = geo.left() + margin, geo.right() - self.width() - margin
|
|
||||||
top, bottom = geo.top() + margin, geo.bottom() - self.height() - margin
|
|
||||||
middle_x = geo.left() + (geo.width() - self.width()) // 2
|
|
||||||
middle_y = geo.top() + (geo.height() - self.height()) // 2
|
|
||||||
positions = {
|
|
||||||
"top-left": (left, top), "top": (middle_x, top), "top-right": (right, top),
|
|
||||||
"left": (left, middle_y), "center": (middle_x, middle_y), "right": (right, middle_y),
|
|
||||||
"bottom-left": (left, bottom), "bottom": (middle_x, bottom), "bottom-right": (right, bottom),
|
|
||||||
}
|
|
||||||
if anchor not in positions:
|
|
||||||
return None
|
|
||||||
return QPoint(*positions[anchor])
|
|
||||||
|
|
||||||
def _clamp_to_screen(self, point: QPoint, geo) -> QPoint:
|
|
||||||
if geo is None:
|
|
||||||
return point
|
|
||||||
x = min(max(point.x(), geo.left()), max(geo.left(), geo.right() - self.width()))
|
|
||||||
y = min(max(point.y(), geo.top()), max(geo.top(), geo.bottom() - self.height()))
|
|
||||||
return QPoint(x, y)
|
|
||||||
|
|
||||||
# ── emotes ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def start_emote(self, emote: str) -> None:
|
|
||||||
self._emote = emote
|
|
||||||
self._emote_tick = 0
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def _advance_emote(self) -> None:
|
|
||||||
if self._emote is None:
|
|
||||||
return
|
|
||||||
self._emote_tick += 1
|
|
||||||
if self._emote_tick > _EMOTE_TICKS:
|
|
||||||
self._emote = None
|
|
||||||
self._emote_tick = 0
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def _emote_transform(self) -> tuple[float, float, float, float]:
|
|
||||||
if self._emote is None:
|
|
||||||
return 0.0, 0.0, 0.0, 1.0
|
|
||||||
return emote_transform(self._emote, self._emote_tick / _EMOTE_TICKS)
|
|
||||||
|
|
||||||
# ── napping (quiet hours / do-not-disturb) ───────────────────────────
|
|
||||||
|
|
||||||
def set_napping(self, napping: bool) -> None:
|
|
||||||
"""Dim and stand still. Purely cosmetic here — the controller is what
|
|
||||||
actually suppresses proactive speech."""
|
|
||||||
if napping == self._napping:
|
|
||||||
return
|
|
||||||
self._napping = napping
|
|
||||||
self.setWindowOpacity(_NAP_OPACITY if napping else 1.0)
|
|
||||||
if napping:
|
|
||||||
self._stop_walking()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def napping(self) -> bool:
|
|
||||||
return self._napping
|
|
||||||
|
|
||||||
# ── mouse transparency ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def set_click_through(self, enabled: bool) -> None:
|
|
||||||
"""When on, the pet ignores the mouse entirely (tray-only control) —
|
|
||||||
for when it's parked over something you need to click a lot."""
|
|
||||||
self._click_through = enabled
|
|
||||||
self.setAttribute(Qt.WA_TransparentForMouseEvents, enabled)
|
|
||||||
if enabled:
|
|
||||||
self.clearMask()
|
|
||||||
self._mask_key = None
|
|
||||||
else:
|
|
||||||
self._mask_key = None # force the shaped mask to be rebuilt
|
|
||||||
|
|
||||||
@property
|
|
||||||
def click_through(self) -> bool:
|
|
||||||
return self._click_through
|
|
||||||
|
|
||||||
def _apply_input_mask(self, pixmap: Optional[QPixmap], x: int, y: int) -> None:
|
|
||||||
"""Restrict the window to the sprite's opaque pixels, so the square
|
|
||||||
window's transparent corners stop swallowing clicks meant for what's
|
|
||||||
underneath. Rebuilt only when the frame actually changes — the mask
|
|
||||||
is derived from the pixmap's alpha, which isn't free."""
|
|
||||||
if self._click_through or not config.PET_SHAPED_INPUT:
|
|
||||||
return
|
|
||||||
if pixmap is None:
|
|
||||||
if self._mask_key is not None:
|
|
||||||
self.clearMask()
|
|
||||||
self._mask_key = None
|
|
||||||
return
|
|
||||||
key = (pixmap.cacheKey(), x, y)
|
|
||||||
if key == self._mask_key:
|
|
||||||
return
|
|
||||||
self._mask_key = key
|
|
||||||
try:
|
|
||||||
region = QRegion(pixmap.mask())
|
|
||||||
region.translate(x, y)
|
|
||||||
self.setMask(region)
|
|
||||||
except Exception:
|
|
||||||
self.clearMask() # a sprite without an alpha channel — never mind
|
|
||||||
|
|
||||||
# ── wandering ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def set_wander_enabled(self, enabled: bool) -> None:
|
|
||||||
self._wander_enabled = enabled
|
|
||||||
if not enabled:
|
|
||||||
self._stop_walking()
|
|
||||||
|
|
||||||
def wander_now(self) -> None:
|
|
||||||
"""Stroll immediately (tray menu / anything that wants a nudge)."""
|
|
||||||
self._next_wander_at = 0.0
|
|
||||||
|
|
||||||
def _screen_geometry(self):
|
|
||||||
# screenAt() so a multi-monitor setup keeps the pet on the screen
|
|
||||||
# it's currently standing on rather than yanking it to the primary.
|
|
||||||
screen = QApplication.screenAt(self.frameGeometry().center()) or QApplication.primaryScreen()
|
|
||||||
return screen.availableGeometry() if screen else None
|
|
||||||
|
|
||||||
def _schedule_next_wander(self) -> None:
|
|
||||||
base = max(1.0, config.PET_WANDER_INTERVAL_SECONDS)
|
|
||||||
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
|
|
||||||
|
|
||||||
def _stop_walking(self) -> None:
|
|
||||||
if self._wander_target is None and not self._bob_offset:
|
|
||||||
return
|
|
||||||
self._wander_target = None
|
|
||||||
self._commanded_move = False
|
|
||||||
self._bob_phase = 0.0
|
|
||||||
self._bob_offset = 0
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def snap_to_edge(self) -> bool:
|
|
||||||
"""If the pet has come to rest near a screen edge, tuck it flush
|
|
||||||
against it — a desktop pet parked 11px off the taskbar looks like a
|
|
||||||
bug. Returns True if it moved."""
|
|
||||||
geo = self._screen_geometry()
|
|
||||||
if geo is None or not config.PET_EDGE_SNAP:
|
|
||||||
return False
|
|
||||||
margin = config.PET_SNAP_MARGIN
|
|
||||||
here = self.pos()
|
|
||||||
x, y = here.x(), here.y()
|
|
||||||
if abs(x - geo.left()) <= margin:
|
|
||||||
x = geo.left()
|
|
||||||
elif abs(geo.right() - (x + self.width())) <= margin:
|
|
||||||
x = geo.right() - self.width() + 1
|
|
||||||
if abs(y - geo.top()) <= margin:
|
|
||||||
y = geo.top()
|
|
||||||
elif abs(geo.bottom() - (y + self.height())) <= margin:
|
|
||||||
y = geo.bottom() - self.height() + 1
|
|
||||||
if (x, y) == (here.x(), here.y()):
|
|
||||||
return False
|
|
||||||
self.move(x, y)
|
|
||||||
self._reposition_bubble()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _pick_wander_target(self) -> Optional[QPoint]:
|
|
||||||
geo = self._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
return None
|
|
||||||
margin = config.PET_WANDER_MARGIN
|
|
||||||
min_x, max_x = geo.left() + margin, geo.right() - self.width() - margin
|
|
||||||
min_y, max_y = geo.top() + margin, geo.bottom() - self.height() - margin
|
|
||||||
if max_x <= min_x or max_y <= min_y: # pet bigger than the screen
|
|
||||||
return None
|
|
||||||
here = self.pos()
|
|
||||||
target = QPoint(random.randint(min_x, max_x), random.randint(min_y, max_y))
|
|
||||||
dx, dy = target.x() - here.x(), target.y() - here.y()
|
|
||||||
distance = math.hypot(dx, dy)
|
|
||||||
limit = max(1.0, config.PET_WANDER_MAX_DISTANCE)
|
|
||||||
if distance > limit: # shorten the trip rather than sprinting the diagonal
|
|
||||||
scale = limit / distance
|
|
||||||
target = QPoint(round(here.x() + dx * scale), round(here.y() + dy * scale))
|
|
||||||
elif distance < 8: # already there — not worth a stroll
|
|
||||||
return None
|
|
||||||
return target
|
|
||||||
|
|
||||||
def _movement_tick(self) -> None:
|
|
||||||
"""One timer, two jobs — emotes play whatever the pet is doing, while
|
|
||||||
wandering only happens when it's otherwise unoccupied."""
|
|
||||||
self._advance_emote()
|
|
||||||
self._wander_tick()
|
|
||||||
|
|
||||||
def _wander_tick(self) -> None:
|
|
||||||
# Only stroll while genuinely idle: not mid-drag, not napping, not
|
|
||||||
# talking/listening, and not while a speech bubble is up (it would walk
|
|
||||||
# out from under it). A commanded `petctl move` ignores all of that
|
|
||||||
# except the drag — if Bolt says go, it goes.
|
|
||||||
busy = (
|
|
||||||
not self._wander_enabled
|
|
||||||
or self._napping
|
|
||||||
or self._current_state != PetState.IDLE
|
|
||||||
or self._bubble.isVisible()
|
|
||||||
)
|
|
||||||
if self._drag_offset is not None or (busy and not self._commanded_move):
|
|
||||||
self._stop_walking()
|
|
||||||
self._schedule_next_wander() # settle first, then wander
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._wander_target is None:
|
|
||||||
if time.monotonic() < self._next_wander_at:
|
|
||||||
return
|
|
||||||
self._wander_target = self._pick_wander_target()
|
|
||||||
if self._wander_target is None:
|
|
||||||
self._schedule_next_wander()
|
|
||||||
return
|
|
||||||
|
|
||||||
here = self.pos()
|
|
||||||
dx = self._wander_target.x() - here.x()
|
|
||||||
dy = self._wander_target.y() - here.y()
|
|
||||||
distance = math.hypot(dx, dy)
|
|
||||||
step = max(1.0, config.PET_WANDER_SPEED * _WANDER_TICK_MS / 1000.0)
|
|
||||||
if distance <= step:
|
|
||||||
self.move(self._wander_target)
|
|
||||||
self._stop_walking()
|
|
||||||
self._schedule_next_wander()
|
|
||||||
self.snap_to_edge()
|
|
||||||
else:
|
|
||||||
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
|
|
||||||
self._bob_phase += 0.45 # little walk-cycle hop
|
|
||||||
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
|
|
||||||
self.update()
|
|
||||||
self._reposition_bubble()
|
|
||||||
|
|
||||||
# ── state / speech ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def set_state(self, state: PetState) -> None:
|
|
||||||
if state == self._current_state:
|
|
||||||
return
|
|
||||||
self._current_state = state
|
|
||||||
self.sprites.get(state).reset()
|
|
||||||
if state != PetState.IDLE and not self._commanded_move:
|
|
||||||
# Stand still while listening/thinking/talking — but not if Bolt
|
|
||||||
# just told it to walk somewhere: that command arrives mid-turn,
|
|
||||||
# and the reply (-> TALKING) lands a moment later.
|
|
||||||
self._stop_walking()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def say(self, text: str, duration_ms: int = 6000) -> None:
|
|
||||||
self._reposition_bubble()
|
|
||||||
self._bubble.show_text(text, duration_ms)
|
|
||||||
|
|
||||||
def closeEvent(self, event) -> None:
|
|
||||||
self._bubble.close()
|
|
||||||
super().closeEvent(event)
|
|
||||||
|
|
||||||
# ── animation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _advance_frame(self) -> None:
|
|
||||||
self.sprites.get(self._current_state).advance()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def paintEvent(self, _event) -> None:
|
|
||||||
painter = QPainter(self)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
painter.setRenderHint(QPainter.SmoothPixmapTransform)
|
|
||||||
pixmap: Optional[QPixmap] = self.sprites.get(self._current_state).current()
|
|
||||||
if pixmap is None:
|
|
||||||
self._apply_input_mask(None, 0, 0)
|
|
||||||
return
|
|
||||||
# Non-square source art (e.g. the Kenney robot sprites) keeps its
|
|
||||||
# aspect ratio when scaled in SpriteSet, so it may be narrower or
|
|
||||||
# shorter than the (square) window — center it either way.
|
|
||||||
x = (self.width() - pixmap.width()) // 2
|
|
||||||
y = (self.height() - pixmap.height()) // 2 + self._bob_offset
|
|
||||||
# The input mask tracks the resting position, not the emote/bob
|
|
||||||
# offset: rebuilding it every frame of a spin would be both expensive
|
|
||||||
# and visibly janky, and the offsets are only a few pixels.
|
|
||||||
self._apply_input_mask(pixmap, x, (self.height() - pixmap.height()) // 2)
|
|
||||||
|
|
||||||
dx, dy, angle, scale = self._emote_transform()
|
|
||||||
if (dx, dy, angle, scale) == (0.0, 0.0, 0.0, 1.0):
|
|
||||||
painter.drawPixmap(x, y, pixmap)
|
|
||||||
return
|
|
||||||
# Rotate/scale about the sprite's own center so a spin doesn't orbit
|
|
||||||
# the window's corner.
|
|
||||||
center_x = x + pixmap.width() / 2
|
|
||||||
center_y = y + pixmap.height() / 2
|
|
||||||
transform = QTransform()
|
|
||||||
transform.translate(center_x + dx, center_y + dy)
|
|
||||||
transform.rotate(angle)
|
|
||||||
transform.scale(scale, scale)
|
|
||||||
transform.translate(-pixmap.width() / 2, -pixmap.height() / 2)
|
|
||||||
painter.setTransform(transform)
|
|
||||||
painter.drawPixmap(0, 0, pixmap)
|
|
||||||
|
|
||||||
# ── drag / click-to-talk ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def mousePressEvent(self, event) -> None:
|
|
||||||
if event.button() == Qt.LeftButton:
|
|
||||||
global_pos = event.globalPosition().toPoint()
|
|
||||||
self._drag_offset = global_pos - self.frameGeometry().topLeft()
|
|
||||||
self._press_pos = global_pos
|
|
||||||
self._dragged = False
|
|
||||||
self._stop_walking() # grabbing it interrupts a stroll at once
|
|
||||||
|
|
||||||
def mouseMoveEvent(self, event) -> None:
|
|
||||||
if self._drag_offset is None:
|
|
||||||
return
|
|
||||||
global_pos = event.globalPosition().toPoint()
|
|
||||||
self.move(global_pos - self._drag_offset)
|
|
||||||
self._reposition_bubble()
|
|
||||||
if (global_pos - self._press_pos).manhattanLength() > _DRAG_THRESHOLD_PX:
|
|
||||||
self._dragged = True
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event) -> None:
|
|
||||||
if event.button() != Qt.LeftButton:
|
|
||||||
return
|
|
||||||
was_click = not self._dragged
|
|
||||||
self._drag_offset = None
|
|
||||||
self._press_pos = None
|
|
||||||
if was_click:
|
|
||||||
self.talk_requested.emit()
|
|
||||||
else:
|
|
||||||
self.snap_to_edge() # dropped near an edge -> tuck it flush
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Sprite loading + frame animation.
|
|
||||||
|
|
||||||
Convention: assets/sprites/<state>/*.png, frames played in filename-sorted
|
|
||||||
order (e.g. frame_00.png, frame_01.png, ...), looping. <state> matches
|
|
||||||
bolt_pet.state.PetState values: idle, listening, thinking, talking.
|
|
||||||
|
|
||||||
If a state's directory has no frames (real art not dropped in yet), falls
|
|
||||||
back to a small procedurally-drawn placeholder blob so the app still runs
|
|
||||||
end-to-end. Swap in real sprite sheets by pointing SPRITE_DIR at your own
|
|
||||||
folder (see assets/sprites/README.md) — no code changes needed as long as
|
|
||||||
the same per-state-subfolder-of-PNGs convention is followed. If your sheets
|
|
||||||
use a different layout (single grid image, etc.), tell me the format and
|
|
||||||
this loader can be adapted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import QSize, Qt
|
|
||||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
|
||||||
|
|
||||||
from ..state import PetState
|
|
||||||
|
|
||||||
DEFAULT_SPRITE_DIR = Path(__file__).resolve().parent.parent / "assets" / "sprites"
|
|
||||||
|
|
||||||
# Placeholder palette per state, used only when no frames are found.
|
|
||||||
_PLACEHOLDER_COLORS = {
|
|
||||||
PetState.IDLE: QColor(120, 170, 240),
|
|
||||||
PetState.LISTENING: QColor(120, 220, 160),
|
|
||||||
PetState.THINKING: QColor(230, 190, 90),
|
|
||||||
PetState.TALKING: QColor(240, 130, 150),
|
|
||||||
PetState.ERROR: QColor(220, 90, 90),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _placeholder_frames(state: PetState, size: int) -> list[QPixmap]:
|
|
||||||
"""A tiny 2-frame "breathing" blob so idle/listening/etc. are visually
|
|
||||||
distinguishable even before real art exists."""
|
|
||||||
color = _PLACEHOLDER_COLORS.get(state, QColor(150, 150, 150))
|
|
||||||
frames = []
|
|
||||||
for scale in (1.0, 0.92):
|
|
||||||
pixmap = QPixmap(size, size)
|
|
||||||
pixmap.fill(Qt.transparent)
|
|
||||||
painter = QPainter(pixmap)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
painter.setBrush(color)
|
|
||||||
painter.setPen(Qt.NoPen)
|
|
||||||
margin = size * (1 - scale) / 2
|
|
||||||
painter.drawEllipse(int(margin), int(margin), int(size * scale), int(size * scale))
|
|
||||||
# simple eyes so it reads as a face, not just a circle
|
|
||||||
eye_r = max(2, size // 16)
|
|
||||||
eye_y = int(size * 0.42)
|
|
||||||
painter.setBrush(QColor(30, 30, 40))
|
|
||||||
painter.drawEllipse(int(size * 0.36) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2)
|
|
||||||
painter.drawEllipse(int(size * 0.64) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2)
|
|
||||||
painter.end()
|
|
||||||
frames.append(pixmap)
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
class SpriteAnimation:
|
|
||||||
"""One state's frame sequence + current playback position."""
|
|
||||||
|
|
||||||
def __init__(self, frames: list[QPixmap]):
|
|
||||||
self.frames = frames or []
|
|
||||||
self._index = 0
|
|
||||||
|
|
||||||
def advance(self) -> None:
|
|
||||||
if self.frames:
|
|
||||||
self._index = (self._index + 1) % len(self.frames)
|
|
||||||
|
|
||||||
def current(self) -> Optional[QPixmap]:
|
|
||||||
if not self.frames:
|
|
||||||
return None
|
|
||||||
return self.frames[self._index]
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
self._index = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]:
|
|
||||||
if not directory.is_dir():
|
|
||||||
return []
|
|
||||||
paths = sorted(directory.glob("*.png")) + sorted(directory.glob("*.PNG"))
|
|
||||||
frames = []
|
|
||||||
for path in paths:
|
|
||||||
pixmap = QPixmap(str(path))
|
|
||||||
if pixmap.isNull():
|
|
||||||
continue
|
|
||||||
if pixmap.size() != QSize(size, size):
|
|
||||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
||||||
frames.append(pixmap)
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
class SpriteSet:
|
|
||||||
"""All animations for every PetState, loaded from *sprite_dir*."""
|
|
||||||
|
|
||||||
def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160):
|
|
||||||
self.size = size
|
|
||||||
self._animations: dict[PetState, SpriteAnimation] = {}
|
|
||||||
for state in PetState:
|
|
||||||
frames = _load_frames_from_dir(sprite_dir / state.value, size)
|
|
||||||
if not frames:
|
|
||||||
frames = _placeholder_frames(state, size)
|
|
||||||
self._animations[state] = SpriteAnimation(frames)
|
|
||||||
|
|
||||||
def get(self, state: PetState) -> SpriteAnimation:
|
|
||||||
return self._animations[state]
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""System tray icon — the pet window is frameless with no taskbar entry, so
|
|
||||||
this menu is the only always-available way to control or exit it: talk now,
|
|
||||||
mute, wander, click-through, nap, history, wake-word tuning, quit.
|
|
||||||
|
|
||||||
Every entry is a plain callback passed in by ui/app.py; this file knows
|
|
||||||
nothing about the controller or the pet window.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
|
|
||||||
from PySide6.QtWidgets import QMenu, QSystemTrayIcon
|
|
||||||
|
|
||||||
|
|
||||||
def _make_icon(muted: bool, napping: bool = False) -> QIcon:
|
|
||||||
pixmap = QPixmap(32, 32)
|
|
||||||
pixmap.fill(Qt.transparent)
|
|
||||||
painter = QPainter(pixmap)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
if muted:
|
|
||||||
color = QColor(200, 60, 60)
|
|
||||||
elif napping:
|
|
||||||
color = QColor(120, 120, 140)
|
|
||||||
else:
|
|
||||||
color = QColor(120, 170, 240)
|
|
||||||
painter.setBrush(color)
|
|
||||||
painter.setPen(Qt.NoPen)
|
|
||||||
painter.drawEllipse(2, 2, 28, 28)
|
|
||||||
painter.end()
|
|
||||||
return QIcon(pixmap)
|
|
||||||
|
|
||||||
|
|
||||||
class PetTray(QSystemTrayIcon):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
on_talk_now: Callable[[], None],
|
|
||||||
on_toggle_mute: Callable[[], bool],
|
|
||||||
on_quit: Callable[[], None],
|
|
||||||
on_set_wander: Optional[Callable[[bool], None]] = None,
|
|
||||||
wander_enabled: bool = True,
|
|
||||||
on_set_click_through: Optional[Callable[[bool], None]] = None,
|
|
||||||
click_through_enabled: bool = False,
|
|
||||||
on_set_nap: Optional[Callable[[bool], None]] = None,
|
|
||||||
on_show_history: Optional[Callable[[], None]] = None,
|
|
||||||
on_show_wake_tuner: Optional[Callable[[], None]] = None,
|
|
||||||
parent=None,
|
|
||||||
):
|
|
||||||
super().__init__(_make_icon(muted=False), parent)
|
|
||||||
self._on_toggle_mute = on_toggle_mute
|
|
||||||
self._muted = False
|
|
||||||
self._napping = False
|
|
||||||
self.setToolTip("Bolt")
|
|
||||||
|
|
||||||
menu = QMenu()
|
|
||||||
self._talk_action = QAction("Talk now", menu)
|
|
||||||
self._talk_action.triggered.connect(on_talk_now)
|
|
||||||
menu.addAction(self._talk_action)
|
|
||||||
|
|
||||||
self._mute_action = QAction("Mute mic", menu)
|
|
||||||
self._mute_action.setCheckable(True)
|
|
||||||
self._mute_action.triggered.connect(self._handle_toggle_mute)
|
|
||||||
menu.addAction(self._mute_action)
|
|
||||||
|
|
||||||
self._nap_action = None
|
|
||||||
if on_set_nap is not None:
|
|
||||||
self._nap_action = QAction("Nap (no proactive noise)", menu)
|
|
||||||
self._nap_action.setCheckable(True)
|
|
||||||
self._nap_action.triggered.connect(lambda checked: on_set_nap(checked))
|
|
||||||
menu.addAction(self._nap_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
|
||||||
|
|
||||||
if on_set_wander is not None:
|
|
||||||
self._wander_action = QAction("Wander around", menu)
|
|
||||||
self._wander_action.setCheckable(True)
|
|
||||||
self._wander_action.setChecked(wander_enabled)
|
|
||||||
self._wander_action.triggered.connect(lambda checked: on_set_wander(checked))
|
|
||||||
menu.addAction(self._wander_action)
|
|
||||||
|
|
||||||
if on_set_click_through is not None:
|
|
||||||
self._click_through_action = QAction("Click through the pet", menu)
|
|
||||||
self._click_through_action.setCheckable(True)
|
|
||||||
self._click_through_action.setChecked(click_through_enabled)
|
|
||||||
self._click_through_action.setToolTip(
|
|
||||||
"Ignore the mouse entirely — control it from this menu instead."
|
|
||||||
)
|
|
||||||
self._click_through_action.triggered.connect(lambda checked: on_set_click_through(checked))
|
|
||||||
menu.addAction(self._click_through_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
|
||||||
|
|
||||||
if on_show_history is not None:
|
|
||||||
history_action = QAction("History…", menu)
|
|
||||||
history_action.triggered.connect(on_show_history)
|
|
||||||
menu.addAction(history_action)
|
|
||||||
|
|
||||||
if on_show_wake_tuner is not None:
|
|
||||||
tuner_action = QAction("Wake word tuning…", menu)
|
|
||||||
tuner_action.triggered.connect(on_show_wake_tuner)
|
|
||||||
menu.addAction(tuner_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
|
||||||
quit_action = QAction("Quit", menu)
|
|
||||||
quit_action.triggered.connect(on_quit)
|
|
||||||
menu.addAction(quit_action)
|
|
||||||
|
|
||||||
self.setContextMenu(menu)
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
def _handle_toggle_mute(self) -> None:
|
|
||||||
self._muted = self._on_toggle_mute()
|
|
||||||
self._mute_action.setChecked(self._muted)
|
|
||||||
self._refresh_icon()
|
|
||||||
|
|
||||||
def set_napping(self, napping: bool) -> None:
|
|
||||||
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen,
|
|
||||||
or a petctl command) — not just ones clicked here."""
|
|
||||||
self._napping = napping
|
|
||||||
if self._nap_action is not None:
|
|
||||||
self._nap_action.setChecked(napping)
|
|
||||||
self._refresh_icon()
|
|
||||||
|
|
||||||
def _refresh_icon(self) -> None:
|
|
||||||
self.setIcon(_make_icon(self._muted, self._napping))
|
|
||||||
if self._muted:
|
|
||||||
self.setToolTip("Bolt (muted)")
|
|
||||||
elif self._napping:
|
|
||||||
self.setToolTip("Bolt (napping)")
|
|
||||||
else:
|
|
||||||
self.setToolTip("Bolt")
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Wake-word sensitivity tuner.
|
|
||||||
|
|
||||||
WAKE_WORD_THRESHOLD is otherwise a number you guess at in .env, restart, and
|
|
||||||
then test by saying "thunderbolt" at your computer repeatedly. This window
|
|
||||||
makes it evidence-based: a live peak-score readout while you talk, a rolling
|
|
||||||
list of near misses (frames that scored just under the threshold — i.e. the
|
|
||||||
times it *nearly* heard you), and a slider that takes effect immediately,
|
|
||||||
mid-listen, without a restart.
|
|
||||||
|
|
||||||
The controller owns the threshold; this window is a view over it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QSlider, QVBoxLayout,
|
|
||||||
)
|
|
||||||
|
|
||||||
_SLIDER_SCALE = 100 # QSlider is integer-only; threshold is 0.00-1.00
|
|
||||||
|
|
||||||
|
|
||||||
class WakeTunerWindow(QDialog):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
get_threshold: Callable[[], float],
|
|
||||||
set_threshold: Callable[[float], None],
|
|
||||||
get_stats: Callable[[], dict],
|
|
||||||
on_reset: Callable[[], None],
|
|
||||||
):
|
|
||||||
super().__init__()
|
|
||||||
self._get_threshold = get_threshold
|
|
||||||
self._set_threshold = set_threshold
|
|
||||||
self._get_stats = get_stats
|
|
||||||
self._on_reset = on_reset
|
|
||||||
|
|
||||||
self.setWindowTitle("Bolt — wake word tuning")
|
|
||||||
self.resize(460, 380)
|
|
||||||
|
|
||||||
self._threshold_label = QLabel()
|
|
||||||
self._slider = QSlider(Qt.Horizontal)
|
|
||||||
self._slider.setRange(5, 99)
|
|
||||||
self._slider.setValue(int(round(get_threshold() * _SLIDER_SCALE)))
|
|
||||||
self._slider.valueChanged.connect(self._threshold_changed)
|
|
||||||
|
|
||||||
self._peak_label = QLabel("Peak score since reset: —")
|
|
||||||
self._peak_label.setToolTip(
|
|
||||||
'Say "thunderbolt" a few times and watch this. Set the threshold '
|
|
||||||
"just below the peak you can hit reliably."
|
|
||||||
)
|
|
||||||
|
|
||||||
self._misses = QListWidget()
|
|
||||||
|
|
||||||
reset_button = QPushButton("Reset stats")
|
|
||||||
reset_button.clicked.connect(self._reset)
|
|
||||||
close_button = QPushButton("Close")
|
|
||||||
close_button.clicked.connect(self.close)
|
|
||||||
close_button.setDefault(True)
|
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
|
||||||
buttons.addWidget(reset_button)
|
|
||||||
buttons.addStretch(1)
|
|
||||||
buttons.addWidget(close_button)
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.addWidget(self._threshold_label)
|
|
||||||
layout.addWidget(self._slider)
|
|
||||||
layout.addWidget(self._peak_label)
|
|
||||||
layout.addWidget(QLabel("Near misses (heard something, didn't quite fire):"))
|
|
||||||
layout.addWidget(self._misses)
|
|
||||||
layout.addLayout(buttons)
|
|
||||||
|
|
||||||
# Polled rather than signal-driven: scores arrive ~12x/second on the
|
|
||||||
# audio thread, and a queued signal per frame to repaint a label is
|
|
||||||
# more traffic than this is worth.
|
|
||||||
self._timer = QTimer(self)
|
|
||||||
self._timer.timeout.connect(self.refresh)
|
|
||||||
self._update_threshold_label()
|
|
||||||
|
|
||||||
def _threshold_changed(self, value: int) -> None:
|
|
||||||
self._set_threshold(value / _SLIDER_SCALE)
|
|
||||||
self._update_threshold_label()
|
|
||||||
|
|
||||||
def _update_threshold_label(self) -> None:
|
|
||||||
threshold = self._slider.value() / _SLIDER_SCALE
|
|
||||||
self._threshold_label.setText(
|
|
||||||
f"Threshold: {threshold:.2f} (lower = more sensitive, more false triggers)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _reset(self) -> None:
|
|
||||||
self._on_reset()
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
|
||||||
stats = self._get_stats() or {}
|
|
||||||
peak = stats.get("peak", 0.0)
|
|
||||||
self._peak_label.setText(f"Peak score since reset: {peak:.3f}")
|
|
||||||
self._misses.clear()
|
|
||||||
for timestamp, score, threshold in reversed(stats.get("near_misses", [])):
|
|
||||||
when = time.strftime("%H:%M:%S", time.localtime(timestamp))
|
|
||||||
self._misses.addItem(f"{when} scored {score:.3f} (threshold {threshold:.2f})")
|
|
||||||
if self._misses.count() == 0:
|
|
||||||
self._misses.addItem("Nothing yet — say the wake phrase a few times.")
|
|
||||||
|
|
||||||
def show_refreshed(self) -> None:
|
|
||||||
self._slider.setValue(int(round(self._get_threshold() * _SLIDER_SCALE)))
|
|
||||||
self.refresh()
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
self.activateWindow()
|
|
||||||
self._timer.start(500)
|
|
||||||
|
|
||||||
def closeEvent(self, event) -> None:
|
|
||||||
self._timer.stop()
|
|
||||||
super().closeEvent(event)
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
"""Self-update from the Gitea releases page.
|
|
||||||
|
|
||||||
Polls `<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
|
||||||
``bolt_pet.__version__`` and, if there is one, moves the checkout to that tag
|
|
||||||
and restarts the pet. The install is expected to be a git clone (which is how
|
|
||||||
it's deployed), so "download the update" is just `git fetch` + `git checkout`
|
|
||||||
— atomic, and the previous ref is one command away if anything goes wrong.
|
|
||||||
|
|
||||||
Safety rules, in the order they're enforced:
|
|
||||||
|
|
||||||
1. **A dirty working tree is never touched.** Local edits are skipped over,
|
|
||||||
not stashed — the pet silently discarding your work-in-progress would be
|
|
||||||
far worse than running an old version.
|
|
||||||
2. **Everything after checkout is guarded.** Dependency install and an import
|
|
||||||
smoke test both run before the restart; if either fails, the checkout is
|
|
||||||
rolled back to the exact ref that was live before (branch name if we were
|
|
||||||
on one, otherwise the commit) and the deps reinstalled from it.
|
|
||||||
3. **The restart only happens once the new code imports.** So a broken
|
|
||||||
release costs you a rollback and a log line, not a pet that won't start.
|
|
||||||
|
|
||||||
The git side goes through an injectable *run* callable — ``(args) ->
|
|
||||||
(returncode, output)`` — so the whole apply/rollback dance is unit-tested
|
|
||||||
against a fake git rather than a real repo. Version comparison and release
|
|
||||||
parsing are pure functions for the same reason.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Optional, Tuple
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
# (returncode, combined stdout+stderr)
|
|
||||||
GitResult = Tuple[int, str]
|
|
||||||
GitRunner = Callable[[list], GitResult]
|
|
||||||
|
|
||||||
_GIT_TIMEOUT_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateError(Exception):
|
|
||||||
"""Raised when an update can't be applied. If it's raised *after* the
|
|
||||||
checkout moved, the rollback has already run."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Release:
|
|
||||||
tag: str
|
|
||||||
name: str
|
|
||||||
body: str
|
|
||||||
prerelease: bool
|
|
||||||
|
|
||||||
|
|
||||||
# ── pure logic ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def parse_version(tag: str) -> tuple:
|
|
||||||
"""``"v1.2.3"`` -> ``(1, 2, 3)``. Leading "v" optional; a trailing
|
|
||||||
suffix ends the parse (``"1.2.3-beta1"`` -> ``(1, 2, 3)``), so a
|
|
||||||
prerelease of a version compares equal to it rather than sorting
|
|
||||||
randomly. Junk parses to ``()``, which is never newer than anything."""
|
|
||||||
parts: list[int] = []
|
|
||||||
for chunk in (tag or "").strip().lstrip("vV").split("."):
|
|
||||||
digits = ""
|
|
||||||
for char in chunk:
|
|
||||||
if not char.isdigit():
|
|
||||||
break
|
|
||||||
digits += char
|
|
||||||
if not digits:
|
|
||||||
break
|
|
||||||
parts.append(int(digits))
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def is_newer(candidate: str, current: str) -> bool:
|
|
||||||
"""True if *candidate* is a strictly newer version than *current*.
|
|
||||||
Compares zero-padded, so 1.2 == 1.2.0 and 1.2.1 > 1.2."""
|
|
||||||
new, old = parse_version(candidate), parse_version(current)
|
|
||||||
if not new:
|
|
||||||
return False
|
|
||||||
width = max(len(new), len(old))
|
|
||||||
return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old))
|
|
||||||
|
|
||||||
|
|
||||||
def release_from_payload(payload: dict) -> Optional[Release]:
|
|
||||||
"""Gitea's release JSON -> Release, or None if it's a draft or has no
|
|
||||||
tag. /releases/latest already excludes drafts and prereleases, but the
|
|
||||||
same parser is used for the full list."""
|
|
||||||
if not isinstance(payload, dict) or payload.get("draft"):
|
|
||||||
return None
|
|
||||||
tag = str(payload.get("tag_name") or "").strip()
|
|
||||||
if not tag:
|
|
||||||
return None
|
|
||||||
return Release(
|
|
||||||
tag=tag,
|
|
||||||
name=str(payload.get("name") or tag),
|
|
||||||
body=str(payload.get("body") or ""),
|
|
||||||
prerelease=bool(payload.get("prerelease")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── talking to Gitea ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_latest_release(api_url: str = None, token: str = None, timeout: float = 15.0) -> Optional[Release]:
|
|
||||||
"""Newest published release, or None if the repo has no releases yet
|
|
||||||
(a fresh repo 404s here, which is not an error worth logging every hour).
|
|
||||||
Raises UpdateError if the server is unreachable or answers with junk."""
|
|
||||||
api_url = (api_url if api_url is not None else config.UPDATE_REPO_API).rstrip("/")
|
|
||||||
if not api_url:
|
|
||||||
raise UpdateError("UPDATE_REPO_API is not set")
|
|
||||||
token = config.UPDATE_TOKEN if token is None else token
|
|
||||||
headers = {"Authorization": f"token {token}"} if token else {}
|
|
||||||
try:
|
|
||||||
response = requests.get(f"{api_url}/releases/latest", headers=headers, timeout=timeout)
|
|
||||||
except Exception as exc:
|
|
||||||
raise UpdateError(f"couldn't reach the releases API: {exc}") from exc
|
|
||||||
if response.status_code == 404:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise UpdateError(f"bad response from the releases API: {exc}") from exc
|
|
||||||
return release_from_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_update(current_version: str = None, **kwargs) -> Optional[Release]:
|
|
||||||
"""The whole "is there anything new?" question in one call. Returns the
|
|
||||||
Release to move to, or None if we're already current."""
|
|
||||||
from . import __version__
|
|
||||||
|
|
||||||
current = __version__ if current_version is None else current_version
|
|
||||||
release = fetch_latest_release(**kwargs)
|
|
||||||
if release is None or not is_newer(release.tag, current):
|
|
||||||
return None
|
|
||||||
return release
|
|
||||||
|
|
||||||
|
|
||||||
# ── git ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def git_runner(repo: Path = None) -> GitRunner:
|
|
||||||
repo = Path(repo or config.HERE)
|
|
||||||
|
|
||||||
def run(args: list) -> GitResult:
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
["git", *args], cwd=str(repo), capture_output=True,
|
|
||||||
text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return 1, f"git {' '.join(args)} failed to start: {exc}"
|
|
||||||
return completed.returncode, ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
||||||
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def is_git_clone(run: GitRunner) -> bool:
|
|
||||||
return run(["rev-parse", "--git-dir"])[0] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def working_tree_dirty(run: GitRunner) -> bool:
|
|
||||||
code, output = run(["status", "--porcelain"])
|
|
||||||
return code != 0 or bool(output.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def current_ref(run: GitRunner) -> str:
|
|
||||||
"""The branch name if we're on one, else the commit SHA — i.e. whatever
|
|
||||||
`git checkout` needs to put things back exactly as they were."""
|
|
||||||
code, output = run(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
|
||||||
if code == 0 and output.strip():
|
|
||||||
return output.strip()
|
|
||||||
code, output = run(["rev-parse", "HEAD"])
|
|
||||||
if code != 0 or not output.strip():
|
|
||||||
raise UpdateError("couldn't work out the current git ref")
|
|
||||||
return output.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def already_at_tag(run: GitRunner, tag: str) -> bool:
|
|
||||||
"""True if HEAD is already the commit *tag* points at.
|
|
||||||
|
|
||||||
Guards the loop you get when a release is cut without bumping
|
|
||||||
__version__ in the tagged commit: the checkout succeeds (it's a no-op),
|
|
||||||
the pet restarts, reads the same old __version__, sees the same "newer"
|
|
||||||
tag, and does it again — a restart every check, forever."""
|
|
||||||
code, head = run(["rev-parse", "HEAD"])
|
|
||||||
if code != 0 or not head.strip():
|
|
||||||
return False
|
|
||||||
code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"])
|
|
||||||
if code != 0 or not target.strip():
|
|
||||||
return False # tag isn't known locally yet, so we're certainly not on it
|
|
||||||
return head.strip() == target.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
|
|
||||||
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
|
|
||||||
return code == 0 and bool(output.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _install_deps(repo: Path) -> None:
|
|
||||||
completed = subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
|
||||||
cwd=str(repo), capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise UpdateError(f"pip install failed: {(completed.stderr or '')[-500:]}")
|
|
||||||
|
|
||||||
|
|
||||||
def _smoke_test(repo: Path) -> None:
|
|
||||||
"""Import the freshly checked-out package in a *subprocess* — this one
|
|
||||||
still has the old modules loaded, so importing here would prove nothing.
|
|
||||||
Catches the common broken release (syntax error, missing dependency)
|
|
||||||
before we hand the session over to it."""
|
|
||||||
completed = subprocess.run(
|
|
||||||
[sys.executable, "-c", "import bolt_pet; import bolt_pet.controller"],
|
|
||||||
cwd=str(repo), capture_output=True, text=True, timeout=120,
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise UpdateError(f"the new version failed to import: {(completed.stderr or '')[-500:]}")
|
|
||||||
|
|
||||||
|
|
||||||
def apply_update(
|
|
||||||
tag: str,
|
|
||||||
run: GitRunner = None,
|
|
||||||
repo: Path = None,
|
|
||||||
on_log: Callable[[str], None] = lambda _msg: None,
|
|
||||||
install_deps: bool = None,
|
|
||||||
verify: Callable[[Path], None] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Move the checkout to *tag*, rolling back to where it was if anything
|
|
||||||
downstream of the checkout fails. Returns the ref we came from (handy for
|
|
||||||
logging / a manual `git checkout` back). Raises UpdateError otherwise."""
|
|
||||||
repo = Path(repo or config.HERE)
|
|
||||||
run = run or git_runner(repo)
|
|
||||||
install_deps = config.UPDATE_INSTALL_DEPS if install_deps is None else install_deps
|
|
||||||
verify = _smoke_test if verify is None else verify
|
|
||||||
|
|
||||||
if not is_git_clone(run):
|
|
||||||
raise UpdateError("not a git clone — auto-update only works on a git checkout")
|
|
||||||
if working_tree_dirty(run):
|
|
||||||
raise UpdateError("working tree has local changes — skipping (nothing was touched)")
|
|
||||||
|
|
||||||
previous = current_ref(run)
|
|
||||||
code, output = run(["fetch", "--tags", "--prune", config.UPDATE_GIT_REMOTE])
|
|
||||||
if code != 0:
|
|
||||||
raise UpdateError(f"git fetch failed: {output}")
|
|
||||||
|
|
||||||
if already_at_tag(run, tag):
|
|
||||||
from . import __version__
|
|
||||||
|
|
||||||
raise UpdateError(
|
|
||||||
f"already checked out {tag}, but __version__ still reads {__version__} — "
|
|
||||||
f"bump it in the tagged commit, or every check re-applies the same release"
|
|
||||||
)
|
|
||||||
|
|
||||||
code, output = run(["checkout", "--force", f"tags/{tag}"])
|
|
||||||
if code != 0:
|
|
||||||
raise UpdateError(f"git checkout {tag} failed: {output}")
|
|
||||||
on_log(f"Checked out {tag} (was {previous}).")
|
|
||||||
|
|
||||||
# Past this point every failure has to put the checkout back.
|
|
||||||
try:
|
|
||||||
if install_deps and _requirements_changed(run, previous, f"tags/{tag}"):
|
|
||||||
on_log("requirements.txt changed — installing.")
|
|
||||||
_install_deps(repo)
|
|
||||||
verify(repo)
|
|
||||||
except UpdateError as exc:
|
|
||||||
_rollback(run, previous, repo, on_log, install_deps)
|
|
||||||
raise UpdateError(f"{exc} — rolled back to {previous}") from exc
|
|
||||||
except Exception as exc: # a verify() that blows up is still a failed update
|
|
||||||
_rollback(run, previous, repo, on_log, install_deps)
|
|
||||||
raise UpdateError(f"update failed ({exc}) — rolled back to {previous}") from exc
|
|
||||||
|
|
||||||
return previous
|
|
||||||
|
|
||||||
|
|
||||||
def _rollback(
|
|
||||||
run: GitRunner,
|
|
||||||
previous: str,
|
|
||||||
repo: Path,
|
|
||||||
on_log: Callable[[str], None],
|
|
||||||
install_deps: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Best-effort return to *previous*. Never raises — it's already running
|
|
||||||
inside a failure path, and the caller's UpdateError is the thing worth
|
|
||||||
surfacing. A rollback that itself fails gets its own loud log line,
|
|
||||||
because that's the one case needing a human."""
|
|
||||||
code, output = run(["checkout", "--force", previous])
|
|
||||||
if code != 0:
|
|
||||||
on_log(f"ROLLBACK FAILED — the checkout is stranded. Run: git checkout {previous} ({output})")
|
|
||||||
return
|
|
||||||
on_log(f"Rolled back to {previous}.")
|
|
||||||
if install_deps:
|
|
||||||
try:
|
|
||||||
_install_deps(repo)
|
|
||||||
except Exception as exc:
|
|
||||||
on_log(f"Rolled back, but reinstalling the old requirements failed: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
# ── restart ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def restart() -> None:
|
|
||||||
"""Replace this process with a fresh `python -m bolt_pet`.
|
|
||||||
|
|
||||||
execv rather than spawn-and-exit so there's no window with two pets
|
|
||||||
holding the same mic, and no orphan if the parent dies first. Never
|
|
||||||
returns when it works; callers should have shut the Qt app and released
|
|
||||||
the audio device before calling it.
|
|
||||||
|
|
||||||
chdir first because `-m bolt_pet` resolves the package from the working
|
|
||||||
directory: the pet may well have been launched from somewhere else
|
|
||||||
(autostart entry, run.sh invoked by path), and the new process has to
|
|
||||||
land on the checkout the update was just applied to."""
|
|
||||||
os.chdir(str(config.HERE))
|
|
||||||
os.execv(sys.executable, [sys.executable, "-m", "bolt_pet"])
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Frozen-app launcher.
|
||||||
|
|
||||||
|
PyInstaller compiles this as a top-level script, so it must NOT use relative
|
||||||
|
imports. It imports the package by name and hands off to bolt_console.__main__.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bolt_console.__main__ as entry
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(entry.main())
|
||||||
@@ -1,37 +1,13 @@
|
|||||||
# Desktop UI
|
# HTTP client to the Bolt desk API.
|
||||||
PySide6>=6.6
|
|
||||||
|
|
||||||
# Audio I/O (mic capture + speaker playback) — cross-platform via PortAudio.
|
|
||||||
# On Linux you may also need the system package: sudo apt install libportaudio2
|
|
||||||
sounddevice>=0.4.6
|
|
||||||
numpy>=1.24
|
|
||||||
|
|
||||||
# HTTP client to the Bolt desk API
|
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
|
||||||
# Wake-word detection (local, offline after first run) — runs the
|
# The window (python -m bolt_console). Not needed for --cli.
|
||||||
# custom-trained thunderbolt.onnx model shipped in this repo, same runtime
|
PySide6>=6.6
|
||||||
# as the main repo's desk_client/bolt_desk.py (bolt.onnx). First use
|
|
||||||
# downloads openwakeword's feature-extraction sub-models (~few MB, cached
|
|
||||||
# under the package's own resources/ dir afterward) — needs internet once.
|
|
||||||
openwakeword
|
|
||||||
|
|
||||||
# Offline TTS fallback if ElevenLabs isn't configured or a request fails.
|
# Dictation (the mic button): mic capture + the server's /desk/stt websocket.
|
||||||
# Uses SAPI5 on Windows, NSSpeechSynthesizer on macOS, espeak on Linux
|
# Optional: without them the mic button explains what's missing.
|
||||||
# (Linux also needs: sudo apt install espeak-ng).
|
sounddevice>=0.4.6
|
||||||
pyttsx3>=2.90
|
websocket-client>=1.7
|
||||||
|
|
||||||
# Global push-to-talk hotkey (PUSH_TO_TALK_HOTKEY). Optional: the pet
|
# Test runner (tests/ — no real server or terminal needed).
|
||||||
# degrades to wake-word + tray + click if it's missing or if the session
|
|
||||||
# won't allow a global key hook (most Wayland setups; macOS needs
|
|
||||||
# Accessibility permission).
|
|
||||||
pynput>=1.7
|
|
||||||
|
|
||||||
# Optional — only needed for scripts/slice_spritesheet.py (converting a
|
|
||||||
# grid sprite sheet into the per-frame-PNG convention sprite.py expects).
|
|
||||||
# Not imported by the app itself.
|
|
||||||
Pillow>=10.0
|
|
||||||
|
|
||||||
# Test runner (tests/ — pure logic, no audio hardware or display needed;
|
|
||||||
# run with QT_QPA_PLATFORM=offscreen).
|
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
REM The terminal version of Bolt Console. Starts in the directory you ran it
|
||||||
|
REM from, so that is the working directory Bolt's commands and paths use.
|
||||||
|
setlocal
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
|
||||||
|
if not exist "%ROOT%.venv" (
|
||||||
|
python -m venv "%ROOT%.venv"
|
||||||
|
"%ROOT%.venv\Scripts\python" -m pip install --upgrade pip
|
||||||
|
"%ROOT%.venv\Scripts\python" -m pip install -r "%ROOT%requirements.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PYTHONPATH=%ROOT%;%PYTHONPATH%"
|
||||||
|
"%ROOT%.venv\Scripts\python" -m bolt_console --cli %*
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Convenience launcher for Windows.
|
REM Opens the Bolt window (no console left behind). For the terminal version
|
||||||
cd /d "%~dp0"
|
REM use run-cli.bat. Bolt starts in the directory you ran this from; change it
|
||||||
|
REM from the folder button in the window.
|
||||||
|
setlocal
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
|
||||||
if not exist .venv (
|
if not exist "%ROOT%.venv" (
|
||||||
python -m venv .venv
|
echo First run: creating .venv and installing requirements...
|
||||||
.venv\Scripts\pip install --upgrade pip
|
python -m venv "%ROOT%.venv"
|
||||||
.venv\Scripts\pip install -r requirements.txt
|
"%ROOT%.venv\Scripts\python" -m pip install --upgrade pip
|
||||||
|
"%ROOT%.venv\Scripts\python" -m pip install -r "%ROOT%requirements.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
.venv\Scripts\python -m bolt_pet
|
set "PYTHONPATH=%ROOT%;%PYTHONPATH%"
|
||||||
|
start "" "%ROOT%.venv\Scripts\pythonw.exe" -m bolt_console %*
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Convenience launcher for macOS/Linux.
|
# Launcher for macOS/Linux: the window by default, ./run.sh --cli for the
|
||||||
|
# terminal version. Starts in the directory you ran it from, so that is
|
||||||
|
# the working directory Bolt's commands and relative paths use.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
if [ ! -d .venv ]; then
|
if [ ! -d "$ROOT/.venv" ]; then
|
||||||
python3 -m venv .venv
|
python3 -m venv "$ROOT/.venv"
|
||||||
./.venv/bin/pip install --upgrade pip
|
"$ROOT/.venv/bin/pip" install --upgrade pip
|
||||||
./.venv/bin/pip install -r requirements.txt
|
"$ROOT/.venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec ./.venv/bin/python -m bolt_pet
|
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
exec "$ROOT/.venv/bin/python" -m bolt_console "$@"
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Cut a grid-based sprite sheet (rows x cols of equal-size frames in one
|
|
||||||
PNG) into the assets/sprites/<state>/frame_NN.png convention this project's
|
|
||||||
sprite loader expects.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/slice_spritesheet.py idle_sheet.png assets/sprites/idle --cols 6 --rows 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
|
|
||||||
def slice_sheet(sheet_path: Path, out_dir: Path, cols: int, rows: int) -> int:
|
|
||||||
sheet = Image.open(sheet_path).convert("RGBA")
|
|
||||||
frame_w = sheet.width // cols
|
|
||||||
frame_h = sheet.height // rows
|
|
||||||
if frame_w == 0 or frame_h == 0:
|
|
||||||
raise ValueError(f"sheet is {sheet.width}x{sheet.height}, too small for {cols}x{rows} frames")
|
|
||||||
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
count = 0
|
|
||||||
for row in range(rows):
|
|
||||||
for col in range(cols):
|
|
||||||
box = (col * frame_w, row * frame_h, (col + 1) * frame_w, (row + 1) * frame_h)
|
|
||||||
frame = sheet.crop(box)
|
|
||||||
frame.save(out_dir / f"frame_{count:02d}.png")
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("sheet", type=Path, help="path to the grid sprite sheet PNG")
|
|
||||||
parser.add_argument("out_dir", type=Path, help="e.g. assets/sprites/idle")
|
|
||||||
parser.add_argument("--cols", type=int, required=True)
|
|
||||||
parser.add_argument("--rows", type=int, default=1)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
count = slice_sheet(args.sheet, args.out_dir, args.cols, args.rows)
|
|
||||||
print(f"Wrote {count} frames to {args.out_dir}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
"""Barge-in detection, driven by a fake mic stream and a fake wake model
|
|
||||||
(no audio hardware, no ONNX runtime)."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector
|
|
||||||
|
|
||||||
|
|
||||||
class FakeStream:
|
|
||||||
"""Yields frames of a given amplitude, mimicking sounddevice's
|
|
||||||
(data, overflowed) 2-D int16 return shape."""
|
|
||||||
|
|
||||||
def __init__(self, amplitudes):
|
|
||||||
self._amplitudes = list(amplitudes)
|
|
||||||
|
|
||||||
def read(self, frames):
|
|
||||||
amplitude = self._amplitudes.pop(0) if self._amplitudes else 0
|
|
||||||
data = np.full((frames, 1), amplitude, dtype=np.int16)
|
|
||||||
return data, False
|
|
||||||
|
|
||||||
|
|
||||||
def test_silence_never_interrupts():
|
|
||||||
detector = BargeInDetector(FakeStream([0] * 20), threshold=1000, required_frames=3)
|
|
||||||
assert not any(detector.check() for _ in range(20))
|
|
||||||
|
|
||||||
|
|
||||||
def test_sustained_speech_interrupts_after_the_required_frames():
|
|
||||||
detector = BargeInDetector(FakeStream([2000] * 5), threshold=1000, required_frames=3)
|
|
||||||
assert detector.check() is False
|
|
||||||
assert detector.check() is False
|
|
||||||
assert detector.check() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_thump_does_not_interrupt():
|
|
||||||
# loud, quiet, loud, quiet ... never three in a row
|
|
||||||
detector = BargeInDetector(FakeStream([2000, 0, 2000, 0, 2000, 0]), threshold=1000, required_frames=3)
|
|
||||||
assert not any(detector.check() for _ in range(6))
|
|
||||||
|
|
||||||
|
|
||||||
def test_counter_resets_after_a_quiet_frame():
|
|
||||||
detector = BargeInDetector(FakeStream([2000, 2000, 0, 2000, 2000, 2000]), threshold=1000, required_frames=3)
|
|
||||||
results = [detector.check() for _ in range(6)]
|
|
||||||
assert results == [False, False, False, False, False, True]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_clears_progress():
|
|
||||||
detector = BargeInDetector(FakeStream([2000] * 6), threshold=1000, required_frames=3)
|
|
||||||
detector.check()
|
|
||||||
detector.check()
|
|
||||||
detector.reset()
|
|
||||||
assert detector.check() is False
|
|
||||||
assert detector.loud_frames == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_mic_error_mid_playback_is_not_fatal():
|
|
||||||
class BrokenStream:
|
|
||||||
def read(self, frames):
|
|
||||||
raise OSError("device disappeared")
|
|
||||||
|
|
||||||
detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1)
|
|
||||||
assert detector.check() is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── wake-word mode ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class FakeModel:
|
|
||||||
"""Scores frames from a canned list, mimicking openWakeWord's
|
|
||||||
{class_name: score} return. Records reset() calls."""
|
|
||||||
|
|
||||||
def __init__(self, scores):
|
|
||||||
self._scores = list(scores)
|
|
||||||
self.resets = 0
|
|
||||||
|
|
||||||
def predict(self, frame):
|
|
||||||
score = self._scores.pop(0) if self._scores else 0.0
|
|
||||||
return {"thunderbolt": score}
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.resets += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _wake_detector(scores, threshold=0.5, amplitudes=None):
|
|
||||||
stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores))
|
|
||||||
return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream
|
|
||||||
|
|
||||||
|
|
||||||
def test_loud_noise_alone_does_not_interrupt_in_wake_mode():
|
|
||||||
"""The whole point of wake mode: a slammed door is deafening and scores
|
|
||||||
nothing, so the pet keeps talking."""
|
|
||||||
detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6)
|
|
||||||
assert not any(detector.check() for _ in range(6))
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_wake_word_interrupts():
|
|
||||||
detector, _ = _wake_detector([0.1, 0.2, 0.9])
|
|
||||||
assert [detector.check() for _ in range(3)] == [False, False, True]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_frame_is_enough_when_it_clears_the_threshold():
|
|
||||||
detector, _ = _wake_detector([0.55])
|
|
||||||
assert detector.check() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_scores_just_under_the_threshold_do_not_fire():
|
|
||||||
detector, _ = _wake_detector([0.49, 0.499], threshold=0.5)
|
|
||||||
assert not any(detector.check() for _ in range(2))
|
|
||||||
|
|
||||||
|
|
||||||
def test_detecting_resets_the_model_so_the_tail_is_not_reused():
|
|
||||||
model = FakeModel([0.9])
|
|
||||||
detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5)
|
|
||||||
assert detector.check() is True
|
|
||||||
assert model.resets == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_clears_the_models_audio_window_not_just_predictions():
|
|
||||||
"""The regression that made the pet interrupt itself a word into every
|
|
||||||
reply: openwakeword's reset() clears only the prediction buffer, so the
|
|
||||||
"thunderbolt" that started the turn was still in the preprocessor's
|
|
||||||
rolling window when playback began, and the first frame fed to the model
|
|
||||||
re-fired on it."""
|
|
||||||
|
|
||||||
class FakePreprocessor:
|
|
||||||
def __init__(self):
|
|
||||||
self.raw_data_buffer = [1, 2, 3]
|
|
||||||
self.feature_buffer = np.ones((120, 96))
|
|
||||||
self.melspectrogram_buffer = np.zeros((76, 32))
|
|
||||||
self.accumulated_samples = 4096
|
|
||||||
|
|
||||||
def _get_embeddings(self, audio):
|
|
||||||
return np.zeros((120, 96))
|
|
||||||
|
|
||||||
model = FakeModel([0.9])
|
|
||||||
model.preprocessor = FakePreprocessor()
|
|
||||||
|
|
||||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
|
||||||
|
|
||||||
assert model.preprocessor.raw_data_buffer == []
|
|
||||||
assert model.preprocessor.accumulated_samples == 0
|
|
||||||
assert not model.preprocessor.feature_buffer.any() # blank, not the old audio
|
|
||||||
assert model.preprocessor.melspectrogram_buffer.all() # restored to ones
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_lazy_wrapper_exposes_its_preprocessor():
|
|
||||||
"""The pet holds _default_model — a lazy *wrapper* around openwakeword's
|
|
||||||
Model. If the wrapper stops proxying .preprocessor, hard_reset() finds
|
|
||||||
nothing to clear and silently degrades to the shallow reset that leaves
|
|
||||||
the last detection in the audio window. That failure is invisible: no
|
|
||||||
exception, no log, the pet just interrupts itself again."""
|
|
||||||
from bolt_pet.audio.wake_word import _OpenWakeWordModel
|
|
||||||
|
|
||||||
wrapper = _OpenWakeWordModel()
|
|
||||||
assert hasattr(wrapper, "preprocessor")
|
|
||||||
assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load
|
|
||||||
|
|
||||||
class FakeInner:
|
|
||||||
preprocessor = object()
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
wrapper._model = FakeInner()
|
|
||||||
assert wrapper.preprocessor is FakeInner.preprocessor
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_model_without_a_preprocessor_still_resets():
|
|
||||||
"""Fakes in tests, and any future openwakeword whose internals moved."""
|
|
||||||
model = FakeModel([0.0])
|
|
||||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
|
||||||
assert model.resets == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_callable_threshold_is_read_every_frame():
|
|
||||||
"""The tray tuner's slider has to apply mid-playback, not just mid-idle."""
|
|
||||||
threshold = {"value": 0.9}
|
|
||||||
detector = WakeWordBargeIn(
|
|
||||||
FakeStream([500] * 2), model=FakeModel([0.6, 0.6]),
|
|
||||||
threshold=lambda: threshold["value"],
|
|
||||||
)
|
|
||||||
assert detector.check() is False
|
|
||||||
threshold["value"] = 0.5
|
|
||||||
assert detector.check() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_model_that_blows_up_mid_playback_is_not_fatal():
|
|
||||||
class BrokenModel:
|
|
||||||
def predict(self, frame):
|
|
||||||
raise RuntimeError("onnx session died")
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
raise RuntimeError("still dead")
|
|
||||||
|
|
||||||
detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5)
|
|
||||||
assert detector.check() is False
|
|
||||||
detector.reset() # must not raise either
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_mic_error_is_not_fatal_in_wake_mode():
|
|
||||||
class BrokenStream:
|
|
||||||
def read(self, frames):
|
|
||||||
raise OSError("device disappeared")
|
|
||||||
|
|
||||||
detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5)
|
|
||||||
assert detector.check() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_detector_picks_the_mode():
|
|
||||||
stream = FakeStream([0])
|
|
||||||
assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn)
|
|
||||||
assert isinstance(make_detector(stream, mode="energy"), BargeInDetector)
|
|
||||||
# A typo in .env shouldn't stop the pet from starting.
|
|
||||||
assert isinstance(make_detector(stream, mode="waek"), BargeInDetector)
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""The Chats dialog, the header New chat, /chats in the terminal, and the
|
||||||
|
row helpers. No network: list/create are injected fakes, and they still run
|
||||||
|
on the real QThreadPool, so the off-UI-thread hand-off is exercised."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from bolt_console import chats, config, console, server_client
|
||||||
|
from bolt_console.tools import Tools
|
||||||
|
from bolt_console.ui import Terminal
|
||||||
|
|
||||||
|
CHATS = [
|
||||||
|
{"session_id": "chat-new1", "label": "Fix the build",
|
||||||
|
"last_message": "try again [Bolt Console: a text terminal on Windows 10; working directory C:/x. To act",
|
||||||
|
"last_active": "2026-09-25T23:46:31+00:00"},
|
||||||
|
{"session_id": "pet-console-test", "label": "console DESKTOP",
|
||||||
|
"last_message": "what is in this folder", "last_active": "2026-09-25T21:26:20+00:00"},
|
||||||
|
{"session_id": "android-SM-S928U", "label": "android SM S928U",
|
||||||
|
"last_message": "wifi networks", "last_active": "2026-09-20T22:33:34+00:00"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _session(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
|
||||||
|
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json")
|
||||||
|
monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9")
|
||||||
|
monkeypatch.setattr(config, "API_KEY", "k")
|
||||||
|
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Calls:
|
||||||
|
"""Fake list/create that record which thread they ran on."""
|
||||||
|
|
||||||
|
def __init__(self, chat_list=CHATS, new_id="chat-fresh", fail=None):
|
||||||
|
self.chat_list, self.new_id, self.fail, self.threads = chat_list, new_id, fail, []
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
self.threads.append(threading.current_thread())
|
||||||
|
if self.fail:
|
||||||
|
raise server_client.ServerError(self.fail)
|
||||||
|
return self.chat_list
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
self.threads.append(threading.current_thread())
|
||||||
|
if self.fail:
|
||||||
|
raise server_client.ServerError(self.fail)
|
||||||
|
return self.new_id
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_preview_drops_client_context_notes_even_when_cut_off():
|
||||||
|
assert chats.clean_preview(CHATS[0]["last_message"]) == "try again"
|
||||||
|
assert chats.clean_preview("hi [on screen right now: VS Code] there") == "hi there"
|
||||||
|
assert chats.clean_preview("x" * 200, 20).endswith("…")
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_time():
|
||||||
|
now = datetime(2026, 9, 26, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def ago(**delta):
|
||||||
|
return (now - timedelta(**delta)).isoformat()
|
||||||
|
|
||||||
|
assert chats.relative_time(ago(seconds=10), now) == "just now"
|
||||||
|
assert chats.relative_time(ago(minutes=12), now) == "12 min ago"
|
||||||
|
assert chats.relative_time(ago(hours=3), now) == "3 h ago"
|
||||||
|
assert chats.relative_time(ago(days=1, hours=2), now) == "yesterday"
|
||||||
|
assert chats.relative_time(ago(days=4), now) == "4 days ago"
|
||||||
|
assert chats.relative_time("garbage", now) == "garbage"
|
||||||
|
|
||||||
|
|
||||||
|
# ── terminal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def terminal_session(calls):
|
||||||
|
out = io.StringIO()
|
||||||
|
ui = Terminal(out=out, color=False, input_fn=lambda _p: "", animate=False)
|
||||||
|
session = console.Session(ui, tools=Tools(ui), list_chats=calls.list,
|
||||||
|
create_chat=calls.create, list_files=lambda: [])
|
||||||
|
return session, out
|
||||||
|
|
||||||
|
|
||||||
|
def test_chats_command_lists_and_marks_the_current_one():
|
||||||
|
session, out = terminal_session(Calls())
|
||||||
|
session.handle("/chats")
|
||||||
|
text = out.getvalue()
|
||||||
|
assert "Fix the build" in text and "try again" in text and "Bolt Console:" not in text
|
||||||
|
assert "* 2 console DESKTOP" in text # the current chat is starred
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_command_switches_by_number_and_remembers_it():
|
||||||
|
session, _ = terminal_session(Calls())
|
||||||
|
session.handle("/chats")
|
||||||
|
session.handle("/chat 3")
|
||||||
|
assert config.SESSION_ID == "android-SM-S928U"
|
||||||
|
assert config._remembered_session() == "android-SM-S928U"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_uses_the_server_minted_id_and_falls_back_locally():
|
||||||
|
session, _ = terminal_session(Calls(new_id="chat-abc123"))
|
||||||
|
session.handle("/new")
|
||||||
|
assert config.SESSION_ID == "chat-abc123"
|
||||||
|
session, out = terminal_session(Calls(fail="HTTP 401"))
|
||||||
|
session.handle("/new")
|
||||||
|
assert config.SESSION_ID.startswith("pet-console-") and "made locally" in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ── window ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def pump(app, predicate, timeout=5.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
app.processEvents()
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window():
|
||||||
|
from bolt_console.gui.window import MainWindow, make_app
|
||||||
|
|
||||||
|
app = make_app(["test"])
|
||||||
|
calls = Calls()
|
||||||
|
win = MainWindow(list_chats=calls.list, create_chat=calls.create)
|
||||||
|
win.calls, win.app = calls, app
|
||||||
|
yield win
|
||||||
|
win.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_lists_chats_off_the_ui_thread(window):
|
||||||
|
window.open_chats()
|
||||||
|
dialog = window.chat_dialog
|
||||||
|
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||||
|
assert window.calls.threads and all(t is not threading.main_thread() for t in window.calls.threads)
|
||||||
|
assert dialog.list.currentItem().data(Qt.UserRole) == "pet-console-test" # current one selected
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_picking_a_chat_switches_session_and_clears_the_transcript(window):
|
||||||
|
from bolt_console.gui.widgets import UserMessage
|
||||||
|
|
||||||
|
window._add(UserMessage("old conversation"))
|
||||||
|
window.open_chats()
|
||||||
|
dialog = window.chat_dialog
|
||||||
|
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||||
|
dialog.list.setCurrentRow(0)
|
||||||
|
dialog.open_button.click()
|
||||||
|
window.app.processEvents()
|
||||||
|
assert config.SESSION_ID == "chat-new1"
|
||||||
|
assert not window.empty.isHidden()
|
||||||
|
assert "Fix the build" in window.hint.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_chat_in_the_dialog_switches_to_the_minted_id(window):
|
||||||
|
window.open_chats()
|
||||||
|
dialog = window.chat_dialog
|
||||||
|
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||||
|
dialog.new_button.click()
|
||||||
|
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_new_chat_mints_off_thread_and_clears(window):
|
||||||
|
from bolt_console.gui.widgets import UserMessage
|
||||||
|
|
||||||
|
window._add(UserMessage("old"))
|
||||||
|
window.start_new_chat()
|
||||||
|
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||||
|
assert window.calls.threads[0] is not threading.main_thread()
|
||||||
|
assert not window.empty.isHidden() and window.new_chat.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_new_chat_failure_keeps_the_current_chat(window):
|
||||||
|
window.calls.fail = "HTTP 401 unauthorized"
|
||||||
|
window.start_new_chat()
|
||||||
|
assert pump(window.app, lambda: "Couldn't start a new chat" in window.hint.text())
|
||||||
|
assert config.SESSION_ID == "pet-console-test"
|
||||||
|
assert window.new_chat.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_shows_a_load_failure(window):
|
||||||
|
window.calls.fail = "HTTP 401"
|
||||||
|
window.open_chats()
|
||||||
|
dialog = window.chat_dialog
|
||||||
|
assert pump(window.app, lambda: "Couldn't load chats" in dialog.status.text())
|
||||||
|
assert dialog.refresh_button.isEnabled()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_is_refused_while_bolt_is_working(window):
|
||||||
|
window._set_busy(True)
|
||||||
|
window.open_chats()
|
||||||
|
assert "Wait for Bolt" in window.hint.text()
|
||||||
|
window.switch_chat("chat-other", "Other")
|
||||||
|
assert config.SESSION_ID == "pet-console-test"
|
||||||
|
window._set_busy(False)
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Whole turns over real HTTP: the real server_client, the real Tools and the
|
||||||
|
real Terminal (writing to a buffer), against a threaded server on a loopback
|
||||||
|
port that speaks the desk protocol. Only the keyboard is faked."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from bolt_console import config, console
|
||||||
|
from bolt_console.console import Session
|
||||||
|
from bolt_console.tools import Tools
|
||||||
|
from bolt_console.ui import Terminal
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDesk:
|
||||||
|
"""Scripted responses, real HTTP. Set `.script` per test."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.script = {}
|
||||||
|
self.requests = []
|
||||||
|
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
||||||
|
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
host, port = self._server.server_address[:2]
|
||||||
|
return f"http://{host}:{port}"
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
|
||||||
|
def _handler(self):
|
||||||
|
desk = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
desk.requests.append(("GET", path))
|
||||||
|
response = desk.script.get(path, {"files": []})
|
||||||
|
if isinstance(response, bytes):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", str(len(response)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(response)
|
||||||
|
else:
|
||||||
|
self._json(response)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
desk.requests.append(("POST", path, body))
|
||||||
|
response = desk.script.get(path)
|
||||||
|
if callable(response):
|
||||||
|
response = response(body)
|
||||||
|
if path.endswith("converse_stream"):
|
||||||
|
if response is None:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/x-ndjson")
|
||||||
|
self.end_headers()
|
||||||
|
for event in response:
|
||||||
|
self.wfile.write((json.dumps(event) + "\n").encode())
|
||||||
|
self.wfile.flush()
|
||||||
|
else:
|
||||||
|
self._json(response if response is not None else {"type": "reply", "text": "ok"})
|
||||||
|
|
||||||
|
def _json(self, payload):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def desk(monkeypatch, tmp_path):
|
||||||
|
server = FakeDesk()
|
||||||
|
monkeypatch.setattr(config, "SERVER_URL", server.url)
|
||||||
|
monkeypatch.setattr(config, "API_KEY", "k")
|
||||||
|
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
|
||||||
|
monkeypatch.setattr(config, "DELIVERED_FILES_DIR", tmp_path / "delivered")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
yield server
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(answers=(), mode="ask"):
|
||||||
|
answers = list(answers)
|
||||||
|
out = io.StringIO()
|
||||||
|
ui = Terminal(out=out, color=False, input_fn=lambda _p: answers.pop(0), animate=False)
|
||||||
|
return Session(ui, tools=Tools(ui, mode=mode)), out
|
||||||
|
|
||||||
|
|
||||||
|
def converse_bodies(desk):
|
||||||
|
return [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_reply_is_printed_once(desk):
|
||||||
|
desk.script["/desk/converse"] = {"type": "reply", "text": "Hi there."}
|
||||||
|
session, out = make_session()
|
||||||
|
session.handle("hello")
|
||||||
|
assert out.getvalue().count("Hi there.") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_sentences_print_as_one_block_and_are_not_repeated(desk):
|
||||||
|
desk.script["/desk/converse_stream"] = [
|
||||||
|
{"type": "say", "text": "First."},
|
||||||
|
{"type": "say", "text": "Second."},
|
||||||
|
{"type": "reply", "text": "First. Second.", "already_spoken": True},
|
||||||
|
]
|
||||||
|
session, out = make_session()
|
||||||
|
session.handle("hello")
|
||||||
|
text = out.getvalue()
|
||||||
|
assert text.count("First.") == 1 and text.count("Second.") == 1
|
||||||
|
assert "* First.\n Second." in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_message_carries_the_working_directory(desk, tmp_path):
|
||||||
|
session, _ = make_session()
|
||||||
|
session.handle("what's here?")
|
||||||
|
body = converse_bodies(desk)[-1]
|
||||||
|
assert body["session_id"] == "pet-console-test"
|
||||||
|
assert body["text"].startswith("what's here?")
|
||||||
|
assert str(tmp_path) in body["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_approved_edit_lands_on_disk_and_the_result_goes_back_up(desk, tmp_path):
|
||||||
|
target = tmp_path / "app.py"
|
||||||
|
target.write_text("DEBUG = True\n", encoding="utf-8")
|
||||||
|
edit = "filectl " + json.dumps(
|
||||||
|
{"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"})
|
||||||
|
desk.script["/desk/converse"] = {"type": "command", "command": edit, "token": "t1"}
|
||||||
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Turned debug off."}
|
||||||
|
|
||||||
|
session, out = make_session(answers=["y"])
|
||||||
|
session.handle("turn debug off in app.py")
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == "DEBUG = False\n"
|
||||||
|
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||||
|
assert posted[0]["token"] == "t1" and "edited" in posted[0]["output"]
|
||||||
|
text = out.getvalue()
|
||||||
|
assert "Edit(" in text and "- DEBUG = True" in text and "+ DEBUG = False" in text
|
||||||
|
assert "Turned debug off." in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declined_shell_command_reports_back_and_the_turn_still_ends(desk, tmp_path):
|
||||||
|
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"}
|
||||||
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."}
|
||||||
|
session, out = make_session(answers=["n"])
|
||||||
|
session.handle("make a file")
|
||||||
|
assert not (tmp_path / "made.txt").exists()
|
||||||
|
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||||
|
assert "declined" in posted[0]["output"]
|
||||||
|
assert "Okay, I won't." in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivered_files_are_saved_after_the_turn(desk, tmp_path):
|
||||||
|
desk.script["/desk/files"] = {"files": [{"id": "f1", "name": "report.txt", "size": 5}]}
|
||||||
|
desk.script["/desk/files/f1"] = b"hello"
|
||||||
|
session, out = make_session()
|
||||||
|
session.handle("send me the report")
|
||||||
|
assert (tmp_path / "delivered" / "report.txt").read_bytes() == b"hello"
|
||||||
|
assert "Received report.txt" in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_errors_are_shown_not_raised(desk):
|
||||||
|
desk.script["/desk/converse"] = {"type": "error", "error": "unauthorized"}
|
||||||
|
session, out = make_session()
|
||||||
|
session.handle("hi")
|
||||||
|
assert "Error: unauthorized" in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_commands(desk, tmp_path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
session, out = make_session()
|
||||||
|
assert session.handle("/cd sub") is True
|
||||||
|
assert Path.cwd() == tmp_path / "sub"
|
||||||
|
session.handle("/auto")
|
||||||
|
assert session.tools.mode == "auto"
|
||||||
|
session.handle("/ask")
|
||||||
|
assert session.tools.mode == "ask"
|
||||||
|
assert session.handle("/exit") is False
|
||||||
|
assert converse_bodies(desk) == [] # none of that went to Bolt
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_exits_cleanly_on_eof(desk):
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
def no_more_input(_prompt):
|
||||||
|
raise EOFError
|
||||||
|
|
||||||
|
ui = Terminal(out=out, color=False, input_fn=no_more_input, animate=False)
|
||||||
|
assert Session(ui).loop() == 0
|
||||||
|
assert "Bolt Console" in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_continues_a_message_onto_the_next_line(desk):
|
||||||
|
lines = ["first line \\", "second line"]
|
||||||
|
ui = Terminal(out=io.StringIO(), color=False, input_fn=lambda _p: lines.pop(0), animate=False)
|
||||||
|
assert ui.read_message() == "first line \nsecond line"
|
||||||
|
|
||||||
|
|
||||||
|
def test_annotate_names_the_directory_and_says_there_is_no_pet():
|
||||||
|
note = console.annotate("hi", "/work", "Linux 6.1")
|
||||||
|
assert note.startswith("hi\n\n")
|
||||||
|
assert "/work" in note and "Linux 6.1" in note and "no pet body" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reply_that_invents_command_results_is_flagged_and_corrected(desk):
|
||||||
|
"""Seen live: 'wrote 1200 chars to …' three turns running, no command sent,
|
||||||
|
no file on disk."""
|
||||||
|
desk.script["/desk/converse_stream"] = [
|
||||||
|
{"type": "say", "text": "Let me check that."},
|
||||||
|
{"type": "say", "text": "→ Command result: [filectl] wrote 1200 chars to C:/x/main.py"},
|
||||||
|
{"type": "reply", "text": "", "already_spoken": True},
|
||||||
|
]
|
||||||
|
session, out = make_session()
|
||||||
|
session.handle("write it")
|
||||||
|
assert console.FABRICATION_WARNING in out.getvalue()
|
||||||
|
|
||||||
|
desk.script["/desk/converse_stream"] = [{"type": "reply", "text": "Sorry.", "already_spoken": False}]
|
||||||
|
session.handle("did you write it?")
|
||||||
|
corrected = converse_bodies(desk)[-1]["text"]
|
||||||
|
assert corrected.startswith(console.CORRECTION)
|
||||||
|
assert "did you write it?" in corrected
|
||||||
|
|
||||||
|
session.handle("thanks") # the correction goes out once, not forever
|
||||||
|
assert not converse_bodies(desk)[-1]["text"].startswith(console.CORRECTION)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_command_output_quoted_in_a_reply_is_not_flagged(desk, tmp_path):
|
||||||
|
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"}
|
||||||
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Got [exit 0] and hi."}
|
||||||
|
session, out = make_session(answers=["y"])
|
||||||
|
session.handle("say hi")
|
||||||
|
assert console.FABRICATION_WARNING not in out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fabricated_matches_the_shapes_seen_live():
|
||||||
|
assert console.fabricated("→ Command result: [filectl] {...}", 0)
|
||||||
|
assert console.fabricated("-> Deliver files result: Files delivered to C:/x", 0)
|
||||||
|
assert console.fabricated("[filectl] wrote 1200 chars to C:/x", 0)
|
||||||
|
assert not console.fabricated("I wrote the plan below.", 0)
|
||||||
|
assert not console.fabricated("→ Command result: [filectl] wrote 3 chars", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_context_note_spells_out_the_command_marker():
|
||||||
|
"""'use filectl and shell commands' made the live model write filectl as
|
||||||
|
prose; the explicit `command:` form got real relayed commands."""
|
||||||
|
note = console.annotate("hi", "/work", "Windows 10")
|
||||||
|
assert "`command: filectl {" in note
|
||||||
|
assert "`command: <shell command>`" in note
|
||||||
|
assert "Never write a command's output yourself" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_conversation_switches_and_remembers_the_session(desk, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state" / "session.json")
|
||||||
|
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
|
||||||
|
session, out = make_session()
|
||||||
|
session._correct_next = True
|
||||||
|
session.handle("/new")
|
||||||
|
new_id = config.SESSION_ID
|
||||||
|
assert new_id.startswith("pet-console-") and new_id != "pet-console-test"
|
||||||
|
assert new_id in out.getvalue()
|
||||||
|
assert not session._correct_next # nothing to correct in an empty history
|
||||||
|
assert config._remembered_session() == new_id # survives a restart
|
||||||
|
|
||||||
|
session.handle("hello")
|
||||||
|
assert converse_bodies(desk)[-1]["session_id"] == new_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_corrupt_state_file_means_the_default_session(tmp_path, monkeypatch):
|
||||||
|
state = tmp_path / "session.json"
|
||||||
|
state.write_text("{not json", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(config, "STATE_FILE", state)
|
||||||
|
assert config._remembered_session() == ""
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bolt_pet import controller as controller_mod
|
|
||||||
from bolt_pet.state import PetState
|
|
||||||
|
|
||||||
# A QApplication is required before any QObject with signals can be built.
|
|
||||||
_app = QApplication.instance() or QApplication(["test"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def no_screen_probes(monkeypatch):
|
|
||||||
# The real ones shell out to xprop/osascript — irrelevant here, and slow
|
|
||||||
# (or hung) on a headless box.
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ctrl():
|
|
||||||
return controller_mod.PetController()
|
|
||||||
|
|
||||||
|
|
||||||
def _capture(signal):
|
|
||||||
events = []
|
|
||||||
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
# ── conversation turn ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_full_turn_happy_path(monkeypatch, ctrl):
|
|
||||||
states = _capture(ctrl.state_changed)
|
|
||||||
said = _capture(ctrl.said)
|
|
||||||
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather")
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: "sunny and 72")
|
|
||||||
spoken = []
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert states == ["listening", "thinking", "talking", "idle"]
|
|
||||||
assert said == ["sunny and 72"]
|
|
||||||
assert spoken == ["sunny and 72"]
|
|
||||||
assert ctrl._state.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_with_nothing_heard_returns_to_idle_without_calling_server(monkeypatch, ctrl):
|
|
||||||
states = _capture(ctrl.state_changed)
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: None)
|
|
||||||
called = {"n": 0}
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
||||||
lambda text, on_command=None: called.__setitem__("n", called["n"] + 1))
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert states == ["listening", "idle"]
|
|
||||||
assert called["n"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_with_empty_transcript_returns_to_idle(monkeypatch, ctrl):
|
|
||||||
states = _capture(ctrl.state_changed)
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "")
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert states == ["listening", "thinking", "idle"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_with_stt_error_flashes_error_then_idle(monkeypatch, ctrl):
|
|
||||||
states = _capture(ctrl.state_changed)
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
|
|
||||||
def boom(pcm):
|
|
||||||
raise controller_mod.stt.SttError("deepgram is down")
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", boom)
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert states == ["listening", "thinking", "error", "idle"]
|
|
||||||
assert any("deepgram is down" in msg for msg in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_with_server_error_flashes_error_then_idle(monkeypatch, ctrl):
|
|
||||||
states = _capture(ctrl.state_changed)
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "hello")
|
|
||||||
|
|
||||||
def boom(text, on_command=None):
|
|
||||||
raise controller_mod.server_client.ServerError("server is down")
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse", boom)
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert states == ["listening", "thinking", "error", "idle"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── mute / talk-now / wake-or-click disambiguation ──────────────────────────
|
|
||||||
|
|
||||||
def test_toggle_mute_flips_and_returns_new_state(ctrl):
|
|
||||||
assert ctrl.toggle_mute() is True
|
|
||||||
assert ctrl.toggle_mute() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_wait_for_wake_or_click_true_on_phrase_detection(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", lambda *a, **k: True)
|
|
||||||
ctrl._stream = object()
|
|
||||||
assert ctrl._wait_for_wake_or_click() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_wait_for_wake_or_click_true_on_manual_trigger(monkeypatch, ctrl):
|
|
||||||
# listen_for_wake_word returns False because should_continue() went
|
|
||||||
# false (the talk_now flag got set) — controller must still recognize
|
|
||||||
# this as "proceed", not "spurious wakeup".
|
|
||||||
def fake_listen(stream, should_continue, on_tick=None, **kwargs):
|
|
||||||
ctrl.request_talk_now()
|
|
||||||
should_continue() # simulate the loop noticing the flag
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen)
|
|
||||||
ctrl._stream = object()
|
|
||||||
assert ctrl._wait_for_wake_or_click() is True
|
|
||||||
assert not ctrl._talk_now.is_set() # cleared after being consumed
|
|
||||||
|
|
||||||
|
|
||||||
def test_wait_for_wake_or_click_false_on_shutdown(monkeypatch, ctrl):
|
|
||||||
def fake_listen(stream, should_continue, on_tick=None, **kwargs):
|
|
||||||
ctrl.stop()
|
|
||||||
return False
|
|
||||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen)
|
|
||||||
ctrl._stream = object()
|
|
||||||
assert ctrl._wait_for_wake_or_click() is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── heartbeat / proactive announcements ─────────────────────────────────────
|
|
||||||
|
|
||||||
def test_heartbeat_speaks_a_pending_announcement_when_idle(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm")
|
|
||||||
spoken = []
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
|
||||||
said = _capture(ctrl.said)
|
|
||||||
|
|
||||||
ctrl._maybe_heartbeat()
|
|
||||||
|
|
||||||
assert spoken == ["don't forget your 3pm"]
|
|
||||||
assert said == ["don't forget your 3pm"]
|
|
||||||
assert ctrl._state.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_does_nothing_when_not_idle(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
|
||||||
ctrl._state.transition(PetState.LISTENING)
|
|
||||||
called = {"n": 0}
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
|
||||||
lambda: called.__setitem__("n", called["n"] + 1))
|
|
||||||
|
|
||||||
ctrl._maybe_heartbeat()
|
|
||||||
|
|
||||||
assert called["n"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_respects_the_interval(monkeypatch, ctrl):
|
|
||||||
# Deterministic fake clock — time.monotonic()'s absolute value is
|
|
||||||
# arbitrary (often system uptime), so asserting behavior relative to it
|
|
||||||
# without controlling it would be flaky.
|
|
||||||
fake_now = {"t": 1000.0}
|
|
||||||
monkeypatch.setattr(controller_mod.time, "monotonic", lambda: fake_now["t"])
|
|
||||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 60)
|
|
||||||
called = {"n": 0}
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
|
||||||
lambda: called.__setitem__("n", called["n"] + 1))
|
|
||||||
|
|
||||||
ctrl._maybe_heartbeat() # last_heartbeat starts at 0.0 -> elapsed is huge -> runs
|
|
||||||
assert called["n"] == 1
|
|
||||||
|
|
||||||
fake_now["t"] += 10 # only 10s later — inside the 60s interval
|
|
||||||
ctrl._maybe_heartbeat()
|
|
||||||
assert called["n"] == 1 # skipped
|
|
||||||
|
|
||||||
fake_now["t"] += 60 # now well past the interval
|
|
||||||
ctrl._maybe_heartbeat()
|
|
||||||
assert called["n"] == 2
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
"""Controller-level wiring for the newer behaviours: petctl routing,
|
|
||||||
barge-in follow-up, quiet hours, screen context, notification forwarding and
|
|
||||||
the live wake threshold.
|
|
||||||
|
|
||||||
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bolt_pet import controller as controller_mod
|
|
||||||
from bolt_pet.notifications import Notification
|
|
||||||
from bolt_pet.state import PetState
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(["test"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def no_screen_probes(monkeypatch):
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ctrl():
|
|
||||||
return controller_mod.PetController()
|
|
||||||
|
|
||||||
|
|
||||||
def _capture(signal):
|
|
||||||
events = []
|
|
||||||
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
# ── petctl routing ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_petctl_commands_never_reach_the_shell(monkeypatch, ctrl):
|
|
||||||
ran = []
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
||||||
actions = _capture(ctrl.action)
|
|
||||||
|
|
||||||
output = ctrl._handle_command("petctl move top-left")
|
|
||||||
|
|
||||||
assert ran == []
|
|
||||||
assert actions == [{"action": "move", "anchor": "top-left"}]
|
|
||||||
assert "top-left" in output
|
|
||||||
|
|
||||||
|
|
||||||
def test_ordinary_commands_still_run_locally(monkeypatch, ctrl):
|
|
||||||
ran = []
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
|
||||||
lambda cmd: ran.append(cmd) or "[exit 0]\n")
|
|
||||||
actions = _capture(ctrl.action)
|
|
||||||
|
|
||||||
ctrl._handle_command("df -h /")
|
|
||||||
|
|
||||||
assert ran == ["df -h /"]
|
|
||||||
assert actions == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_petctl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
|
|
||||||
ran = []
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
|
||||||
output = ctrl._handle_command("petctl move sideways")
|
|
||||||
assert ran == []
|
|
||||||
assert "[pet]" in output
|
|
||||||
|
|
||||||
|
|
||||||
def test_petctl_nap_also_flips_the_controller_state(ctrl):
|
|
||||||
napping = _capture(ctrl.napping)
|
|
||||||
ctrl._handle_command("petctl nap on")
|
|
||||||
assert napping == [True]
|
|
||||||
assert ctrl._napping is True
|
|
||||||
|
|
||||||
|
|
||||||
# ── barge-in ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
|
|
||||||
"""_speak resets the detector after playback so the pet's own voice
|
|
||||||
doesn't linger in the wake model's window. Reading the stats after that
|
|
||||||
reset reports 0.000 at frame 0 for every interruption, which is worse
|
|
||||||
than no instrumentation — it looks like hard evidence and isn't."""
|
|
||||||
from bolt_pet.audio import barge_in as barge_in_mod
|
|
||||||
|
|
||||||
class Detector(barge_in_mod.WakeWordBargeIn):
|
|
||||||
def __init__(self):
|
|
||||||
self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5
|
|
||||||
self._frame_len = 1280
|
|
||||||
self.reset_calls = 0
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.reset_calls += 1
|
|
||||||
self._frames, self._peak, self._last = 0, 0.0, 0.0
|
|
||||||
|
|
||||||
detector = Detector()
|
|
||||||
ctrl._barge_in = detector
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
|
|
||||||
def interrupted_playback(text, on_error=None, should_stop=None):
|
|
||||||
# What really happens: frames get scored during playback, then one
|
|
||||||
# clears the threshold and playback aborts.
|
|
||||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback)
|
|
||||||
|
|
||||||
ctrl._speak("a very long explanation")
|
|
||||||
|
|
||||||
interrupted = next(m for m in logs if "Interrupted" in m)
|
|
||||||
assert "0.810" in interrupted and "frame 7" in interrupted
|
|
||||||
# Once before playback (clear the window) and once after (drop the pet's
|
|
||||||
# own voice) — the point is that the *read* happens between them.
|
|
||||||
assert detector.reset_calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: False) # interrupted
|
|
||||||
|
|
||||||
ctrl._speak("a very long explanation")
|
|
||||||
|
|
||||||
assert ctrl._talk_now.is_set() # loop picks the next turn up without a wake word
|
|
||||||
assert any("Interrupted" in message for message in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
|
||||||
ctrl._speak("short answer")
|
|
||||||
assert not ctrl._talk_now.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
# ── follow-up listening ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def spoke(monkeypatch):
|
|
||||||
"""Playback that always completes, so only the follow-up rule decides
|
|
||||||
whether another turn is queued."""
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
|
|
||||||
ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?")
|
|
||||||
|
|
||||||
assert ctrl._talk_now.is_set() # no wake word needed for the answer
|
|
||||||
assert ctrl._pending_follow_up # and the next turn knows it's an answer
|
|
||||||
assert any("listening for your answer" in message for message in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
|
||||||
ctrl._speak("It's 7:15 AM on July 23, 2026.")
|
|
||||||
assert not ctrl._talk_now.is_set()
|
|
||||||
assert not ctrl._pending_follow_up
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
|
||||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
|
||||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
|
||||||
assert not ctrl._talk_now.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2)
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
|
|
||||||
for _ in range(2):
|
|
||||||
ctrl._speak("Want me to keep going?")
|
|
||||||
ctrl._talk_now.clear()
|
|
||||||
assert ctrl._follow_ups == 2
|
|
||||||
|
|
||||||
ctrl._speak("Want me to keep going?")
|
|
||||||
|
|
||||||
assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it
|
|
||||||
assert any("Follow-up limit reached" in message for message in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1)
|
|
||||||
ctrl._follow_ups = 1
|
|
||||||
logs = _capture(ctrl.log)
|
|
||||||
|
|
||||||
ctrl._speak("Done — the file is saved.")
|
|
||||||
|
|
||||||
assert not any("Follow-up limit" in message for message in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
||||||
lambda *a, **kw: None) # you said nothing
|
|
||||||
ctrl._follow_ups = 3
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert ctrl._follow_ups == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl):
|
|
||||||
"""You were just asked something — you get longer to think than when you
|
|
||||||
deliberately said the wake word."""
|
|
||||||
grace = []
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
||||||
lambda *a, **kw: grace.append(kw.get("grace_s")) or None)
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn() # you started this one
|
|
||||||
ctrl._pending_follow_up = True
|
|
||||||
ctrl._handle_conversation_turn() # this one answers a question
|
|
||||||
|
|
||||||
assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS]
|
|
||||||
|
|
||||||
|
|
||||||
def test_muting_stops_follow_ups(spoke, ctrl):
|
|
||||||
ctrl._muted = True
|
|
||||||
ctrl._speak("Shall I continue?")
|
|
||||||
assert not ctrl._talk_now.is_set() # mute means don't listen, question or not
|
|
||||||
|
|
||||||
|
|
||||||
def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False)
|
|
||||||
ctrl._speak("Shall I continue?")
|
|
||||||
assert not ctrl._talk_now.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: False)
|
|
||||||
ctrl._follow_ups = 3
|
|
||||||
|
|
||||||
ctrl._speak("a very long explanation")
|
|
||||||
|
|
||||||
assert ctrl._follow_ups == 0 # you're clearly engaged
|
|
||||||
assert ctrl._talk_now.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
|
||||||
ctrl._speak("**bold** reply")
|
|
||||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
|
||||||
|
|
||||||
|
|
||||||
# ── screen context ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
||||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's this error?")
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
|
||||||
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
|
||||||
sent = []
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
||||||
lambda text, on_command=None: sent.append(text) or "that's a KeyError")
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert "[on screen right now: app.py]" in sent[0]
|
|
||||||
# ...but the *history* keeps what you actually said, not the annotation.
|
|
||||||
assert ctrl.history.entries()[0].text == "what's this error?"
|
|
||||||
|
|
||||||
|
|
||||||
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_quiet_hours_suppress_the_heartbeat(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
|
||||||
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "00:00-23:59")
|
|
||||||
called = {"n": 0}
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
|
||||||
lambda: called.__setitem__("n", called["n"] + 1))
|
|
||||||
|
|
||||||
ctrl._maybe_heartbeat()
|
|
||||||
|
|
||||||
assert called["n"] == 0
|
|
||||||
assert ctrl._napping is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_fullscreen_triggers_do_not_disturb(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
|
|
||||||
monkeypatch.setattr(controller_mod.config, "DND_ON_FULLSCREEN", True)
|
|
||||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: True)
|
|
||||||
napping = _capture(ctrl.napping)
|
|
||||||
|
|
||||||
ctrl._refresh_nap_state()
|
|
||||||
|
|
||||||
assert napping == [True]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_manual_nap_overrides_the_schedule(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
|
|
||||||
ctrl.set_napping(True)
|
|
||||||
ctrl._last_nap_check = 0.0
|
|
||||||
ctrl._refresh_nap_state()
|
|
||||||
assert ctrl._napping is True # the schedule doesn't wake it back up
|
|
||||||
|
|
||||||
ctrl.set_napping(None) # back on schedule
|
|
||||||
ctrl._last_nap_check = 0.0
|
|
||||||
ctrl._refresh_nap_state()
|
|
||||||
assert ctrl._napping is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
||||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
||||||
lambda text, on_command=None: "always")
|
|
||||||
spoken = []
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
|
||||||
ctrl.set_napping(True)
|
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
|
||||||
|
|
||||||
assert spoken == ["always"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── notification bridge ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
|
||||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
|
|
||||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
||||||
sent, spoken = [], []
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
||||||
lambda text, on_command=None: sent.append(text) or "your build is green")
|
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
||||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
|
||||||
|
|
||||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
|
||||||
ctrl._drain_notifications()
|
|
||||||
|
|
||||||
assert "CI: Build finished" in sent[0]
|
|
||||||
assert spoken == ["your build is green"]
|
|
||||||
assert ctrl._state.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtered_out_notifications_are_never_queued(ctrl):
|
|
||||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
|
||||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
|
||||||
assert ctrl._pending_notifications == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
|
||||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
|
||||||
called = {"n": 0}
|
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
|
||||||
lambda text, on_command=None: called.__setitem__("n", called["n"] + 1))
|
|
||||||
ctrl.set_napping(True)
|
|
||||||
|
|
||||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
|
||||||
ctrl._drain_notifications()
|
|
||||||
|
|
||||||
assert called["n"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── wake threshold ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_threshold_is_live_and_clamped(ctrl):
|
|
||||||
ctrl.set_wake_threshold(0.72)
|
|
||||||
assert ctrl.wake_threshold() == pytest.approx(0.72)
|
|
||||||
ctrl.set_wake_threshold(5)
|
|
||||||
assert ctrl.wake_threshold() == pytest.approx(0.99)
|
|
||||||
ctrl.set_wake_threshold(-1)
|
|
||||||
assert ctrl.wake_threshold() == pytest.approx(0.01)
|
|
||||||
|
|
||||||
|
|
||||||
def test_near_misses_are_recorded_for_the_tuner(ctrl):
|
|
||||||
ctrl.set_wake_threshold(0.5)
|
|
||||||
ctrl._observe_wake_score(0.42, 0.5) # near miss
|
|
||||||
ctrl._observe_wake_score(0.01, 0.5) # background noise, not interesting
|
|
||||||
stats = ctrl.wake_stats()
|
|
||||||
assert len(stats["near_misses"]) == 1
|
|
||||||
assert stats["peak"] == pytest.approx(0.42)
|
|
||||||
|
|
||||||
ctrl.reset_wake_stats()
|
|
||||||
assert ctrl.wake_stats()["near_misses"] == []
|
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Dictation without a mic or a server: a fake PortAudio stream we push
|
||||||
|
frames into, and a fake /desk/stt socket that answers like Deepgram."""
|
||||||
|
|
||||||
|
import array
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bolt_console import dictation
|
||||||
|
|
||||||
|
|
||||||
|
def frame(level: int, samples: int = 1280) -> bytes:
|
||||||
|
return array.array("h", [level, -level] * (samples // 2)).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def result(text, final):
|
||||||
|
return json.dumps({"type": "Results", "is_final": final,
|
||||||
|
"channel": {"alternatives": [{"transcript": text}]}})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
"""Answers each audio chunk with the next scripted message; on CloseStream
|
||||||
|
sends whatever is left, then closes."""
|
||||||
|
|
||||||
|
def __init__(self, replies=()):
|
||||||
|
self.replies = list(replies)
|
||||||
|
self.audio = []
|
||||||
|
self.closed_stream = False
|
||||||
|
self._inbox = queue.Queue()
|
||||||
|
|
||||||
|
def send_binary(self, data):
|
||||||
|
self.audio.append(data)
|
||||||
|
if self.replies and len(self.audio) % 2 == 0:
|
||||||
|
self._inbox.put(self.replies.pop(0))
|
||||||
|
|
||||||
|
def send(self, text):
|
||||||
|
if json.loads(text).get("type") == "CloseStream":
|
||||||
|
self.closed_stream = True
|
||||||
|
for reply in self.replies:
|
||||||
|
self._inbox.put(reply)
|
||||||
|
self._inbox.put("")
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
return self._inbox.get(timeout=5)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._inbox.put("")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStream:
|
||||||
|
def __init__(self):
|
||||||
|
self.callback = None
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
def open(self, callback, _rate):
|
||||||
|
self.callback = callback
|
||||||
|
return self
|
||||||
|
|
||||||
|
def push(self, *chunks):
|
||||||
|
for chunk in chunks:
|
||||||
|
self.callback(chunk)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
def __init__(self):
|
||||||
|
self.updates, self.levels, self.done = [], [], threading.Event()
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
def kwargs(self):
|
||||||
|
return dict(on_update=lambda f, i: self.updates.append((f, i)),
|
||||||
|
on_level=self.levels.append,
|
||||||
|
on_done=lambda text, err: (setattr(self, "result", (text, err)), self.done.set()))
|
||||||
|
|
||||||
|
|
||||||
|
def make(sockets, stream, rec, **extra):
|
||||||
|
sockets = list(sockets)
|
||||||
|
|
||||||
|
def connect(_rate):
|
||||||
|
item = sockets.pop(0)
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
return item
|
||||||
|
|
||||||
|
return dictation.Dictation(**rec.kwargs(), connect_fn=connect, open_stream_fn=stream.open, **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_words_arrive_while_talking_and_stop_returns_the_text():
|
||||||
|
sock = FakeSocket([result("open the", False), result("open the readme", True),
|
||||||
|
result("please", True)])
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
d = make([sock], stream, rec)
|
||||||
|
d.start()
|
||||||
|
assert d.live
|
||||||
|
stream.push(*[frame(3000)] * 4)
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while len(rec.updates) < 2 and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert rec.updates[0] == ("", "open the")
|
||||||
|
assert rec.updates[1] == ("open the readme", "")
|
||||||
|
d.stop()
|
||||||
|
assert rec.done.wait(5)
|
||||||
|
assert rec.result == ("open the readme please", None)
|
||||||
|
assert sock.closed_stream and stream.stopped
|
||||||
|
assert max(rec.levels) > 0 and rec.levels[-1] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_live_socket_means_the_whole_recording_is_sent_on_stop():
|
||||||
|
late = FakeSocket([result("hello bolt", True)])
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
d = make([ConnectionError("down"), late], stream, rec)
|
||||||
|
d.start()
|
||||||
|
assert not d.live
|
||||||
|
stream.push(frame(3000), frame(3000), frame(0))
|
||||||
|
time.sleep(0.3)
|
||||||
|
d.stop()
|
||||||
|
assert rec.done.wait(5)
|
||||||
|
assert rec.result == ("hello bolt", None)
|
||||||
|
assert b"".join(late.audio) == frame(3000) * 2 + frame(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_socket_dying_mid_recording_does_not_duplicate_words():
|
||||||
|
class Dying(FakeSocket):
|
||||||
|
def send_binary(self, data):
|
||||||
|
if len(self.audio) >= 1:
|
||||||
|
raise OSError("connection reset")
|
||||||
|
super().send_binary(data)
|
||||||
|
self._inbox.put(result("half", True))
|
||||||
|
|
||||||
|
retry = FakeSocket([result("the whole thing", True)])
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
d = make([Dying(), retry], stream, rec)
|
||||||
|
d.start()
|
||||||
|
stream.push(frame(3000), frame(3000), frame(3000))
|
||||||
|
time.sleep(0.3)
|
||||||
|
d.stop()
|
||||||
|
assert rec.done.wait(5)
|
||||||
|
assert rec.result == ("the whole thing", None) # not "half the whole thing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pausing_after_speech_stops_on_its_own():
|
||||||
|
sock = FakeSocket([result("done talking", True)])
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
stopping = threading.Event()
|
||||||
|
d = make([sock], stream, rec, silence_seconds=0.3, on_stopping=stopping.set)
|
||||||
|
d.start()
|
||||||
|
stream.push(frame(3000), frame(3000))
|
||||||
|
for _ in range(8):
|
||||||
|
stream.push(frame(10))
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert rec.done.wait(5) # nobody called stop()
|
||||||
|
assert stopping.is_set()
|
||||||
|
assert rec.result == ("done talking", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_silence_alone_never_auto_stops():
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
d = make([FakeSocket()], stream, rec, silence_seconds=0.2)
|
||||||
|
d.start()
|
||||||
|
for _ in range(8):
|
||||||
|
stream.push(frame(10))
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert not rec.done.is_set() # still waiting for you to start talking
|
||||||
|
d.stop()
|
||||||
|
assert rec.done.wait(5)
|
||||||
|
assert rec.result[0] == "" and "Didn't catch anything" in rec.result[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_throws_the_result_away_without_a_fallback_upload():
|
||||||
|
stream, rec = FakeStream(), Recorder()
|
||||||
|
d = make([ConnectionError("down")], stream, rec) # a fallback would need a 2nd socket
|
||||||
|
d.start()
|
||||||
|
stream.push(frame(3000))
|
||||||
|
d.cancel()
|
||||||
|
assert rec.done.wait(5)
|
||||||
|
assert rec.result == ("", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mic_failure_raises_from_start_and_closes_the_socket():
|
||||||
|
sock = FakeSocket()
|
||||||
|
rec = Recorder()
|
||||||
|
|
||||||
|
def broken(_cb, _rate):
|
||||||
|
raise OSError("no input device")
|
||||||
|
|
||||||
|
d = dictation.Dictation(**rec.kwargs(), connect_fn=lambda _r: sock, open_stream_fn=broken)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
d.start()
|
||||||
|
assert sock._inbox.get(timeout=1) == "" # closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_result_is_tolerant():
|
||||||
|
assert dictation.parse_result(result("hi", True)) == ("hi", True)
|
||||||
|
assert dictation.parse_result(json.dumps({"type": "Metadata"})) == ("", False)
|
||||||
|
assert dictation.parse_result(b"not json") == ("", False)
|
||||||
|
assert dictation.parse_result(json.dumps({"type": "Results", "speech_final": True,
|
||||||
|
"channel": {"alternatives": [{"transcript": "x"}]}})) == ("x", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_url_carries_the_session_and_audio_format(monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
fake_ws = types.SimpleNamespace(create_connection=lambda url, header, timeout: seen.update(url=url, header=header))
|
||||||
|
monkeypatch.setitem(sys.modules, "websocket", fake_ws)
|
||||||
|
monkeypatch.setattr(dictation.config, "SERVER_URL", "https://bolt.example")
|
||||||
|
monkeypatch.setattr(dictation.config, "API_KEY", "k")
|
||||||
|
monkeypatch.setattr(dictation.config, "SESSION_ID", "pet-console-x y")
|
||||||
|
dictation.connect()
|
||||||
|
assert seen["url"].startswith("wss://bolt.example/desk/stt?")
|
||||||
|
assert "session_id=pet-console-x+y" in seen["url"] and "sample_rate=16000" in seen["url"]
|
||||||
|
assert seen["header"] == ["X-Desk-Api-Key: k"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the window ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ScriptedDictation:
|
||||||
|
"""Stands in for Dictation in the window: the test drives its callbacks."""
|
||||||
|
|
||||||
|
last = None
|
||||||
|
|
||||||
|
def __init__(self, on_update, on_level, on_done, silence_seconds=3.0, on_stopping=None):
|
||||||
|
self.on_update, self.on_level, self.on_done = on_update, on_level, on_done
|
||||||
|
self.stopped = self.cancelled = False
|
||||||
|
self.live = True
|
||||||
|
ScriptedDictation.last = self
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancelled = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_mic_fills_the_composer_and_blocks_send_until_done(monkeypatch):
|
||||||
|
from bolt_console import config
|
||||||
|
from bolt_console.gui.window import MainWindow, make_app
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9")
|
||||||
|
monkeypatch.setattr(config, "API_KEY", "k")
|
||||||
|
monkeypatch.setattr(dictation, "unavailable_reason", lambda: None)
|
||||||
|
app = make_app(["test"])
|
||||||
|
window = MainWindow(dictation_factory=ScriptedDictation)
|
||||||
|
try:
|
||||||
|
window.composer.edit.setPlainText("Please")
|
||||||
|
window.toggle_dictation()
|
||||||
|
rec = ScriptedDictation.last
|
||||||
|
assert window.composer.mic.state == "recording"
|
||||||
|
rec.on_update("open the readme", "and")
|
||||||
|
app.processEvents()
|
||||||
|
assert window.composer.edit.toPlainText() == "Please open the readme and"
|
||||||
|
assert window.composer.edit.isReadOnly() and not window.composer.send.isEnabled()
|
||||||
|
|
||||||
|
window.toggle_dictation() # click again: stop
|
||||||
|
assert rec.stopped and window.composer.mic.state == "finishing"
|
||||||
|
rec.on_done("open the readme and summarise it", None)
|
||||||
|
app.processEvents()
|
||||||
|
assert window.composer.edit.toPlainText() == "Please open the readme and summarise it"
|
||||||
|
assert not window.composer.edit.isReadOnly() and window.composer.send.isEnabled()
|
||||||
|
assert window.composer.mic.state == "idle"
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_shows_why_the_mic_is_unavailable(monkeypatch):
|
||||||
|
from bolt_console.gui.window import MainWindow, make_app
|
||||||
|
|
||||||
|
monkeypatch.setattr(dictation, "unavailable_reason", lambda: "Dictation needs sounddevice")
|
||||||
|
make_app(["test"])
|
||||||
|
window = MainWindow(dictation_factory=ScriptedDictation)
|
||||||
|
try:
|
||||||
|
ScriptedDictation.last = None
|
||||||
|
window.toggle_dictation()
|
||||||
|
assert ScriptedDictation.last is None
|
||||||
|
assert window.composer.mic.state == "unavailable"
|
||||||
|
assert "sounddevice" in window.hint.text()
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from bolt_console import file_delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_strips_directory_components():
|
||||||
|
assert file_delivery.sanitize_filename("../../etc/passwd") == "passwd"
|
||||||
|
assert file_delivery.sanitize_filename("/absolute/path/report.pdf") == "report.pdf"
|
||||||
|
assert file_delivery.sanitize_filename("plain.txt") == "plain.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_falls_back_on_empty_or_dots():
|
||||||
|
assert file_delivery.sanitize_filename("") == "delivered_file"
|
||||||
|
assert file_delivery.sanitize_filename("..") == "delivered_file"
|
||||||
|
assert file_delivery.sanitize_filename(".") == "delivered_file"
|
||||||
|
assert file_delivery.sanitize_filename(None) == "delivered_file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_path_returns_the_plain_name_when_free(tmp_path):
|
||||||
|
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||||
|
assert path == tmp_path / "report.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_path_suffixes_on_collision(tmp_path):
|
||||||
|
(tmp_path / "report.pdf").write_bytes(b"existing")
|
||||||
|
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||||
|
assert path == tmp_path / "report (1).pdf"
|
||||||
|
|
||||||
|
(tmp_path / "report (1).pdf").write_bytes(b"also existing")
|
||||||
|
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||||
|
assert path == tmp_path / "report (2).pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_path_creates_the_directory(tmp_path):
|
||||||
|
target = tmp_path / "nested" / "dir"
|
||||||
|
file_delivery.unique_path(target, "a.txt")
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_writes_bytes_and_sanitizes_the_name(tmp_path):
|
||||||
|
path = file_delivery.save(tmp_path, "../sneaky/report.pdf", b"hello")
|
||||||
|
assert path == tmp_path / "report.pdf"
|
||||||
|
assert path.read_bytes() == b"hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_never_overwrites_an_existing_download(tmp_path):
|
||||||
|
first = file_delivery.save(tmp_path, "notes.txt", b"first")
|
||||||
|
second = file_delivery.save(tmp_path, "notes.txt", b"second")
|
||||||
|
assert first != second
|
||||||
|
assert first.read_bytes() == b"first"
|
||||||
|
assert second.read_bytes() == b"second"
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""filectl parsing + execution — the pseudo-commands the server can relay to
|
||||||
|
read/write/edit local files instead of a raw shell heredoc.
|
||||||
|
|
||||||
|
filectl {"op": ...} is a single-line JSON envelope (not a multi-line
|
||||||
|
marker block) because it rides the "command" tool marker, which the main
|
||||||
|
repo's tool-call extractor only captures up to the next newline — see the
|
||||||
|
module docstring in bolt_console/file_ops.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from bolt_console import file_ops
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd(payload: dict) -> str:
|
||||||
|
return "filectl " + json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_non_file_commands_are_left_alone():
|
||||||
|
assert file_ops.parse("ls -la") is None
|
||||||
|
assert file_ops.parse("systemctl restart nginx") is None
|
||||||
|
assert file_ops.parse("") is None
|
||||||
|
# "filed" must not be mistaken for the "file" prefix
|
||||||
|
assert file_ops.parse("filed --list") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_parses_defaults():
|
||||||
|
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp"})) == {
|
||||||
|
"action": "list", "path": "/tmp", "pattern": "*", "recursive": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_parses_pattern_and_recursive():
|
||||||
|
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": "*.py", "recursive": True})) == {
|
||||||
|
"action": "list", "path": "/tmp", "pattern": "*.py", "recursive": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_requires_a_path():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "list"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_rejects_empty_pattern():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": ""}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_parses_path_and_optional_line_range():
|
||||||
|
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt"})) == {
|
||||||
|
"action": "read", "path": "/tmp/a.txt", "start": None, "end": None,
|
||||||
|
}
|
||||||
|
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt", "start": 10, "end": 40})) == {
|
||||||
|
"action": "read", "path": "/tmp/a.txt", "start": 10, "end": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_requires_a_path():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "read"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_rejects_non_numeric_line_args():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt", "start": "start"}')
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_parses_path_and_content():
|
||||||
|
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": "hello\nworld"})) == {
|
||||||
|
"action": "write", "path": "/tmp/a.txt", "content": "hello\nworld",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_allows_empty_content():
|
||||||
|
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": ""})) == {
|
||||||
|
"action": "write", "path": "/tmp/a.txt", "content": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_without_content_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_parses_old_and_new():
|
||||||
|
assert file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz"})) == {
|
||||||
|
"action": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_rejects_identical_old_and_new():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "same", "new": "same"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_missing_fields_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_with_embedded_quotes_and_shell_metacharacters_survives():
|
||||||
|
payload = 'echo "hi $USER" `whoami` && rm -rf /'
|
||||||
|
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": payload})
|
||||||
|
assert "\n" not in command # stays on one line, as the command marker requires
|
||||||
|
assert file_ops.parse(command) == {"action": "write", "path": "/tmp/a.txt", "content": payload}
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_content_stays_on_one_physical_line():
|
||||||
|
content = "line one\nline two\nline three with \"quotes\" and \\backslashes\\"
|
||||||
|
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": content})
|
||||||
|
assert "\n" not in command
|
||||||
|
assert file_ops.parse(command)["content"] == content
|
||||||
|
|
||||||
|
|
||||||
|
def test_help():
|
||||||
|
assert file_ops.parse("filectl help") == {"action": "help"}
|
||||||
|
assert file_ops.parse("filectl") == {"action": "help"}
|
||||||
|
assert file_ops.parse(_cmd({"op": "help"})) == {"action": "help"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_json_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse("filectl {not valid json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_object_json_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse("filectl [1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_op_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.parse(_cmd({"op": "frobnicate", "path": "/tmp/a.txt"}))
|
||||||
|
|
||||||
|
|
||||||
|
# ── execution ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_list_shows_files_and_subdirectories(tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("hi")
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||||
|
|
||||||
|
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
|
||||||
|
assert "a.txt\t2B" in output
|
||||||
|
assert "sub/" in output
|
||||||
|
assert "b.txt" not in output # non-recursive: nested file not shown
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_recursive_finds_nested_files(tmp_path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||||
|
|
||||||
|
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "recursive": True}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
|
||||||
|
assert "sub/b.txt" in output or "sub\\b.txt" in output # os-dependent separator
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_pattern_filters_entries(tmp_path):
|
||||||
|
(tmp_path / "a.py").write_text("x")
|
||||||
|
(tmp_path / "b.txt").write_text("y")
|
||||||
|
|
||||||
|
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "pattern": "*.py"}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
|
||||||
|
assert "a.py" in output
|
||||||
|
assert "b.txt" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_empty_directory_says_so(tmp_path):
|
||||||
|
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
assert "no entries" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_missing_directory_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.execute({"action": "list", "path": "/no/such/dir", "pattern": "*", "recursive": False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_rejects_a_file_path(tmp_path):
|
||||||
|
target = tmp_path / "a.txt"
|
||||||
|
target.write_text("hi")
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.execute({"action": "list", "path": str(target), "pattern": "*", "recursive": False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_truncates_past_the_entry_cap(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(file_ops, "_MAX_LIST_ENTRIES", 3)
|
||||||
|
for i in range(5):
|
||||||
|
(tmp_path / f"f{i}.txt").write_text("x")
|
||||||
|
|
||||||
|
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
|
||||||
|
assert "truncated" in output
|
||||||
|
assert output.count(".txt") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_then_read_round_trips(tmp_path):
|
||||||
|
target = tmp_path / "notes.txt"
|
||||||
|
write_action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "line one\nline two"}))
|
||||||
|
result = file_ops.execute(write_action)
|
||||||
|
assert target.read_text() == "line one\nline two"
|
||||||
|
assert str(target) in result
|
||||||
|
|
||||||
|
read_action = file_ops.parse(_cmd({"op": "read", "path": str(target)}))
|
||||||
|
output = file_ops.execute(read_action)
|
||||||
|
assert "line one" in output
|
||||||
|
assert "line two" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_missing_file_raises():
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.execute({"action": "read", "path": "/no/such/file.txt", "start": None, "end": None})
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_respects_line_range(tmp_path):
|
||||||
|
target = tmp_path / "a.txt"
|
||||||
|
target.write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
||||||
|
action = file_ops.parse(_cmd({"op": "read", "path": str(target), "start": 3, "end": 5}))
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
assert "line3" in output and "line5" in output
|
||||||
|
assert "line1" not in output and "line6" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_replaces_a_unique_match(tmp_path):
|
||||||
|
target = tmp_path / "a.py"
|
||||||
|
target.write_text("def foo():\n return 1\n")
|
||||||
|
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "return 1", "new": "return 2"}))
|
||||||
|
file_ops.execute(action)
|
||||||
|
assert target.read_text() == "def foo():\n return 2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_fails_when_text_not_found(tmp_path):
|
||||||
|
target = tmp_path / "a.py"
|
||||||
|
target.write_text("def foo():\n return 1\n")
|
||||||
|
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "nope", "new": "x"}))
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.execute(action)
|
||||||
|
assert target.read_text() == "def foo():\n return 1\n" # untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_fails_when_text_is_ambiguous(tmp_path):
|
||||||
|
target = tmp_path / "a.py"
|
||||||
|
target.write_text("x = 1\nx = 1\n")
|
||||||
|
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}))
|
||||||
|
with pytest.raises(file_ops.FileOpError):
|
||||||
|
file_ops.execute(action)
|
||||||
|
assert target.read_text() == "x = 1\nx = 1\n" # untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_creates_parent_directories(tmp_path):
|
||||||
|
target = tmp_path / "nested" / "dir" / "a.txt"
|
||||||
|
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
|
||||||
|
file_ops.execute(action)
|
||||||
|
assert target.read_text() == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
# ── lenient JSON (see relay_json) ───────────────────────────────────────────
|
||||||
|
# Machine-written JSON fails in a small, repeatable set of ways. Observed live
|
||||||
|
# 2026-07-30: a stray quote after `false` cost a whole desk turn — rejected,
|
||||||
|
# re-sent identically, rejected again, then abandoned with a promise to the
|
||||||
|
# user that nothing fulfilled.
|
||||||
|
|
||||||
|
def test_the_stray_quote_that_cost_a_live_turn_now_parses():
|
||||||
|
action = file_ops.parse(
|
||||||
|
'filectl {"op":"list","path":"/home/maji/Documents","pattern":"*","recursive":false"}'
|
||||||
|
)
|
||||||
|
assert action["action"] == "list"
|
||||||
|
assert action["path"] == "/home/maji/Documents"
|
||||||
|
assert action["recursive"] is False
|
||||||
|
assert action["_repairs"] == ["removed a stray quote after a bare value"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload,expected", [
|
||||||
|
('{"op": "read", "path": "/tmp/a.txt",}', "removed a trailing comma"),
|
||||||
|
("{'op': 'read', 'path': '/tmp/a.txt'}", "converted single-quoted strings to double-quoted"),
|
||||||
|
('{“op”: “read”, “path”: “/tmp/a.txt”}', "replaced smart quotes with straight ones"),
|
||||||
|
('```json {"op": "read", "path": "/tmp/a.txt"} ```', "stripped a markdown code fence"),
|
||||||
|
])
|
||||||
|
def test_common_model_json_mistakes_are_repaired(payload, expected):
|
||||||
|
action = file_ops.parse("filectl " + payload)
|
||||||
|
assert action["action"] == "read"
|
||||||
|
assert action["path"] == "/tmp/a.txt"
|
||||||
|
assert expected in action["_repairs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_literals_are_converted():
|
||||||
|
action = file_ops.parse('filectl {"op": "list", "path": "/tmp", "recursive": True}')
|
||||||
|
assert action["recursive"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
|
||||||
|
"""Silently fixing it would work today and guarantee the same broken call
|
||||||
|
tomorrow — the model has to be told while it still has the turn."""
|
||||||
|
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||||
|
action = file_ops.parse(
|
||||||
|
'filectl {"op":"list","path":%s,"recursive":false"}' % json.dumps(str(tmp_path))
|
||||||
|
)
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
assert "a.txt" in output
|
||||||
|
assert "your JSON was malformed" in output
|
||||||
|
assert "stray quote" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_json_gets_no_repair_note(tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||||
|
action = file_ops.parse('filectl {"op": "list", "path": %s}' % json.dumps(str(tmp_path)))
|
||||||
|
assert "_repairs" not in action
|
||||||
|
assert "malformed" not in file_ops.execute(action)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genuinely_unparseable_json_points_at_the_character():
|
||||||
|
""""Expecting ',' delimiter: char 74" is not something a model can act on."""
|
||||||
|
with pytest.raises(file_ops.FileOpError) as excinfo:
|
||||||
|
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt" "extra": 1}')
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "^" in message # caret under the offending character
|
||||||
|
assert '"extra"' in message # ...and the fragment around it
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_raw_windows_path_is_repaired_and_reported():
|
||||||
|
r"""Models type Windows paths with single backslashes. `\U` isn't a JSON
|
||||||
|
escape, so strict parsing would reject the whole call."""
|
||||||
|
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\Users\sam\data.txt"}')
|
||||||
|
assert action["path"] == r"C:\Users\sam\data.txt"
|
||||||
|
assert any("backslash" in repair for repair in action["_repairs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_backslash_repair_leaves_valid_escapes_alone():
|
||||||
|
# The escaped pair and the valid \n survive; only the lone \U is doubled.
|
||||||
|
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\\ok\Users\n.txt"}')
|
||||||
|
assert action["path"] == "C:\\ok\\Users\n.txt"
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""The window: a whole turn through the real MainWindow, worker thread and
|
||||||
|
Bridge against the loopback desk server from test_console — only the mouse is
|
||||||
|
faked (by clicking the approval button programmatically)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel
|
||||||
|
|
||||||
|
from bolt_console.gui import widgets
|
||||||
|
from bolt_console.gui.bridge import Bridge
|
||||||
|
from bolt_console.gui.window import MainWindow, make_app
|
||||||
|
|
||||||
|
from tests.test_console import desk # noqa: F401 (fixture)
|
||||||
|
|
||||||
|
app = make_app(["test"])
|
||||||
|
|
||||||
|
|
||||||
|
def pump_until(predicate, timeout=10.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
app.processEvents()
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def entries(window, kind):
|
||||||
|
layout = window.entries
|
||||||
|
return [layout.itemAt(i).widget() for i in range(layout.count())
|
||||||
|
if isinstance(layout.itemAt(i).widget(), kind)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window(desk, monkeypatch): # noqa: F811
|
||||||
|
from bolt_console import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "STREAMING_REPLIES", True)
|
||||||
|
win = MainWindow(mode="ask")
|
||||||
|
yield win
|
||||||
|
win.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_blocks_the_worker_until_the_window_answers():
|
||||||
|
bridge = Bridge()
|
||||||
|
asked = []
|
||||||
|
bridge.approval_needed.connect(lambda tool_id, q: asked.append(q))
|
||||||
|
result = {}
|
||||||
|
worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?")))
|
||||||
|
worker.start()
|
||||||
|
assert pump_until(lambda: asked == ["Run it?"])
|
||||||
|
assert worker.is_alive() # still waiting on the user
|
||||||
|
bridge.answer("always")
|
||||||
|
worker.join(2)
|
||||||
|
assert result["a"] == "always"
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_releases_a_pending_approval_as_no():
|
||||||
|
bridge = Bridge()
|
||||||
|
result = {}
|
||||||
|
worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?")))
|
||||||
|
worker.start()
|
||||||
|
time.sleep(0.05)
|
||||||
|
bridge.close()
|
||||||
|
worker.join(2)
|
||||||
|
assert result["a"] == "no"
|
||||||
|
assert bridge.confirm("again?") == "no" # and never blocks after close
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_turn_with_an_approved_edit(window, desk, tmp_path): # noqa: F811
|
||||||
|
target = tmp_path / "app.py"
|
||||||
|
target.write_text("DEBUG = True\n", encoding="utf-8")
|
||||||
|
edit = "filectl " + json.dumps(
|
||||||
|
{"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"})
|
||||||
|
desk.script["/desk/converse_stream"] = [
|
||||||
|
{"type": "say", "text": "On it."},
|
||||||
|
{"type": "command", "command": edit, "token": "t1"},
|
||||||
|
]
|
||||||
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Debug is **off** now."}
|
||||||
|
|
||||||
|
window._send("turn debug off")
|
||||||
|
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||||
|
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||||
|
card = entries(window, widgets.ToolCard)[0]
|
||||||
|
assert card.pill.property("kind") == "waiting"
|
||||||
|
assert "DEBUG = True" in card.diff.text()
|
||||||
|
|
||||||
|
card.allow_button.click()
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == "DEBUG = False\n"
|
||||||
|
assert card.pill.property("kind") == "ok"
|
||||||
|
texts = [m.text() for m in entries(window, widgets.AssistantMessage)]
|
||||||
|
assert texts == ["On it.", "Debug is **off** now."]
|
||||||
|
assert len(entries(window, widgets.UserMessage)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_reports_back_and_the_turn_finishes(window, desk, tmp_path): # noqa: F811
|
||||||
|
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"}
|
||||||
|
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."}
|
||||||
|
window._send("make a file")
|
||||||
|
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||||
|
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||||
|
card = entries(window, widgets.ToolCard)[0]
|
||||||
|
card._answer("no")
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
assert not (tmp_path / "made.txt").exists()
|
||||||
|
assert card.pill.property("kind") == "declined"
|
||||||
|
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||||
|
assert "declined" in posted[0]["output"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_allow_flips_the_header_switch_to_auto(window, desk, tmp_path): # noqa: F811
|
||||||
|
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"}
|
||||||
|
window._send("say hi")
|
||||||
|
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||||
|
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||||
|
entries(window, widgets.ToolCard)[0]._answer("always")
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
assert window.session.tools.mode == "auto"
|
||||||
|
assert window.mode.buttons["auto"].isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_empties_the_transcript_but_keeps_the_scaffolding(window, desk): # noqa: F811
|
||||||
|
window._send("hello")
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
window.clear_transcript()
|
||||||
|
app.processEvents()
|
||||||
|
assert entries(window, widgets.UserMessage) == []
|
||||||
|
assert window.entries.indexOf(window.thinking) != -1
|
||||||
|
assert not window.empty.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_code_uses_the_app_mono_font():
|
||||||
|
html = widgets.markdown_html("run `ls` now", QLabel().font())
|
||||||
|
assert "Courier New" not in html
|
||||||
|
assert widgets.theme.CODE in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_columns_pads_tab_separated_rows():
|
||||||
|
rows = widgets.align_columns(["dir/", "a.txt\t5B", "longer.txt\t12B"])
|
||||||
|
assert rows[0] == "dir/"
|
||||||
|
assert rows[1].index("5B") == rows[2].index("12B")
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_chat_clears_the_window_and_starts_a_fresh_session(window, desk, tmp_path, monkeypatch): # noqa: F811
|
||||||
|
from bolt_console import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json")
|
||||||
|
desk.script["/desk/chats"] = {"session_id": "chat-3f9a2b1c9e04"} # POST mints the id
|
||||||
|
window._send("hello")
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
window.start_new_chat()
|
||||||
|
assert pump_until(lambda: config.SESSION_ID == "chat-3f9a2b1c9e04")
|
||||||
|
assert ("POST", "/desk/chats", {}) in desk.requests # over real HTTP, off the UI thread
|
||||||
|
assert entries(window, widgets.UserMessage) == []
|
||||||
|
assert not window.empty.isHidden()
|
||||||
|
|
||||||
|
window._send("next")
|
||||||
|
assert pump_until(lambda: not window._busy)
|
||||||
|
converse = [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]]
|
||||||
|
assert converse[-1]["session_id"] == "chat-3f9a2b1c9e04" # the next turn uses the new chat
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_ico_holds_every_size_the_exe_needs(tmp_path):
|
||||||
|
from PySide6.QtGui import QImageReader
|
||||||
|
from bolt_console.gui import icons
|
||||||
|
|
||||||
|
path = tmp_path / "bolt.ico"
|
||||||
|
icons.write_ico(str(path))
|
||||||
|
reader = QImageReader(str(path))
|
||||||
|
sizes = []
|
||||||
|
while True:
|
||||||
|
image = reader.read()
|
||||||
|
if image.isNull():
|
||||||
|
break
|
||||||
|
sizes.append(image.width())
|
||||||
|
if not reader.jumpToNextImage():
|
||||||
|
break
|
||||||
|
assert sizes == list(icons.APP_ICON_SIZES)
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Conversation scrollback + push-to-talk hotkey parsing."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import history as history_mod
|
|
||||||
from bolt_pet.hotkey import GlobalHotkey, HotkeyError, to_pynput_spec
|
|
||||||
|
|
||||||
|
|
||||||
# ── history ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_keeps_entries_in_order():
|
|
||||||
log = history_mod.ConversationHistory(limit=10)
|
|
||||||
log.add(history_mod.USER, "what's the weather")
|
|
||||||
log.add(history_mod.PET, "sunny and 72")
|
|
||||||
assert [e.text for e in log.entries()] == ["what's the weather", "sunny and 72"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_drops_the_oldest_past_the_limit():
|
|
||||||
log = history_mod.ConversationHistory(limit=2)
|
|
||||||
for i in range(5):
|
|
||||||
log.add(history_mod.PET, f"line {i}")
|
|
||||||
assert [e.text for e in log.entries()] == ["line 3", "line 4"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_blank_entries_are_ignored():
|
|
||||||
log = history_mod.ConversationHistory()
|
|
||||||
assert log.add(history_mod.PET, " ") is None
|
|
||||||
assert len(log) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_can_filter_by_role():
|
|
||||||
log = history_mod.ConversationHistory()
|
|
||||||
log.add(history_mod.USER, "hello")
|
|
||||||
log.add(history_mod.PET, "hi there")
|
|
||||||
log.add(history_mod.USER, "still there?")
|
|
||||||
assert log.last().text == "still there?"
|
|
||||||
assert log.last(history_mod.PET).text == "hi there"
|
|
||||||
|
|
||||||
|
|
||||||
def test_as_text_is_copyable_transcript():
|
|
||||||
log = history_mod.ConversationHistory()
|
|
||||||
log.add(history_mod.USER, "ping")
|
|
||||||
log.add(history_mod.PET, "pong")
|
|
||||||
assert log.as_text() == "You: ping\nBolt: pong"
|
|
||||||
|
|
||||||
|
|
||||||
def test_timestamps_are_rendered_when_present():
|
|
||||||
log = history_mod.ConversationHistory()
|
|
||||||
log.add(history_mod.PET, "pong", timestamp=1710000000.0)
|
|
||||||
assert log.as_text(clock=lambda t: "12:00:00") == "[12:00:00] Bolt: pong"
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_empties_the_log():
|
|
||||||
log = history_mod.ConversationHistory()
|
|
||||||
log.add(history_mod.PET, "pong")
|
|
||||||
log.clear()
|
|
||||||
assert len(log) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── hotkey ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_translates_a_readable_spec_to_pynput_syntax():
|
|
||||||
assert to_pynput_spec("ctrl+alt+space") == "<ctrl>+<alt>+<space>"
|
|
||||||
assert to_pynput_spec("ctrl+shift+b") == "<ctrl>+<shift>+b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_accepts_the_names_people_actually_type():
|
|
||||||
assert to_pynput_spec("Control+Option+Space") == "<ctrl>+<alt>+<space>"
|
|
||||||
assert to_pynput_spec("super+k") == "<cmd>+k"
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_spec_is_an_error_at_parse_time():
|
|
||||||
with pytest.raises(HotkeyError):
|
|
||||||
to_pynput_spec("")
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_hotkey_starts_cleanly_and_reports_nothing():
|
|
||||||
hotkey = GlobalHotkey("", lambda: None)
|
|
||||||
assert hotkey.start() is None
|
|
||||||
assert hotkey.running is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_hotkey_reports_instead_of_raising():
|
|
||||||
hotkey = GlobalHotkey("+++", lambda: None)
|
|
||||||
problem = hotkey.start()
|
|
||||||
assert problem and "invalid" in problem
|
|
||||||
assert hotkey.running is False
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bolt_pet.audio import mic
|
|
||||||
|
|
||||||
FRAME_LEN = 320 # small for fast tests
|
|
||||||
SAMPLE_RATE = 8000
|
|
||||||
|
|
||||||
|
|
||||||
class _ScriptedStream:
|
|
||||||
"""Replays a fixed list of frames, then quiet forever."""
|
|
||||||
|
|
||||||
def __init__(self, frames):
|
|
||||||
self._frames = list(frames)
|
|
||||||
|
|
||||||
def read(self, frames):
|
|
||||||
if self._frames:
|
|
||||||
frame = self._frames.pop(0)
|
|
||||||
else:
|
|
||||||
frame = np.zeros(FRAME_LEN, dtype=np.int16)
|
|
||||||
return frame.reshape(-1, 1), False
|
|
||||||
|
|
||||||
|
|
||||||
def _loud(n=1):
|
|
||||||
return [np.full(FRAME_LEN, 5000, dtype=np.int16) for _ in range(n)]
|
|
||||||
|
|
||||||
|
|
||||||
def _quiet(n=1):
|
|
||||||
return [np.zeros(FRAME_LEN, dtype=np.int16) for _ in range(n)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_none_when_nothing_ever_gets_loud():
|
|
||||||
stream = _ScriptedStream(_quiet(50))
|
|
||||||
calls = {"i": 0}
|
|
||||||
|
|
||||||
def should_continue():
|
|
||||||
calls["i"] += 1
|
|
||||||
return calls["i"] <= 50
|
|
||||||
|
|
||||||
result = mic.record_utterance(
|
|
||||||
stream, should_continue=should_continue,
|
|
||||||
rms_threshold=300, silence_end_sec=0.5, max_utterance_s=5, min_utterance_s=0.1,
|
|
||||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
|
||||||
)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_captures_speech_and_stops_after_trailing_silence():
|
|
||||||
# speech, then enough silence to cross the silence_end_sec threshold
|
|
||||||
silence_end_sec = 0.5
|
|
||||||
silence_limit_frames = int(silence_end_sec * SAMPLE_RATE / FRAME_LEN)
|
|
||||||
frames = _loud(5) + _quiet(silence_limit_frames + 2)
|
|
||||||
stream = _ScriptedStream(frames)
|
|
||||||
|
|
||||||
result = mic.record_utterance(
|
|
||||||
stream, rms_threshold=300, silence_end_sec=silence_end_sec,
|
|
||||||
max_utterance_s=5, min_utterance_s=0.05,
|
|
||||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
|
||||||
)
|
|
||||||
assert result is not None
|
|
||||||
# captured the loud frames plus the silence up to (and including) the
|
|
||||||
# frame that crossed the silence-end threshold, but not endless silence
|
|
||||||
assert len(result) < len(frames) * FRAME_LEN
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_none_if_utterance_shorter_than_minimum():
|
|
||||||
frames = _loud(1) + _quiet(2) # crosses silence limit almost immediately
|
|
||||||
stream = _ScriptedStream(frames)
|
|
||||||
result = mic.record_utterance(
|
|
||||||
stream, rms_threshold=300, silence_end_sec=0.05,
|
|
||||||
max_utterance_s=5, min_utterance_s=5.0, # impossible to satisfy
|
|
||||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
|
||||||
)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_stops_at_max_utterance_even_without_silence():
|
|
||||||
max_utterance_s = 0.5
|
|
||||||
max_frames = int(max_utterance_s * SAMPLE_RATE / FRAME_LEN)
|
|
||||||
stream = _ScriptedStream(_loud(max_frames + 20)) # never goes quiet
|
|
||||||
|
|
||||||
result = mic.record_utterance(
|
|
||||||
stream, rms_threshold=300, silence_end_sec=10.0, # would never trigger
|
|
||||||
max_utterance_s=max_utterance_s, min_utterance_s=0.01,
|
|
||||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
|
||||||
)
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) == max_frames * FRAME_LEN
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_none_when_should_continue_stops_before_speech():
|
|
||||||
stream = _ScriptedStream(_quiet(100))
|
|
||||||
result = mic.record_utterance(stream, should_continue=lambda: False,
|
|
||||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_pcm_to_wav_bytes_round_trips_via_wave_module():
|
|
||||||
import wave
|
|
||||||
import io
|
|
||||||
|
|
||||||
pcm = np.array([0, 100, -100, 32767, -32768], dtype=np.int16)
|
|
||||||
wav_bytes = mic.pcm_to_wav_bytes(pcm, sample_rate=16000)
|
|
||||||
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
|
|
||||||
assert wf.getnchannels() == 1
|
|
||||||
assert wf.getsampwidth() == 2
|
|
||||||
assert wf.getframerate() == 16000
|
|
||||||
frames = wf.readframes(wf.getnframes())
|
|
||||||
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""dbus-monitor parsing + the forward/rate-limit gate. No session bus
|
|
||||||
needed — the parser is fed canned dbus-monitor output."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet.notifications import Notification, NotificationGate, iter_notifications
|
|
||||||
|
|
||||||
SAMPLE = '''signal time=1710000000.1 sender=org.freedesktop.DBus -> destination=:1.7 serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameAcquired
|
|
||||||
string ":1.7"
|
|
||||||
method call time=1710000001.2 sender=:1.72 -> destination=org.freedesktop.Notifications serial=88 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
|
|
||||||
string "Firefox"
|
|
||||||
uint32 0
|
|
||||||
string ""
|
|
||||||
string "Build finished"
|
|
||||||
string "All 42 tests passed"
|
|
||||||
array [
|
|
||||||
]
|
|
||||||
int32 -1
|
|
||||||
method call time=1710000002.3 sender=:1.80 -> destination=org.freedesktop.Notifications serial=91 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
|
|
||||||
string "Calendar"
|
|
||||||
uint32 0
|
|
||||||
string "calendar-icon"
|
|
||||||
string "Standup in 5 minutes"
|
|
||||||
string ""
|
|
||||||
array [
|
|
||||||
]
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _parse(text=SAMPLE):
|
|
||||||
return list(iter_notifications(text.splitlines()))
|
|
||||||
|
|
||||||
|
|
||||||
def test_parses_each_notify_call():
|
|
||||||
parsed = _parse()
|
|
||||||
assert parsed == [
|
|
||||||
Notification(app="Firefox", summary="Build finished", body="All 42 tests passed"),
|
|
||||||
Notification(app="Calendar", summary="Standup in 5 minutes", body=""),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unrelated_dbus_traffic_is_ignored():
|
|
||||||
noise = SAMPLE.split("method call")[0]
|
|
||||||
assert _parse(noise) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_trailing_notification_without_a_following_block_is_still_emitted():
|
|
||||||
assert _parse()[-1].summary == "Standup in 5 minutes"
|
|
||||||
|
|
||||||
|
|
||||||
def test_as_text_is_what_gets_sent_to_the_server():
|
|
||||||
assert _parse()[0].as_text() == "Firefox: Build finished — All 42 tests passed"
|
|
||||||
assert _parse()[1].as_text() == "Calendar: Standup in 5 minutes"
|
|
||||||
|
|
||||||
|
|
||||||
# ── gate ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _notification(summary="Build finished", app="Firefox"):
|
|
||||||
return Notification(app=app, summary=summary, body="")
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_filter_forwards_everything():
|
|
||||||
gate = NotificationGate("", min_interval=0)
|
|
||||||
assert gate.should_forward(_notification(), now=0) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_filter_regex_selects_what_is_worth_a_round_trip():
|
|
||||||
gate = NotificationGate(r"build|deploy", min_interval=0)
|
|
||||||
assert gate.should_forward(_notification("Build finished"), now=0) is True
|
|
||||||
assert gate.should_forward(_notification("New message from Dave"), now=1) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_drops_a_burst():
|
|
||||||
gate = NotificationGate("", min_interval=60)
|
|
||||||
assert gate.should_forward(_notification(), now=100) is True
|
|
||||||
assert gate.should_forward(_notification(), now=120) is False
|
|
||||||
assert gate.should_forward(_notification(), now=161) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_broken_regex_does_not_silence_the_bridge():
|
|
||||||
gate = NotificationGate("(unclosed", min_interval=0)
|
|
||||||
assert gate.should_forward(_notification(), now=0) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_notifications_are_dropped():
|
|
||||||
gate = NotificationGate("", min_interval=0)
|
|
||||||
assert gate.should_forward(Notification(app="", summary="", body=""), now=0) is False
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""petctl parsing — the pseudo-commands the server can relay to drive the
|
|
||||||
pet's body instead of a shell."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import pet_actions
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_pet_commands_are_left_alone():
|
|
||||||
assert pet_actions.parse("ls -la") is None
|
|
||||||
assert pet_actions.parse("systemctl restart nginx") is None
|
|
||||||
assert pet_actions.parse("") is None
|
|
||||||
# "petstore" must not be mistaken for the "pet" prefix
|
|
||||||
assert pet_actions.parse("petstore --list") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_to_an_anchor():
|
|
||||||
assert pet_actions.parse("petctl move top-left") == {"action": "move", "anchor": "top-left"}
|
|
||||||
assert pet_actions.parse("petctl move bottom_right") == {"action": "move", "anchor": "bottom-right"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_to_coordinates():
|
|
||||||
assert pet_actions.parse("petctl move 300 120") == {"action": "move", "x": 300, "y": 120}
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_rejects_nonsense_targets():
|
|
||||||
with pytest.raises(pet_actions.ActionError):
|
|
||||||
pet_actions.parse("petctl move sideways")
|
|
||||||
with pytest.raises(pet_actions.ActionError):
|
|
||||||
pet_actions.parse("petctl move")
|
|
||||||
|
|
||||||
|
|
||||||
def test_emotes():
|
|
||||||
assert pet_actions.parse("petctl emote wave") == {"action": "emote", "emote": "wave"}
|
|
||||||
with pytest.raises(pet_actions.ActionError):
|
|
||||||
pet_actions.parse("petctl emote moonwalk")
|
|
||||||
|
|
||||||
|
|
||||||
def test_say_keeps_the_whole_sentence():
|
|
||||||
assert pet_actions.parse('petctl say "build is green"') == {
|
|
||||||
"action": "say", "text": "build is green"
|
|
||||||
}
|
|
||||||
assert pet_actions.parse("petctl say build is green")["text"] == "build is green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_wander_and_nap_toggles():
|
|
||||||
assert pet_actions.parse("petctl wander off") == {"action": "wander", "enabled": False}
|
|
||||||
assert pet_actions.parse("petctl nap on") == {"action": "nap", "enabled": True}
|
|
||||||
with pytest.raises(pet_actions.ActionError):
|
|
||||||
pet_actions.parse("petctl wander maybe")
|
|
||||||
|
|
||||||
|
|
||||||
def test_alternate_prefixes_and_verbs():
|
|
||||||
assert pet_actions.parse("bolt-pet goto center")["anchor"] == "center"
|
|
||||||
assert pet_actions.parse("pet do hop")["emote"] == "hop"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_verb_is_an_error_not_a_shell_command():
|
|
||||||
with pytest.raises(pet_actions.ActionError):
|
|
||||||
pet_actions.parse("petctl explode")
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_is_reported_back_to_the_server():
|
|
||||||
assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"})
|
|
||||||
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
|
||||||
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"""Window-side behaviour for the newer features: emote curves, petctl
|
|
||||||
actions, edge snapping, napping, click-through.
|
|
||||||
|
|
||||||
Needs a QApplication — run with QT_QPA_PLATFORM=offscreen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtCore import QPoint
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import config
|
|
||||||
from bolt_pet.state import PetState
|
|
||||||
from bolt_pet.ui.pet_window import _EMOTE_TICKS, PetWindow, emote_transform
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def qt_app():
|
|
||||||
yield QApplication.instance() or QApplication([])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pet(qt_app):
|
|
||||||
window = PetWindow()
|
|
||||||
yield window
|
|
||||||
window.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── emote curves (pure maths) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("emote", ["wave", "hop", "bounce", "spin", "nod", "shake", "wiggle"])
|
|
||||||
def test_every_emote_returns_the_sprite_to_rest(emote):
|
|
||||||
# Anything that doesn't land back at the identity transform leaves the pet
|
|
||||||
# permanently askew. (approx: the sine curves land on ~1e-16, not 0.0.)
|
|
||||||
rest = pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-9)
|
|
||||||
assert emote_transform(emote, 1.0) == rest
|
|
||||||
assert emote_transform(emote, 0.0) == rest
|
|
||||||
|
|
||||||
|
|
||||||
def test_emotes_actually_move_the_sprite_mid_animation():
|
|
||||||
for emote in ("wave", "hop", "spin", "nod", "shake"):
|
|
||||||
samples = [emote_transform(emote, i / 20) for i in range(1, 20)]
|
|
||||||
assert any(sample != (0.0, 0.0, 0.0, 1.0) for sample in samples), emote
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_emote_is_a_no_op_not_a_crash():
|
|
||||||
assert emote_transform("moonwalk", 0.5) == (0.0, 0.0, 0.0, 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_progress_is_clamped():
|
|
||||||
assert emote_transform("hop", 5.0) == emote_transform("hop", 1.0)
|
|
||||||
assert emote_transform("hop", -3.0) == emote_transform("hop", 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_emote_finishes_and_clears_itself(pet):
|
|
||||||
pet.start_emote("spin")
|
|
||||||
assert pet._emote == "spin"
|
|
||||||
for _ in range(_EMOTE_TICKS + 2):
|
|
||||||
pet._advance_emote()
|
|
||||||
assert pet._emote is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── petctl actions ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_move_action_sets_a_walk_target(pet):
|
|
||||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
|
||||||
assert pet._wander_target is not None
|
|
||||||
assert pet._commanded_move is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_commanded_moves_happen_even_while_talking(pet, monkeypatch):
|
|
||||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False) # snapping would move it again on arrival
|
|
||||||
pet.set_state(PetState.TALKING)
|
|
||||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
|
||||||
target = pet._wander_target
|
|
||||||
if target is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
for _ in range(2000):
|
|
||||||
pet._wander_tick()
|
|
||||||
if pet._wander_target is None:
|
|
||||||
break
|
|
||||||
assert pet.pos() == target
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_commanded_move_survives_the_reply_arriving(pet):
|
|
||||||
# Real ordering: `petctl move` comes back as a tool call mid-turn, then
|
|
||||||
# the reply flips the pet to TALKING a moment later. That must not cancel
|
|
||||||
# the walk it was just told to make.
|
|
||||||
pet.apply_action({"action": "move", "anchor": "center"})
|
|
||||||
pet.set_state(PetState.TALKING)
|
|
||||||
assert pet._wander_target is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_to_explicit_coordinates_is_clamped_on_screen(pet):
|
|
||||||
pet.apply_action({"action": "move", "x": -5000, "y": -5000})
|
|
||||||
geo = pet._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
assert pet._wander_target.x() >= geo.left()
|
|
||||||
assert pet._wander_target.y() >= geo.top()
|
|
||||||
|
|
||||||
|
|
||||||
def test_say_action_shows_the_bubble(pet):
|
|
||||||
pet.apply_action({"action": "say", "text": "build is green"})
|
|
||||||
assert pet._bubble.text == "build is green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_wander_and_nap_actions(pet):
|
|
||||||
pet.apply_action({"action": "wander", "enabled": False})
|
|
||||||
assert pet._wander_enabled is False
|
|
||||||
pet.apply_action({"action": "nap", "enabled": True})
|
|
||||||
assert pet.napping is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_emote_action_starts_the_emote(pet):
|
|
||||||
pet.apply_action({"action": "emote", "emote": "wave"})
|
|
||||||
assert pet._emote == "wave"
|
|
||||||
|
|
||||||
|
|
||||||
# ── napping ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_napping_dims_the_pet_and_stops_it_wandering(pet):
|
|
||||||
pet.set_napping(True)
|
|
||||||
assert pet.windowOpacity() < 1.0
|
|
||||||
start = pet.pos()
|
|
||||||
pet.wander_now()
|
|
||||||
for _ in range(60):
|
|
||||||
pet._wander_tick()
|
|
||||||
assert pet.pos() == start
|
|
||||||
|
|
||||||
pet.set_napping(False)
|
|
||||||
assert pet.windowOpacity() == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
# ── edge snapping ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_snaps_flush_when_parked_near_an_edge(pet, monkeypatch):
|
|
||||||
geo = pet._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
|
||||||
pet.move(geo.left() + 10, geo.top() + 10)
|
|
||||||
assert pet.snap_to_edge() is True
|
|
||||||
assert pet.pos() == QPoint(geo.left(), geo.top())
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_snap_from_the_middle_of_the_screen(pet, monkeypatch):
|
|
||||||
geo = pet._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
|
||||||
middle = QPoint(geo.left() + geo.width() // 2, geo.top() + geo.height() // 2)
|
|
||||||
pet.move(middle)
|
|
||||||
assert pet.snap_to_edge() is False
|
|
||||||
assert pet.pos() == middle
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapping_can_be_turned_off(pet, monkeypatch):
|
|
||||||
geo = pet._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
|
|
||||||
pet.move(geo.left() + 10, geo.top() + 10)
|
|
||||||
assert pet.snap_to_edge() is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── click-through ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_click_through_toggles_mouse_transparency(pet):
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
pet.set_click_through(True)
|
|
||||||
assert pet.click_through is True
|
|
||||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is True
|
|
||||||
|
|
||||||
pet.set_click_through(False)
|
|
||||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Quiet-hours parsing and matching, without waiting for 11pm."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from datetime import time as dtime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import quiet
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_spec_means_never_quiet():
|
|
||||||
assert quiet.is_quiet("") is False
|
|
||||||
assert quiet.is_quiet(" ") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_simple_daytime_range():
|
|
||||||
ranges = quiet.parse_ranges("13:00-14:00")
|
|
||||||
assert quiet.in_ranges(dtime(13, 30), ranges) is True
|
|
||||||
assert quiet.in_ranges(dtime(12, 59), ranges) is False
|
|
||||||
assert quiet.in_ranges(dtime(14, 0), ranges) is False # end is exclusive
|
|
||||||
|
|
||||||
|
|
||||||
def test_range_wrapping_past_midnight():
|
|
||||||
ranges = quiet.parse_ranges("23:00-08:00")
|
|
||||||
for moment in (dtime(23, 0), dtime(23, 59), dtime(0, 0), dtime(7, 59)):
|
|
||||||
assert quiet.in_ranges(moment, ranges) is True, moment
|
|
||||||
for moment in (dtime(8, 0), dtime(12, 0), dtime(22, 59)):
|
|
||||||
assert quiet.in_ranges(moment, ranges) is False, moment
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_ranges():
|
|
||||||
ranges = quiet.parse_ranges("23:00-08:00, 13:00-14:00")
|
|
||||||
assert quiet.in_ranges(dtime(13, 15), ranges) is True
|
|
||||||
assert quiet.in_ranges(dtime(2, 0), ranges) is True
|
|
||||||
assert quiet.in_ranges(dtime(16, 0), ranges) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_length_range_is_not_all_day():
|
|
||||||
assert quiet.in_ranges(dtime(12, 0), quiet.parse_ranges("09:00-09:00")) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_specs_raise_when_parsed_directly():
|
|
||||||
for spec in ("nonsense", "25:00-26:00", "13:00", "13:60-14:00"):
|
|
||||||
with pytest.raises(quiet.QuietHoursError):
|
|
||||||
quiet.parse_ranges(spec)
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_spec_is_reported_but_never_mutes_the_pet():
|
|
||||||
seen = []
|
|
||||||
assert quiet.is_quiet("nonsense", now=dtime(3, 0), on_error=seen.append) is False
|
|
||||||
assert len(seen) == 1
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Screen-context parsing/annotation. The subprocess probes are platform
|
|
||||||
specific; the parsing they feed is not, so that's what's tested here."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import screen_context
|
|
||||||
|
|
||||||
|
|
||||||
def test_parses_the_active_window_id():
|
|
||||||
output = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007\n"
|
|
||||||
assert screen_context.parse_xprop_window_id(output) == "0x3c00007"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_active_window_id_when_nothing_is_focused():
|
|
||||||
assert screen_context.parse_xprop_window_id("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0") is None
|
|
||||||
assert screen_context.parse_xprop_window_id("") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parses_the_window_title():
|
|
||||||
output = '_NET_WM_NAME(UTF8_STRING) = "bolt_pet/controller.py - Cursor"\n'
|
|
||||||
assert screen_context.parse_xprop_window_name(output) == "bolt_pet/controller.py - Cursor"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unset_title_property():
|
|
||||||
assert screen_context.parse_xprop_window_name("_NET_WM_NAME: not found") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_fullscreen_state_detection():
|
|
||||||
assert screen_context.parse_xprop_fullscreen(
|
|
||||||
"_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN, _NET_WM_STATE_FOCUSED") is True
|
|
||||||
assert screen_context.parse_xprop_fullscreen("_NET_WM_STATE(ATOM) = _NET_WM_STATE_FOCUSED") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_desktop_titles_are_treated_as_no_context():
|
|
||||||
assert screen_context.clean_title("Desktop") is None
|
|
||||||
assert screen_context.clean_title(" ") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_long_titles_are_truncated():
|
|
||||||
cleaned = screen_context.clean_title("x" * 500)
|
|
||||||
assert len(cleaned) <= 160 and cleaned.endswith("…")
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotate_appends_context_as_an_aside():
|
|
||||||
annotated = screen_context.annotate("what's this error?", "app.py — Traceback")
|
|
||||||
assert annotated.startswith("what's this error?")
|
|
||||||
assert "[on screen right now: app.py — Traceback]" in annotated
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotate_is_a_no_op_without_a_title_or_text():
|
|
||||||
assert screen_context.annotate("hello", None) == "hello"
|
|
||||||
assert screen_context.annotate("hello", "Desktop") == "hello"
|
|
||||||
assert screen_context.annotate("", "Firefox") == ""
|
|
||||||
@@ -6,14 +6,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bolt_pet import server_client
|
from bolt_console import server_client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _configure(monkeypatch):
|
def _configure(monkeypatch):
|
||||||
monkeypatch.setattr(server_client.config, "SERVER_URL", "http://test-server:5002")
|
monkeypatch.setattr(server_client.config, "SERVER_URL", "http://test-server:5002")
|
||||||
monkeypatch.setattr(server_client.config, "API_KEY", "test-key")
|
monkeypatch.setattr(server_client.config, "API_KEY", "test-key")
|
||||||
monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-test")
|
monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-console-test")
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(json_data, ok=True):
|
def _mock_response(json_data, ok=True):
|
||||||
@@ -27,11 +27,11 @@ def test_converse_returns_reply_directly():
|
|||||||
with patch.object(server_client.requests, "post") as post:
|
with patch.object(server_client.requests, "post") as post:
|
||||||
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
||||||
result = server_client.converse("hi")
|
result = server_client.converse("hi")
|
||||||
assert result == "hello there"
|
assert result.text == "hello there"
|
||||||
post.assert_called_once()
|
post.assert_called_once()
|
||||||
args, kwargs = post.call_args
|
args, kwargs = post.call_args
|
||||||
assert args[0] == "http://test-server:5002/desk/converse"
|
assert args[0] == "http://test-server:5002/desk/converse"
|
||||||
assert kwargs["json"] == {"session_id": "pet-test", "text": "hi"}
|
assert kwargs["json"] == {"session_id": "pet-console-test", "text": "hi"}
|
||||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@ def test_converse_relays_a_command_then_returns_reply():
|
|||||||
with patch.object(server_client.requests, "post", side_effect=responses) as post:
|
with patch.object(server_client.requests, "post", side_effect=responses) as post:
|
||||||
on_command = MagicMock(return_value="[exit 0]\nhi")
|
on_command = MagicMock(return_value="[exit 0]\nhi")
|
||||||
result = server_client.converse("run echo hi", on_command=on_command)
|
result = server_client.converse("run echo hi", on_command=on_command)
|
||||||
assert result == "done"
|
assert result.text == "done"
|
||||||
on_command.assert_called_once_with("echo hi")
|
on_command.assert_called_once_with("echo hi")
|
||||||
# second call was to /desk/tool_result with the command's output
|
# second call was to /desk/tool_result with the command's output
|
||||||
second_call = post.call_args_list[1]
|
second_call = post.call_args_list[1]
|
||||||
assert second_call.args[0] == "http://test-server:5002/desk/tool_result"
|
assert second_call.args[0] == "http://test-server:5002/desk/tool_result"
|
||||||
assert second_call.kwargs["json"] == {
|
assert second_call.kwargs["json"] == {
|
||||||
"session_id": "pet-test", "token": "tok1", "output": "[exit 0]\nhi",
|
"session_id": "pet-console-test", "token": "tok1", "output": "[exit 0]\nhi",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -66,21 +66,182 @@ def test_converse_raises_server_error_when_unreachable():
|
|||||||
server_client.converse("hi")
|
server_client.converse("hi")
|
||||||
|
|
||||||
|
|
||||||
def test_report_status_returns_reply_text_when_present():
|
|
||||||
with patch.object(server_client.requests, "post") as post:
|
|
||||||
post.return_value = _mock_response({"reply": "don't forget your 3pm"})
|
|
||||||
result = server_client.report_status()
|
|
||||||
assert result == "don't forget your 3pm"
|
|
||||||
|
|
||||||
|
|
||||||
def test_report_status_returns_none_when_nothing_pending():
|
|
||||||
with patch.object(server_client.requests, "post") as post:
|
|
||||||
post.return_value = _mock_response({"ok": True})
|
|
||||||
assert server_client.report_status() is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_health_returns_parsed_json():
|
def test_check_health_returns_parsed_json():
|
||||||
with patch.object(server_client.requests, "get") as get:
|
with patch.object(server_client.requests, "get") as get:
|
||||||
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
|
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
|
||||||
result = server_client.check_health()
|
result = server_client.check_health()
|
||||||
assert result == {"ok": True, "service": "bolt-desk-api"}
|
assert result == {"ok": True, "service": "bolt-desk-api"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_outbox_files_returns_the_queue():
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response(
|
||||||
|
{"files": [{"id": "abc", "name": "report.pdf", "size": 9}]}
|
||||||
|
)
|
||||||
|
result = server_client.list_outbox_files()
|
||||||
|
assert result == [{"id": "abc", "name": "report.pdf", "size": 9}]
|
||||||
|
args, kwargs = get.call_args
|
||||||
|
assert args[0] == "http://test-server:5002/desk/files"
|
||||||
|
assert kwargs["params"] == {"session_id": "pet-console-test"}
|
||||||
|
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_outbox_files_defaults_to_empty_list():
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response({})
|
||||||
|
assert server_client.list_outbox_files() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_outbox_files_raises_server_error_when_unreachable():
|
||||||
|
with patch.object(server_client.requests, "get", side_effect=ConnectionError("no route")):
|
||||||
|
with pytest.raises(server_client.ServerError):
|
||||||
|
server_client.list_outbox_files()
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_outbox_file_returns_raw_bytes():
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
resp = _mock_response({})
|
||||||
|
resp.content = b"%PDF fake bytes"
|
||||||
|
get.return_value = resp
|
||||||
|
result = server_client.download_outbox_file("abc")
|
||||||
|
assert result == b"%PDF fake bytes"
|
||||||
|
args, kwargs = get.call_args
|
||||||
|
assert args[0] == "http://test-server:5002/desk/files/abc"
|
||||||
|
assert kwargs["params"] == {"session_id": "pet-console-test"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response({}, ok=False)
|
||||||
|
with pytest.raises(server_client.ServerError, match="abc"):
|
||||||
|
server_client.download_outbox_file("abc")
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamResponse:
|
||||||
|
def __init__(self, events):
|
||||||
|
self._lines = [__import__("json").dumps(e) for e in events]
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def iter_lines(self, decode_unicode=True):
|
||||||
|
return iter(self._lines)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_converse_stream_hands_each_sentence_over_and_marks_the_reply_streamed():
|
||||||
|
said = []
|
||||||
|
events = [
|
||||||
|
{"type": "say", "text": "First."},
|
||||||
|
{"type": "say", "text": "Second."},
|
||||||
|
{"type": "reply", "text": "First. Second.", "already_spoken": True},
|
||||||
|
]
|
||||||
|
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||||
|
reply = server_client.converse_stream("hi", on_say=said.append)
|
||||||
|
assert said == ["First.", "Second."]
|
||||||
|
assert reply == server_client.Reply("First. Second.", streamed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_converse_stream_finishes_a_tool_call_through_the_plain_relay():
|
||||||
|
events = [{"type": "command", "command": "filectl {}", "token": "t1"}]
|
||||||
|
posts = [_StreamResponse(events), _mock_response({"type": "reply", "text": "done"})]
|
||||||
|
on_command = MagicMock(return_value="out")
|
||||||
|
with patch.object(server_client.requests, "post", side_effect=posts) as post:
|
||||||
|
reply = server_client.converse_stream("hi", on_say=lambda _s: None, on_command=on_command)
|
||||||
|
on_command.assert_called_once_with("filectl {}")
|
||||||
|
assert post.call_args_list[1].args[0].endswith("/desk/tool_result")
|
||||||
|
assert reply == server_client.Reply("done", streamed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_converse_stream_failing_before_any_text_raises_for_the_fallback():
|
||||||
|
with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")):
|
||||||
|
with pytest.raises(server_client.ServerError):
|
||||||
|
server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_local_command_runs_in_the_current_directory(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / "marker.txt").write_text("x")
|
||||||
|
output = server_client.run_local_command("dir /b" if sys.platform == "win32" else "ls")
|
||||||
|
assert output.startswith("[exit 0]")
|
||||||
|
assert "marker.txt" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_final_reply_repeating_the_streamed_sentences_is_not_shown_twice():
|
||||||
|
"""Seen live: sentences streamed, then the same text again as the reply,
|
||||||
|
without already_spoken — with line breaks the stream had dropped."""
|
||||||
|
events = [
|
||||||
|
{"type": "say", "text": "Let me check that."},
|
||||||
|
{"type": "say", "text": "The folder contains:"},
|
||||||
|
{"type": "reply", "text": "Let me check that.\n\nThe folder contains:\n- a\n- b"},
|
||||||
|
]
|
||||||
|
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||||
|
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||||
|
assert reply == server_client.Reply("- a\n- b", streamed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_exact_repeat_counts_as_streamed():
|
||||||
|
events = [{"type": "say", "text": "Done."}, {"type": "reply", "text": "Done."}]
|
||||||
|
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||||
|
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||||
|
assert reply.streamed and reply.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_different_final_reply_is_shown_in_full():
|
||||||
|
events = [{"type": "say", "text": "Checking."}, {"type": "reply", "text": "All good."}]
|
||||||
|
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||||
|
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||||
|
assert reply == server_client.Reply("All good.", streamed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_chats_returns_the_list_with_the_api_key():
|
||||||
|
chats = [{"session_id": "chat-3f9a2b1c9e04", "label": "Fix the build",
|
||||||
|
"last_message": "try again", "last_active": "2026-09-25T23:46:31+00:00"}]
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response({"chats": chats})
|
||||||
|
assert server_client.list_chats() == chats
|
||||||
|
args, kwargs = get.call_args
|
||||||
|
assert args[0] == "http://test-server:5002/desk/chats"
|
||||||
|
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_chats_defaults_to_empty_and_wraps_failures():
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response({})
|
||||||
|
assert server_client.list_chats() == []
|
||||||
|
with patch.object(server_client.requests, "get") as get:
|
||||||
|
get.return_value = _mock_response({}, ok=False) # e.g. a 401
|
||||||
|
with pytest.raises(server_client.ServerError, match="couldn't list chats"):
|
||||||
|
server_client.list_chats()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_chat_posts_and_returns_the_new_session_id():
|
||||||
|
with patch.object(server_client.requests, "post") as post:
|
||||||
|
post.return_value = _mock_response({"session_id": "chat-3f9a2b1c9e04"})
|
||||||
|
assert server_client.create_chat() == "chat-3f9a2b1c9e04"
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://test-server:5002/desk/chats"
|
||||||
|
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_chat_without_a_session_id_is_an_error():
|
||||||
|
with patch.object(server_client.requests, "post") as post:
|
||||||
|
post.return_value = _mock_response({})
|
||||||
|
with pytest.raises(server_client.ServerError, match="session_id"):
|
||||||
|
server_client.create_chat()
|
||||||
|
with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")):
|
||||||
|
with pytest.raises(server_client.ServerError):
|
||||||
|
server_client.create_chat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_call_follows_a_reassigned_session_id(monkeypatch):
|
||||||
|
"""Switching chats is just `config.SESSION_ID = new_id`; the next request
|
||||||
|
must carry it, with no call site holding on to the old one."""
|
||||||
|
monkeypatch.setattr(server_client.config, "SESSION_ID", "chat-new")
|
||||||
|
with patch.object(server_client.requests, "post") as post:
|
||||||
|
post.return_value = _mock_response({"type": "reply", "text": "hi"})
|
||||||
|
server_client.converse("hi")
|
||||||
|
assert post.call_args.kwargs["json"]["session_id"] == "chat-new"
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Sanitizing chat-formatted replies into speakable prose. Pure string logic
|
|
||||||
— no audio hardware, no Qt (see the testing conventions in CLAUDE.md)."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
|
||||||
|
|
||||||
|
|
||||||
def test_bold_markers_are_not_spoken():
|
|
||||||
assert "*" not in for_speech("Here's the **maji-desktop** snapshot")
|
|
||||||
assert for_speech("Here's the **maji-desktop** snapshot") == "Here's the maji-desktop snapshot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_italics_and_underscores_dropped():
|
|
||||||
assert for_speech("that is _really_ odd") == "that is really odd"
|
|
||||||
assert for_speech("***everything*** is fine") == "everything is fine"
|
|
||||||
|
|
||||||
|
|
||||||
def test_bullet_list_becomes_sentences():
|
|
||||||
spoken = for_speech(
|
|
||||||
"System status:\n"
|
|
||||||
"* **OS:** Linux 7.0.0\n"
|
|
||||||
"* **Uptime:** 1 day\n"
|
|
||||||
)
|
|
||||||
assert "*" not in spoken
|
|
||||||
assert spoken == "System status: OS: Linux 7.0.0. Uptime: 1 day."
|
|
||||||
|
|
||||||
|
|
||||||
def test_emoji_and_symbols_removed():
|
|
||||||
assert for_speech("✅ Done 🚀 — all good") == "Done all good"
|
|
||||||
assert for_speech("→ next step") == "to next step"
|
|
||||||
|
|
||||||
|
|
||||||
def test_urls_and_code_are_not_read_out_character_by_character():
|
|
||||||
assert for_speech("see https://example.com/x?y=1 for docs") == "see link for docs"
|
|
||||||
assert for_speech("run `sudo reboot` now") == "run sudo reboot now"
|
|
||||||
assert for_speech("here:\n```\nls -la\n```\n") == "here: (code)."
|
|
||||||
|
|
||||||
|
|
||||||
def test_headings_quotes_and_rules_stripped():
|
|
||||||
assert for_speech("## Summary\n---\n> quoted bit") == "Summary. quoted bit."
|
|
||||||
|
|
||||||
|
|
||||||
def test_ampersand_and_percent_are_spoken_as_words():
|
|
||||||
assert for_speech("R&D at 50% capacity") == "R and D at 50 percent capacity"
|
|
||||||
|
|
||||||
|
|
||||||
def test_blank_and_symbol_only_input():
|
|
||||||
assert for_speech("") == ""
|
|
||||||
assert for_speech(None) == ""
|
|
||||||
assert for_speech("***") == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_display_keeps_emoji_but_drops_markdown():
|
|
||||||
assert for_display("**Done** ✅") == "Done ✅"
|
|
||||||
assert for_display("* one\n* two") == "• one • two"
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_question_only_fires_on_a_trailing_question():
|
|
||||||
assert is_question("Ready to run a command or start a project?")
|
|
||||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
|
||||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
|
||||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_question_ignores_trailing_decoration():
|
|
||||||
assert is_question("Ready to go? 🚀")
|
|
||||||
assert is_question('Shall I continue?"')
|
|
||||||
assert is_question("Want me to fix it? **")
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
|
||||||
# The '?' here is inside a URL query string, which for_speech strips.
|
|
||||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
|
||||||
assert not is_question("")
|
|
||||||
assert not is_question(None)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bolt_pet.state import InvalidTransition, PetState, PetStateMachine
|
|
||||||
|
|
||||||
|
|
||||||
def test_starts_idle():
|
|
||||||
sm = PetStateMachine()
|
|
||||||
assert sm.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_happy_path_transitions():
|
|
||||||
sm = PetStateMachine()
|
|
||||||
sm.transition(PetState.LISTENING)
|
|
||||||
sm.transition(PetState.THINKING)
|
|
||||||
sm.transition(PetState.TALKING)
|
|
||||||
sm.transition(PetState.IDLE)
|
|
||||||
assert sm.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_idle_to_talking_is_allowed_for_proactive_announcements():
|
|
||||||
# The heartbeat poll can make the pet speak unprompted (a reminder
|
|
||||||
# firing, a nudge from the server) with no preceding listen/think leg.
|
|
||||||
sm = PetStateMachine()
|
|
||||||
sm.transition(PetState.TALKING)
|
|
||||||
assert sm.state == PetState.TALKING
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_transition_raises():
|
|
||||||
sm = PetStateMachine()
|
|
||||||
with pytest.raises(InvalidTransition):
|
|
||||||
sm.transition(PetState.THINKING) # can't skip straight to thinking with no utterance
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_state_transition_is_a_noop():
|
|
||||||
calls = []
|
|
||||||
sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new)))
|
|
||||||
sm.transition(PetState.IDLE) # already idle
|
|
||||||
assert calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_change_callback_fires_with_old_and_new():
|
|
||||||
calls = []
|
|
||||||
sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new)))
|
|
||||||
sm.transition(PetState.LISTENING)
|
|
||||||
assert calls == [(PetState.IDLE, PetState.LISTENING)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_state_can_reach_error_and_recover():
|
|
||||||
for path in (
|
|
||||||
[PetState.LISTENING],
|
|
||||||
[PetState.LISTENING, PetState.THINKING],
|
|
||||||
[PetState.LISTENING, PetState.THINKING, PetState.TALKING],
|
|
||||||
):
|
|
||||||
sm = PetStateMachine()
|
|
||||||
for step in path:
|
|
||||||
sm.transition(step)
|
|
||||||
sm.transition(PetState.ERROR)
|
|
||||||
sm.transition(PetState.IDLE)
|
|
||||||
assert sm.state == PetState.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_force_recovers_from_talking_directly_to_idle_without_validation():
|
|
||||||
sm = PetStateMachine()
|
|
||||||
sm.transition(PetState.LISTENING)
|
|
||||||
sm.transition(PetState.THINKING)
|
|
||||||
sm.transition(PetState.TALKING)
|
|
||||||
sm.force(PetState.LISTENING) # not in TALKING's allowed set, but force skips the check
|
|
||||||
assert sm.state == PetState.LISTENING
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Graphical sudo prompts: command rewriting and helper resolution.
|
|
||||||
Pure logic — no display, no sudo, no password."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import sudo_askpass
|
|
||||||
|
|
||||||
|
|
||||||
# ── rewriting ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command,expected",
|
|
||||||
[
|
|
||||||
("sudo apt update", "sudo -A apt update"),
|
|
||||||
("sudo apt update", "sudo -A apt update"), # spacing preserved
|
|
||||||
("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"),
|
|
||||||
("ls; sudo reboot", "ls; sudo -A reboot"),
|
|
||||||
("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"),
|
|
||||||
("sudo systemctl restart x\nsudo systemctl status x",
|
|
||||||
"sudo -A systemctl restart x\nsudo -A systemctl status x"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_bare_sudo_gets_the_askpass_flag(command, expected):
|
|
||||||
assert sudo_askpass.add_askpass_flag(command) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command",
|
|
||||||
[
|
|
||||||
"sudo -n apt update", # explicitly non-interactive
|
|
||||||
"sudo -A apt update", # already asking
|
|
||||||
"sudo -u bob whoami", # the caller was explicit
|
|
||||||
"ls -la", # no sudo at all
|
|
||||||
"echo 'run sudo later'", # inside a quoted string
|
|
||||||
"pseudo --version", # not the word sudo
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_commands_that_must_not_be_rewritten(command):
|
|
||||||
assert sudo_askpass.add_askpass_flag(command) == command
|
|
||||||
|
|
||||||
|
|
||||||
def test_blank_input():
|
|
||||||
assert sudo_askpass.add_askpass_flag("") == ""
|
|
||||||
assert sudo_askpass.add_askpass_flag(None) == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command,expected",
|
|
||||||
[
|
|
||||||
("sudo apt update", True),
|
|
||||||
("ls && sudo reboot", True),
|
|
||||||
("ls -la", False),
|
|
||||||
("echo 'sudo'", False),
|
|
||||||
("", False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_which_commands_get_the_longer_timeout(command, expected):
|
|
||||||
assert sudo_askpass.needs_password_prompt(command) is expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── helper resolution ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_configured_helper_wins():
|
|
||||||
found = sudo_askpass.find_helper(
|
|
||||||
configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass",
|
|
||||||
)
|
|
||||||
assert found == "/opt/my-askpass"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced():
|
|
||||||
"""Better to have sudo fail than to quietly prompt with something the
|
|
||||||
user didn't choose."""
|
|
||||||
assert sudo_askpass.find_helper(
|
|
||||||
configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity",
|
|
||||||
) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_real_askpass_binary_beats_a_generated_wrapper():
|
|
||||||
found = sudo_askpass.find_helper(
|
|
||||||
configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass",
|
|
||||||
which=lambda tool: "/usr/bin/zenity",
|
|
||||||
)
|
|
||||||
assert found == "/usr/bin/ksshaskpass"
|
|
||||||
|
|
||||||
|
|
||||||
def test_falls_back_to_wrapping_a_dialog_tool(tmp_path):
|
|
||||||
found = sudo_askpass.find_helper(
|
|
||||||
configured="", is_executable=lambda p: False,
|
|
||||||
which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None,
|
|
||||||
cache_dir=tmp_path,
|
|
||||||
)
|
|
||||||
script = tmp_path / "askpass.sh"
|
|
||||||
assert found == str(script)
|
|
||||||
assert "zenity --password" in script.read_text()
|
|
||||||
assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_helper_available_at_all():
|
|
||||||
assert sudo_askpass.find_helper(
|
|
||||||
configured="", is_executable=lambda p: False, which=lambda tool: None,
|
|
||||||
) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_wrapper_passes_sudos_prompt_through():
|
|
||||||
"""sudo hands the helper its prompt as $1 — it names the account the
|
|
||||||
password is for, which is worth showing in the dialog."""
|
|
||||||
script = sudo_askpass.helper_script("/usr/bin/zenity")
|
|
||||||
assert script.startswith("#!/bin/sh")
|
|
||||||
assert '"$1"' in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_environment_points_sudo_at_the_helper():
|
|
||||||
env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"})
|
|
||||||
assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh"
|
|
||||||
assert env["PATH"] == "/usr/bin" # the rest of the environment survives
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""The relayed-command dispatcher: what runs, what asks first, what's refused."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from bolt_console import tools as tools_mod
|
||||||
|
from bolt_console.tools import Tools
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUI:
|
||||||
|
def __init__(self, answers=()):
|
||||||
|
self.answers = list(answers)
|
||||||
|
self.calls = []
|
||||||
|
self.results = []
|
||||||
|
self.questions = []
|
||||||
|
|
||||||
|
def tool_call(self, name, argument):
|
||||||
|
self.calls.append((name, argument))
|
||||||
|
|
||||||
|
def tool_result(self, text, error=False):
|
||||||
|
self.results.append((text, error))
|
||||||
|
|
||||||
|
def diff(self, old, new):
|
||||||
|
self.calls.append(("diff", old, new))
|
||||||
|
|
||||||
|
def confirm(self, question):
|
||||||
|
self.questions.append(question)
|
||||||
|
return self.answers.pop(0) if self.answers else "no"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeShell:
|
||||||
|
def __init__(self, output="[exit 0]\nhello"):
|
||||||
|
self.output = output
|
||||||
|
self.ran = []
|
||||||
|
|
||||||
|
def __call__(self, command):
|
||||||
|
self.ran.append(command)
|
||||||
|
return self.output
|
||||||
|
|
||||||
|
|
||||||
|
def filectl(**payload) -> str:
|
||||||
|
return "filectl " + json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_command_asks_first_and_runs_on_yes():
|
||||||
|
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||||
|
output = Tools(ui, run_shell=shell).handle("echo hello")
|
||||||
|
assert shell.ran == ["echo hello"]
|
||||||
|
assert output == "[exit 0]\nhello"
|
||||||
|
assert ui.calls == [("Shell", "echo hello")]
|
||||||
|
assert ui.results == [("hello", False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_declined_command_never_runs_and_tells_the_model_why():
|
||||||
|
ui, shell = FakeUI(["no"]), FakeShell()
|
||||||
|
output = Tools(ui, run_shell=shell).handle("rm -rf build")
|
||||||
|
assert shell.ran == []
|
||||||
|
assert "declined" in output and "did not run" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_switches_the_session_to_auto():
|
||||||
|
ui, shell = FakeUI(["always"]), FakeShell()
|
||||||
|
tools = Tools(ui, run_shell=shell)
|
||||||
|
tools.handle("echo one")
|
||||||
|
tools.handle("echo two")
|
||||||
|
assert tools.mode == "auto"
|
||||||
|
assert len(ui.questions) == 1
|
||||||
|
assert shell.ran == ["echo one", "echo two"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_mode_never_asks():
|
||||||
|
ui, shell = FakeUI(), FakeShell()
|
||||||
|
Tools(ui, mode="auto", run_shell=shell).handle("echo hi")
|
||||||
|
assert ui.questions == []
|
||||||
|
assert shell.ran == ["echo hi"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_command_is_shown_as_an_error_with_its_status():
|
||||||
|
ui = FakeUI(["yes"])
|
||||||
|
Tools(ui, run_shell=FakeShell("[exit 2]\nno such file")).handle("cat nope")
|
||||||
|
assert ui.results == [("[exit 2]\nno such file", True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_and_lists_never_ask(tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
|
||||||
|
ui = FakeUI()
|
||||||
|
tools = Tools(ui, run_shell=FakeShell())
|
||||||
|
read = tools.handle(filectl(op="read", path=str(tmp_path / "a.txt")))
|
||||||
|
listed = tools.handle(filectl(op="list", path=str(tmp_path)))
|
||||||
|
assert ui.questions == []
|
||||||
|
assert "one" in read and "two" in read
|
||||||
|
assert "a.txt" in listed
|
||||||
|
assert ui.calls[0] == ("Read", str(tmp_path / "a.txt"))
|
||||||
|
assert ui.results[0] == ("Read 2 lines", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_asks_and_declining_leaves_the_disk_alone(tmp_path):
|
||||||
|
target = tmp_path / "new.txt"
|
||||||
|
ui = FakeUI(["no"])
|
||||||
|
output = Tools(ui).handle(filectl(op="write", path=str(target), content="x"))
|
||||||
|
assert not target.exists()
|
||||||
|
assert "declined" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_shows_the_diff_then_applies_on_yes(tmp_path):
|
||||||
|
target = tmp_path / "f.py"
|
||||||
|
target.write_text("x = 1\n", encoding="utf-8")
|
||||||
|
ui = FakeUI(["yes"])
|
||||||
|
output = Tools(ui).handle(filectl(op="edit", path=str(target), old="x = 1", new="x = 2"))
|
||||||
|
assert target.read_text(encoding="utf-8") == "x = 2\n"
|
||||||
|
assert ("diff", "x = 1", "x = 2") in ui.calls
|
||||||
|
assert "edited" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_filectl_errors_go_back_to_the_model_not_up_the_stack(tmp_path):
|
||||||
|
ui = FakeUI()
|
||||||
|
output = Tools(ui).handle(filectl(op="read", path=str(tmp_path / "missing.txt")))
|
||||||
|
assert output.startswith("[filectl] no such file")
|
||||||
|
assert ui.results[-1][1] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pet_commands_are_explained_rather_than_run():
|
||||||
|
ui, shell = FakeUI(), FakeShell()
|
||||||
|
output = Tools(ui, mode="auto", run_shell=shell).handle("petctl emote wave")
|
||||||
|
assert shell.ran == []
|
||||||
|
assert output == tools_mod.PET_UNAVAILABLE.format(name="petctl")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialogue_scenes_are_explained_rather_than_run():
|
||||||
|
shell = FakeShell()
|
||||||
|
output = Tools(FakeUI(), mode="auto", run_shell=shell).handle('dialoguectl {"lines": []}')
|
||||||
|
assert shell.ran == []
|
||||||
|
assert "isn't available" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stray_command_label_is_stripped_and_reported():
|
||||||
|
"""Seen live: the server relayed `command: powershell -Command "..."` and
|
||||||
|
cmd.exe answered "'command:' is not recognized"."""
|
||||||
|
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||||
|
output = Tools(ui, run_shell=shell).handle('command: powershell -Command "Get-PnpDevice"')
|
||||||
|
assert shell.ran == ['powershell -Command "Get-PnpDevice"']
|
||||||
|
assert ui.calls[0] == ("Shell", 'powershell -Command "Get-PnpDevice"')
|
||||||
|
assert 'removed a leading "command:" label' in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrapping_backticks_are_stripped():
|
||||||
|
shell = FakeShell()
|
||||||
|
Tools(FakeUI(), mode="auto", run_shell=shell).handle("`git status`")
|
||||||
|
assert shell.ran == ["git status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_label_is_stripped_before_filectl_is_recognised(tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("hi", encoding="utf-8")
|
||||||
|
output = Tools(FakeUI()).handle(
|
||||||
|
"command: filectl " + json.dumps({"op": "read", "path": str(tmp_path / "a.txt")}))
|
||||||
|
assert "hi" in output and "command:" in output # read ran; note explains the fix
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clean_command_gets_no_note():
|
||||||
|
output = Tools(FakeUI(), mode="auto", run_shell=FakeShell()).handle("echo hi")
|
||||||
|
assert "note" not in output
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Streaming-TTS chunk reassembly and the near-miss log. Both are pure —
|
|
||||||
no network, no audio device, no ONNX model."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet.audio.tts import chunks_to_int16
|
|
||||||
from bolt_pet.audio.wake_word import NearMissLog
|
|
||||||
|
|
||||||
|
|
||||||
def _pcm(*values):
|
|
||||||
return np.array(values, dtype=np.int16).tobytes()
|
|
||||||
|
|
||||||
|
|
||||||
def test_whole_samples_pass_straight_through():
|
|
||||||
chunks = list(chunks_to_int16([_pcm(1, 2), _pcm(3, 4)]))
|
|
||||||
assert np.concatenate(chunks).tolist() == [1, 2, 3, 4]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_sample_split_across_two_http_chunks_is_rejoined():
|
|
||||||
# The killer bug this exists to prevent: an odd byte at a chunk boundary
|
|
||||||
# shifts everything after it by one byte and plays as static.
|
|
||||||
raw = _pcm(100, -200, 300, -400)
|
|
||||||
chunks = list(chunks_to_int16([raw[:3], raw[3:]]))
|
|
||||||
assert np.concatenate(chunks).tolist() == [100, -200, 300, -400]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_odd_boundaries_in_a_row():
|
|
||||||
raw = _pcm(*range(1, 21))
|
|
||||||
pieces = [raw[i:i + 3] for i in range(0, len(raw), 3)] # every boundary odd
|
|
||||||
assert np.concatenate(list(chunks_to_int16(pieces))).tolist() == list(range(1, 21))
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_chunks_are_skipped():
|
|
||||||
assert list(chunks_to_int16([b"", b""])) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_dangling_byte_at_the_end_is_dropped_not_played():
|
|
||||||
raw = _pcm(7, 8) + b"\x01"
|
|
||||||
assert np.concatenate(list(chunks_to_int16([raw]))).tolist() == [7, 8]
|
|
||||||
|
|
||||||
|
|
||||||
# ── wake-word near misses ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_scores_just_under_the_threshold_are_recorded():
|
|
||||||
log = NearMissLog(limit=10, margin=0.2)
|
|
||||||
assert log.observe(0.45, threshold=0.5, timestamp=1.0) is True
|
|
||||||
assert log.entries() == [(1.0, 0.45, 0.5)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_detections_and_background_noise_are_not_near_misses():
|
|
||||||
log = NearMissLog(limit=10, margin=0.2)
|
|
||||||
assert log.observe(0.90, threshold=0.5, timestamp=1.0) is False # it fired
|
|
||||||
assert log.observe(0.05, threshold=0.5, timestamp=2.0) is False # just noise
|
|
||||||
assert log.entries() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_peak_tracks_every_score_not_just_near_misses():
|
|
||||||
log = NearMissLog(limit=10, margin=0.2)
|
|
||||||
log.observe(0.30, threshold=0.5, timestamp=1.0)
|
|
||||||
log.observe(0.95, threshold=0.5, timestamp=2.0)
|
|
||||||
log.observe(0.10, threshold=0.5, timestamp=3.0)
|
|
||||||
assert log.peak == 0.95
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_log_is_bounded():
|
|
||||||
log = NearMissLog(limit=3, margin=0.2)
|
|
||||||
for i in range(10):
|
|
||||||
log.observe(0.45, threshold=0.5, timestamp=float(i))
|
|
||||||
assert len(log.entries()) == 3
|
|
||||||
assert log.entries()[-1][0] == 9.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_resets_peak_and_entries():
|
|
||||||
log = NearMissLog(limit=3, margin=0.2)
|
|
||||||
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
|
||||||
log.clear()
|
|
||||||
assert log.entries() == [] and log.peak == 0.0
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""Auto-updater: version comparison, release parsing, and the apply/rollback
|
|
||||||
dance driven by a fake git (no network, no real repo, nothing checked out)."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from bolt_pet import updater
|
|
||||||
|
|
||||||
|
|
||||||
# ── version comparison ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"tag,expected",
|
|
||||||
[
|
|
||||||
("v1.2.3", (1, 2, 3)),
|
|
||||||
("1.2.3", (1, 2, 3)),
|
|
||||||
("V0.1.0", (0, 1, 0)),
|
|
||||||
("1.2", (1, 2)),
|
|
||||||
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
|
|
||||||
("", ()),
|
|
||||||
("nightly", ()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_version(tag, expected):
|
|
||||||
assert updater.parse_version(tag) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"candidate,current",
|
|
||||||
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
|
|
||||||
)
|
|
||||||
def test_is_newer_accepts_newer_versions(candidate, current):
|
|
||||||
assert updater.is_newer(candidate, current)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"candidate,current",
|
|
||||||
[
|
|
||||||
("0.1.0", "0.1.0"),
|
|
||||||
("0.1.0", "0.2.0"),
|
|
||||||
("0.1", "0.1.0"), # zero-padded: equal, not newer
|
|
||||||
("", "0.1.0"),
|
|
||||||
("nightly", "0.1.0"), # unparseable is never newer
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_is_newer_rejects_same_or_older(candidate, current):
|
|
||||||
assert not updater.is_newer(candidate, current)
|
|
||||||
|
|
||||||
|
|
||||||
def test_release_parsing_skips_drafts_and_untagged():
|
|
||||||
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
|
|
||||||
assert updater.release_from_payload({"name": "no tag"}) is None
|
|
||||||
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
|
|
||||||
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── fake git ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class FakeGit:
|
|
||||||
"""Records every git invocation and answers from a canned script.
|
|
||||||
*failures* maps a leading-args tuple to the (code, output) it should
|
|
||||||
return, so a test can make exactly one command fail."""
|
|
||||||
|
|
||||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
|
||||||
head="abc1234", tag_commit="def5678"):
|
|
||||||
self.calls = []
|
|
||||||
self._ref = ref
|
|
||||||
self._dirty = dirty
|
|
||||||
self._failures = failures or {}
|
|
||||||
self._requirements_changed = requirements_changed
|
|
||||||
self._head = head
|
|
||||||
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
|
||||||
|
|
||||||
def __call__(self, args):
|
|
||||||
self.calls.append(list(args))
|
|
||||||
for prefix, result in self._failures.items():
|
|
||||||
if tuple(args[: len(prefix)]) == prefix:
|
|
||||||
return result
|
|
||||||
head = args[0]
|
|
||||||
if head == "rev-parse" and args[1] == "--git-dir":
|
|
||||||
return 0, ".git"
|
|
||||||
if head == "status":
|
|
||||||
return 0, " M bolt_pet/config.py" if self._dirty else ""
|
|
||||||
if head == "symbolic-ref":
|
|
||||||
return (0, self._ref) if self._ref else (1, "")
|
|
||||||
if head == "rev-parse":
|
|
||||||
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
|
||||||
if head == "diff":
|
|
||||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
|
||||||
return 0, ""
|
|
||||||
|
|
||||||
def commands(self):
|
|
||||||
"""Just the verbs, for asserting on the sequence."""
|
|
||||||
return [call[0] for call in self.calls]
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_update_checks_out_the_tag(tmp_path):
|
|
||||||
git = FakeGit()
|
|
||||||
previous = updater.apply_update(
|
|
||||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
|
|
||||||
)
|
|
||||||
assert previous == "main"
|
|
||||||
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
|
|
||||||
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
|
|
||||||
git = FakeGit(dirty=True)
|
|
||||||
with pytest.raises(updater.UpdateError, match="local changes"):
|
|
||||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
||||||
assert "fetch" not in git.commands()
|
|
||||||
assert "checkout" not in git.commands()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
|
|
||||||
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
|
|
||||||
with pytest.raises(updater.UpdateError, match="not a git clone"):
|
|
||||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
||||||
assert "checkout" not in git.commands()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
|
|
||||||
git = FakeGit(ref="main")
|
|
||||||
|
|
||||||
def broken(repo):
|
|
||||||
raise updater.UpdateError("the new version failed to import: boom")
|
|
||||||
|
|
||||||
with pytest.raises(updater.UpdateError, match="rolled back to main"):
|
|
||||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
|
|
||||||
|
|
||||||
checkouts = [call for call in git.calls if call[0] == "checkout"]
|
|
||||||
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
|
|
||||||
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
|
|
||||||
git = FakeGit(ref="")
|
|
||||||
|
|
||||||
with pytest.raises(updater.UpdateError):
|
|
||||||
updater.apply_update(
|
|
||||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
|
|
||||||
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
|
|
||||||
)
|
|
||||||
assert ["checkout", "--force", "abc1234"] in git.calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
|
||||||
git = FakeGit()
|
|
||||||
|
|
||||||
def exploding(repo):
|
|
||||||
raise ValueError("not even an UpdateError")
|
|
||||||
|
|
||||||
with pytest.raises(updater.UpdateError, match="rolled back"):
|
|
||||||
updater.apply_update(
|
|
||||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
|
|
||||||
)
|
|
||||||
assert ["checkout", "--force", "main"] in git.calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
|
||||||
"""Cut a release but forget to bump __version__ in the tagged commit and
|
|
||||||
every check would see the same "newer" tag: check out (a no-op), restart,
|
|
||||||
read the old version, repeat — a restart loop every check interval."""
|
|
||||||
git = FakeGit(head="same1234", tag_commit="same1234")
|
|
||||||
|
|
||||||
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
|
||||||
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
|
||||||
verify=lambda repo: None)
|
|
||||||
|
|
||||||
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
|
||||||
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
|
||||||
# The failure key matches by prefix, so make it explicit that a tag we
|
|
||||||
# can't resolve means "not there yet" rather than "already applied".
|
|
||||||
assert updater.already_at_tag(git, "v9.9.9") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
|
||||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
|
||||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
|
||||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
||||||
assert "checkout" not in git.commands()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
|
|
||||||
"""Rollback itself failing is the one case a human has to fix by hand."""
|
|
||||||
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
|
|
||||||
logs = []
|
|
||||||
|
|
||||||
with pytest.raises(updater.UpdateError):
|
|
||||||
updater.apply_update(
|
|
||||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
|
|
||||||
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
|
|
||||||
)
|
|
||||||
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
|
|
||||||
installs = []
|
|
||||||
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
|
|
||||||
|
|
||||||
updater.apply_update(
|
|
||||||
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
|
|
||||||
install_deps=True, verify=lambda repo: None,
|
|
||||||
)
|
|
||||||
assert installs == []
|
|
||||||
|
|
||||||
updater.apply_update(
|
|
||||||
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
|
|
||||||
install_deps=True, verify=lambda repo: None,
|
|
||||||
)
|
|
||||||
assert installs == [tmp_path]
|
|
||||||
|
|
||||||
|
|
||||||
# ── release checking ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_for_update_returns_nothing_when_current(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
updater, "fetch_latest_release",
|
|
||||||
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
|
|
||||||
)
|
|
||||||
assert updater.check_for_update(current_version="0.1.0") is None
|
|
||||||
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_releases_yet_is_not_an_error(monkeypatch):
|
|
||||||
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
|
|
||||||
assert updater.check_for_update(current_version="0.1.0") is None
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bolt_pet.audio import wake_word
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeStream:
|
|
||||||
"""Yields a fixed sequence of frames, then silence forever."""
|
|
||||||
|
|
||||||
def __init__(self, frames, frame_len):
|
|
||||||
self._frames = list(frames)
|
|
||||||
self._frame_len = frame_len
|
|
||||||
|
|
||||||
def read(self, frames):
|
|
||||||
if self._frames:
|
|
||||||
frame = self._frames.pop(0)
|
|
||||||
else:
|
|
||||||
frame = np.zeros(self._frame_len, dtype=np.int16)
|
|
||||||
return frame.reshape(-1, 1), False
|
|
||||||
|
|
||||||
|
|
||||||
def _frame(frame_len):
|
|
||||||
return np.zeros(frame_len, dtype=np.int16)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeModel:
|
|
||||||
"""Reports the given score sequence (one dict per predict() call, then
|
|
||||||
repeats the last entry) and records reset() calls."""
|
|
||||||
|
|
||||||
def __init__(self, score_sequence):
|
|
||||||
self._scores = list(score_sequence)
|
|
||||||
self.reset_calls = 0
|
|
||||||
|
|
||||||
def predict(self, frame):
|
|
||||||
if self._scores:
|
|
||||||
return self._scores.pop(0)
|
|
||||||
return {"thunderbolt": 0.0}
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.reset_calls += 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_true_and_resets_on_detection(monkeypatch):
|
|
||||||
frame_len = 1280
|
|
||||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
|
||||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
|
||||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame
|
|
||||||
monkeypatch.setattr(wake_word.config, "WAKE_WORD_THRESHOLD", 0.5)
|
|
||||||
|
|
||||||
stream = _FakeStream([_frame(frame_len)] * 3, frame_len)
|
|
||||||
model = _FakeModel([{"thunderbolt": 0.1}, {"thunderbolt": 0.9}])
|
|
||||||
|
|
||||||
detected = wake_word.listen_for_wake_word(stream, model=model)
|
|
||||||
|
|
||||||
assert detected is True
|
|
||||||
assert model.reset_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_false_when_should_continue_goes_false_first():
|
|
||||||
frame_len = 1280
|
|
||||||
stream = _FakeStream([_frame(frame_len)] * 5, frame_len)
|
|
||||||
model = _FakeModel([{"thunderbolt": 0.0}] * 5)
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
def should_continue():
|
|
||||||
calls["n"] += 1
|
|
||||||
return calls["n"] <= 3
|
|
||||||
|
|
||||||
detected = wake_word.listen_for_wake_word(
|
|
||||||
stream, should_continue=should_continue, model=model,
|
|
||||||
)
|
|
||||||
assert detected is False
|
|
||||||
assert model.reset_calls == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_threshold_is_respected(monkeypatch):
|
|
||||||
frame_len = 1280
|
|
||||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
|
||||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
|
||||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08)
|
|
||||||
|
|
||||||
stream = _FakeStream([_frame(frame_len)] * 3, frame_len)
|
|
||||||
model = _FakeModel([{"thunderbolt": 0.6}] * 3)
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
def should_continue():
|
|
||||||
calls["n"] += 1
|
|
||||||
return calls["n"] <= 3
|
|
||||||
|
|
||||||
detected = wake_word.listen_for_wake_word(
|
|
||||||
stream, should_continue=should_continue, model=model, threshold=0.7,
|
|
||||||
)
|
|
||||||
assert detected is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_tick_fires_once_per_check_interval(monkeypatch):
|
|
||||||
frame_len = 1280
|
|
||||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
|
||||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
|
||||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame/check
|
|
||||||
|
|
||||||
frames = [_frame(frame_len) for _ in range(5)]
|
|
||||||
stream = _FakeStream(frames, frame_len)
|
|
||||||
model = _FakeModel([{"thunderbolt": 0.0}] * 5)
|
|
||||||
ticks = {"n": 0}
|
|
||||||
state = {"i": 0}
|
|
||||||
|
|
||||||
def should_continue():
|
|
||||||
state["i"] += 1
|
|
||||||
return state["i"] <= 5
|
|
||||||
|
|
||||||
wake_word.listen_for_wake_word(
|
|
||||||
stream,
|
|
||||||
should_continue=should_continue,
|
|
||||||
model=model,
|
|
||||||
on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1),
|
|
||||||
)
|
|
||||||
assert ticks["n"] == 5 # one check-interval per frame at this config
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_tick_interval_can_span_multiple_frames(monkeypatch):
|
|
||||||
frame_len = 1280
|
|
||||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
|
||||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
|
||||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.24) # 3 frames/check
|
|
||||||
|
|
||||||
frames = [_frame(frame_len) for _ in range(6)]
|
|
||||||
stream = _FakeStream(frames, frame_len)
|
|
||||||
model = _FakeModel([{"thunderbolt": 0.0}] * 6)
|
|
||||||
ticks = {"n": 0}
|
|
||||||
state = {"i": 0}
|
|
||||||
|
|
||||||
def should_continue():
|
|
||||||
state["i"] += 1
|
|
||||||
return state["i"] <= 6
|
|
||||||
|
|
||||||
wake_word.listen_for_wake_word(
|
|
||||||
stream,
|
|
||||||
should_continue=should_continue,
|
|
||||||
model=model,
|
|
||||||
on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1),
|
|
||||||
)
|
|
||||||
assert ticks["n"] == 2 # 6 frames / 3 frames-per-check
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Autonomous wandering. Needs a QApplication, so run with
|
|
||||||
QT_QPA_PLATFORM=offscreen (same as test_controller.py)."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bolt_pet import config
|
|
||||||
from bolt_pet.state import PetState
|
|
||||||
from bolt_pet.ui.pet_window import PetWindow
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def qt_app():
|
|
||||||
app = QApplication.instance() or QApplication([])
|
|
||||||
yield app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pet(qt_app):
|
|
||||||
window = PetWindow()
|
|
||||||
window.set_wander_enabled(True)
|
|
||||||
yield window
|
|
||||||
window.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _walk_until_done(pet, max_ticks=2000):
|
|
||||||
for _ in range(max_ticks):
|
|
||||||
pet._wander_tick()
|
|
||||||
if pet._wander_target is None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_wanders_when_idle_and_reaches_its_target(pet, monkeypatch):
|
|
||||||
# Snapping is tested separately; here it would tug the pet off its target
|
|
||||||
# the moment it arrives near an edge.
|
|
||||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
|
|
||||||
pet.wander_now()
|
|
||||||
pet._wander_tick()
|
|
||||||
target = pet._wander_target
|
|
||||||
if target is None:
|
|
||||||
pytest.skip("no usable screen geometry for a stroll on this host")
|
|
||||||
assert _walk_until_done(pet), "pet never reached its wander target"
|
|
||||||
assert pet.pos() == target
|
|
||||||
|
|
||||||
|
|
||||||
def test_stays_put_while_talking(pet):
|
|
||||||
pet.set_state(PetState.TALKING)
|
|
||||||
start = pet.pos()
|
|
||||||
pet.wander_now()
|
|
||||||
for _ in range(50):
|
|
||||||
pet._wander_tick()
|
|
||||||
assert pet.pos() == start
|
|
||||||
assert pet._wander_target is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_stays_put_while_bubble_is_up(pet):
|
|
||||||
pet.say("hello there", duration_ms=60000)
|
|
||||||
start = pet.pos()
|
|
||||||
pet.wander_now()
|
|
||||||
for _ in range(50):
|
|
||||||
pet._wander_tick()
|
|
||||||
assert pet.pos() == start
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabling_wander_stops_movement(pet):
|
|
||||||
pet.set_wander_enabled(False)
|
|
||||||
start = pet.pos()
|
|
||||||
pet.wander_now()
|
|
||||||
for _ in range(50):
|
|
||||||
pet._wander_tick()
|
|
||||||
assert pet.pos() == start
|
|
||||||
|
|
||||||
|
|
||||||
def test_stroll_stays_on_screen(pet):
|
|
||||||
geo = pet._screen_geometry()
|
|
||||||
if geo is None:
|
|
||||||
pytest.skip("no usable screen geometry on this host")
|
|
||||||
for _ in range(10):
|
|
||||||
pet.wander_now()
|
|
||||||
pet._wander_tick()
|
|
||||||
assert _walk_until_done(pet)
|
|
||||||
assert geo.contains(pet.geometry())
|
|
||||||