12 Commits
Author SHA1 Message Date
themajesticmagicianandClaude Opus 5.5 3977804ae3 Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:

- server_client.list_chats() / create_chat(), same shape and error
  handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
  SESSION_ID every call reads, persisted to session.json. The window
  refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
  populated on open, with Refresh and New Chat. Network calls run as
  QRunnables on the global QThreadPool and come back through Signals,
  never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
  past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
  fallback).

This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.

Build output (build/, dist/, Release/, *.spec) is now ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:50:10 -06:00
themajesticmagician 54030a7b31 Add microphone picker and device management features
- Implement `list_input_devices` to retrieve and filter input-capable audio devices.
- Create `MicPickerDialog` for selecting and saving microphone settings.
- Add `set_env_value` function to update .env file with selected microphone index.
- Update UI components to integrate microphone selection functionality.
- Enhance tests for microphone device handling and environment variable management.
2026-09-24 23:31:18 -06:00
themajesticmagicianandClaude Sonnet 5 ad9925da02 Add a --deep probe to check_tts so a live TTS failure isn't invisible
Reported: the pet was speaking in the offline pyttsx3 voice instead of the
server-synthesized one. speak()'s fallback chain is silent by design (a
TtsError just logs and falls through to pyttsx3), and the shallow doctor
check only ever verified config presence, not that a real /desk/tts call
actually succeeds — so "misconfigured" and "configured but the server
rejects it" looked identical from the outside.

check_tts(deep=True) now posts a couple of words to /desk/tts for real and
reports the exact HTTP status and server error message (a 404 means the
voice id isn't real or isn't owned by this key - the likely shape of a
Voicebox-clone ownership mismatch; 402 means the account is out of
credits; 401 means DESK_API_KEY is wrong). Wired through the same
CHECKS/run() deep-parameter dance check_streaming_endpoint already uses.

5 new tests, including one pinning that run()'s TypeError-fallback
actually reaches check_tts's deep parameter rather than silently running
it shallow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CBvympe6SuHdhf9q1VQqS
2026-09-13 16:35:01 -06:00
themajesticmagician 2a2cf38399 Update desktop app to android app capabilities. 2026-09-13 16:23:52 -06:00
themajesticmagicianandClaude Opus 5 3a0959f55d Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON
endpoint, so the wait is time-to-first-sentence rather than the whole model
call, and Deepgram's live websocket transcribes while you're still talking
instead of uploading the WAV afterwards. Both fall back invisibly — a stream
that fails before anything was said drops to converse(), and a socket that
never opens just means the old one-shot path.

Speaking lived in four near-copies in the controller (a reply, a holding line,
a streamed sentence, a dialogue scene) that had already drifted: one didn't arm
barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an
Utterance describing the policy differences, with collaborators injected so the
whole of it tests without Qt or audio.

The mouth follows the audio rather than a timer: tts.level_of reduces each PCM
frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a
linear map leaves the mouth barely open during normal talking) and that indexes
the talking frames, which the sprite script now draws as an openness ramp.
Offline pyttsx3 has no waveform, so stale levels hand control back to the timed
loop instead of freezing the mouth mid-syllable.

Also: the pet starts where you left it (ignoring positions on monitors that are
no longer connected, since restoring those faithfully is how it ends up
somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that
says what to do about each problem rather than only what's wrong.

tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit
test passed all week while notifications sat unspoken for minutes, the pet said
things twice and [laughing] got read aloud — each an interaction between two
individually-correct units. It drives whole turns against a real HTTP server on
a loopback port, faking only the mic and the speakers. It found a NameError in
the paint path that would have fired on every repaint while talking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:01:06 -06:00
themajesticmagician c4e805defd Add relay_json module, update dialogue and file_ops, update local settings 2026-07-31 01:27:20 -06:00
themajesticmagician 96afc351ac Add text-to-dialogue, self-restart capability, and misc updates 2026-07-30 20:50:41 -06:00
themajesticmagicianandClaude Opus 5 5b49670983 v0.2.3
Multi-monitor jumps (`petctl jump`/`monitors`), pull-only screen OCR
(`petctl read`), generated sprite art with a distance-stepped walk cycle,
plus the filectl file ops and server file delivery merged back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:20:46 -06:00
themajesticmagician 311fe8b709 Merge remote-tracking branch 'origin/main' into screens
# Conflicts:
#	.claude/settings.local.json
#	CLAUDE.md
#	bolt_pet/controller.py
2026-07-28 16:19:45 -06:00
themajesticmagicianandClaude Opus 5 b121bbba17 Multi-monitor jumps, screen OCR, and generated sprite art
petctl gains screen verbs: `jump` (1-based number, name, next/prev/
primary/other, or a direction resolved from real geometry), `monitors`,
and `read` for OCR of a monitor's contents.

- monitors.py: pure layout model + jump-target resolution. The monitor
  list is published by PetWindow from QGuiApplication.screens() over a
  queued signal, so the controller and window agree on what "monitor 2"
  means; xrandr and Qt order screens differently on the same machine.
- screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR).
  Nothing captures unless the server asks, and the text rides back up
  the tool-result relay so Bolt can read a screen mid-turn. Both deps
  optional, soft-failing with a reason. SCREEN_TEXT=false removes it.
- Query verbs are answered in controller._handle_command rather than
  pet_actions.describe(), because their output is the point.
- scripts/generate_bolt_sprites.py draws every frame; walk/ is a
  side-view cycle stepped by distance travelled, not by the animation
  timer, so the planted paw tracks the window exactly. sprite.py loads
  it via EXTRA_ANIMATIONS keyed by name, with has() so callers can
  decline a placeholder blob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:17:40 -06:00
themajesticmagician ccb3aeb7ae v0.2.2 2026-07-26 19:10:14 -06:00
themajesticmagician c16fada8d8 Update 2026-07-26 18:49:51 -06:00
94 changed files with 6442 additions and 7230 deletions
+60 -1
View File
@@ -30,7 +30,66 @@
"Bash(git push *)",
"Bash(git remote *)",
"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__)"
]
}
}
+25 -149
View File
@@ -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) ──────────────────────────────────────────────────
# The same server URL + key desk_client/bolt_desk.py and the Android app use.
# 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
# Your Bolt server and its desk API key (required).
BOLT_SERVER_URL=https://your-bolt-server.example.com
DESK_API_KEY=
# Unique per machine/install so sessions don't collide. Defaults to
# "pet-<hostname>" if unset.
#DESK_SESSION_ID=pet-my-desktop
# Conversation id on the server. Default: pet-console-<hostname>. Keep the
# "pet-" prefix: the server offers the filectl file tool to pet sessions.
#DESK_SESSION_ID=
# ── Wake word ────────────────────────────────────────────────────────────────
# openWakeWord model trained for "thunderbolt" — thunderbolt.onnx ships in
# the project root next to this file. Point WAKE_MODEL_FILE at a different
# .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
# ask = confirm shell commands, file writes and edits (reads never ask)
# auto = run everything without asking
#APPROVAL_MODE=ask
# ── STT (Deepgram) ──────────────────────────────────────────────────────────
DEEPGRAM_API_KEY=
#DEEPGRAM_MODEL=nova-3
# Seconds before a relayed shell command is killed.
#COMMAND_TIMEOUT_SECONDS=120
# ── TTS (ElevenLabs) — omit to use offline TTS only ─────────────────────────
ELEVENLABS_API_KEY=
ELEVENLABS_VOICE_ID=
#ELEVENLABS_MODEL_ID=eleven_flash_v2
#TTS_SAMPLE_RATE=24000
# Print sentences as they stream in (falls back automatically if unsupported).
#STREAMING_REPLIES=true
# ── 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=
#SPEAKER_DEVICE=
#DICTATION_SILENCE_SECONDS=3
# ── VAD tuning (optional) ───────────────────────────────────────────────────
#VAD_RMS_THRESHOLD=300
#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
# ANSI colour (NO_COLOR in the environment also turns it off).
#COLOR=true
+6
View File
@@ -5,3 +5,9 @@ __pycache__/
.pytest_cache/
.claude
CLAUDE.md
# PyInstaller output (build_bolt_console.py) and packaged releases
build/
dist/
Release/
*.spec
+99 -247
View File
@@ -4,267 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
A desktop pet (PySide6 window) that is a voice/click UI on top of an external
Bolt server's desk API — same brain, memory, tools, and persona as that
server's Discord bot and Linux desk client. This repo has **no import
dependency** on the server repo; it's a standalone HTTP client configured via
its own `.env`.
A PySide6 desktop app (default) and a terminal REPL (`--cli`), both Claude Code-style, on top of an
external Bolt server's desk API. It uses the same
brain, memory, tools and persona as the server's Discord bot and its other desk clients. There is
**no import dependency** on the server repo: it's a standalone HTTP client configured through its
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 /
click → record utterance → Deepgram STT → + active-window context → POST
/desk/converse → [server may relay a shell command to run on this machine, or
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).
Pipeline: `typed line → + cwd/OS note → POST /desk/converse_stream (or /desk/converse) → [server
relays a command: filectl file op, or a shell command, after y/n approval] → /desk/tool_result →
… → reply printed`.
## Commands
```bash
# Setup + run (creates .venv and installs requirements.txt on first run)
./run.sh # macOS/Linux
run.bat # Windows
# 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
run.bat / run-cli.bat / ./run.sh [--cli] # window / terminal; creates .venv; keeps the caller's cwd
python -m bolt_console --doctor [--deep]
python -m bolt_console -p "message" # one-shot
.venv/Scripts/python -m pytest tests # (.venv/bin on Linux/macOS)
```
There is no lint/build step configured beyond pytest. `cp .env.example .env`
and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
`ELEVENLABS_API_KEY`) before running — without server config the controller
logs a missing-config message and exits its thread instead of starting.
Runtime dependencies: `requests`, plus `PySide6` for the window only. `bolt_console.gui` is imported
lazily, so `--cli` works without Qt. The local venv is Python 3.9, so no `match`, no
runtime `X | Y` unions, and no builtin generics outside annotations.
## Architecture
## Architecture (`bolt_console/`)
- **`config.py`** — loads `.env` from the project root (not via python-dotenv;
a small hand-rolled parser matching the server repo's `desk_client/bolt_desk.py`
convention) into module-level constants. Everything else reads config from
here, never `os.environ` directly.
- **`state.py`** — `PetStateMachine`, pure logic with no Qt/audio imports (kept
that way deliberately for cheap unit testing). Enforces a transition table;
notably `IDLE -> TALKING` is legal directly (no LISTENING/THINKING leg)
because the heartbeat can make the pet speak proactively/unprompted.
- **`controller.py`** — `PetController(QObject)`, the pipeline orchestrator.
Runs on a background `QThread` (wired in `ui/app.py`) so audio I/O/network
never blocks the Qt event loop; communicates with the UI only through Qt
signals (`state_changed`, `said`, `log`, `action`, `napping`), never touches
a `QWidget` directly. Also drives the periodic heartbeat
(`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the
server push proactive spoken announcements between user turns, and on the
same tick re-evaluates nap state and drains queued desktop notifications.
It owns the live wake-word threshold (`wake_threshold()` is passed to
`listen_for_wake_word` as a *callable* so the tray slider takes effect
mid-listen) and the conversation `history`.
- **`server_client.py`** — HTTP client for the desk API, dependency-free
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
server-issued shell commands (`run_local_command`, executed via
`subprocess.run(shell=True)` as the desktop user, 30s default timeout) via
`/desk/tool_result` until the server sends a final `reply` (capped at
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
the server repo's other desk clients — commands only ever originate from
the user's own voice/click requests in their own session.
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx`
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
carries odd bytes across HTTP chunk boundaries, without which everything
after the first split sample plays as static — falling back to whole-clip
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
**wake** (default) scores every frame with the same openWakeWord model the
idle listener uses, so only the wake phrase cuts playback; **energy** is the
original N-consecutive-loud-frames rule, threshold ~4x the VAD one because
the mic hears the pet's own voice. Wake mode shares `_default_model` with
the idle listener — the two never run concurrently — and `reset()`s it on
detection so the tail of one reply can't count toward the next). Each
accepts an injectable stream/model/protocol so tests don't need real audio
hardware or a display.
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the
pet" payload type and this repo can't change the server, so these ride the
existing shell-command relay: `controller._handle_command` parses them and
they never reach `subprocess`; anything else is a real shell command exactly
as before. Pure parsing; the UI half is `PetWindow.apply_action`.
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
osascript) appended to each utterance via `context_for()`, plus
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
no images. Every probe is best-effort and returns None/False rather than
raising; the parsing is split into pure functions that are tested without a
display server.
- **`quiet.py`** — quiet-hours spec parsing (`23:00-08:00`, wraps midnight,
comma-separated). Napping suppresses *proactive* noise and wandering only;
wake word / click / push-to-talk still work.
- **`notifications.py`** — Linux/D-Bus notification bridge: tails
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
and rate-limits them (`NotificationGate`), and the controller forwards
survivors through `converse()`. Off by default — each one is a round trip.
- **`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.
- **`config.py`**: a small hand-written `.env` parser feeding module constants. Nothing else reads
`os.environ`. `SESSION_ID` defaults to `pet-console-<host>`. The `pet-` prefix is deliberate: the
server advertises `filectl` to pet sessions, and `-console` keeps this a separate conversation
from a Bolt-Pet on the same machine. `new_session()` (New chat / `/new`) rotates to
`<base>-<timestamp>` and remembers it in `~/.cache/bolt-console/session.json`. `DESK_SESSION_ID`
in `.env` overrides the remembered id. Tests must monkeypatch `config.STATE_FILE`.
- **`server_client.py`**: the desk protocol. `_relay()` is the shared command → tool_result loop.
`converse_stream()` hands each `say` sentence over as it arrives and finishes a tool call through
`_relay`. It raises `ServerError` only if nothing was shown yet, which lets the caller fall back to
`converse()` invisibly. `Reply.streamed` means "already on screen, don't print again".
`run_local_command` runs in `os.getcwd()` with stdin closed and `CREATE_NO_WINDOW`, because under
pythonw every cmd.exe would otherwise flash a console.
- **`tools.py`**: `Tools`, the `on_command` callable. It routes `filectl` to `file_ops`, answers
`petctl`/`dialoguectl` with `PET_UNAVAILABLE` (the server may still emit them), and treats
anything else as a shell command. Approval: reads and lists never ask. Shell, write and edit ask
via `ui.confirm` unless `mode == "auto"`, and "always" switches the session to auto. A decline
returns `DECLINED` to the model rather than raising, so the turn still completes.
- **`file_ops.py`**: `filectl {json}` list/read/write/edit. It is a **single-line** JSON wire format
because the server's `command` marker only captures to the first newline. `edit` must match
exactly once. Parsing (`parse`) is separate from I/O (`execute`).
- **`relay_json.py`**: lenient JSON for model-typed payloads. It tries strict parsing first, then
named repairs (fences, smart quotes, invalid backslash escapes, stray quotes, trailing commas,
Python literals, single quotes), and accepts a repair only if the result parses. Repairs are
always reported back to the model, never silent. The backslash repair only touches *invalid*
escapes, so `C:\temp` still parses as a tab (that's ambiguous, so it's left alone).
- **`ui.py`**: `Terminal` handles plain ANSI rendering, the spinner thread, tool-call/result lines,
diffs, `confirm` and `read_message`. All output goes through `_emit` under the same lock the
spinner draws under. The spinner redraws by erasing the current line, so output is always written
as whole lines (see `assistant_chunk`). Glyphs fall back to ASCII on non-UTF-8 streams.
- **`console.py`**: `Session` (one turn, delivered-file download, slash commands, `!` local shell,
the loop) and `run()` (argv). Collaborators are injected so tests can drive it.
- **`gui/`**: the window. `bridge.Bridge` implements the same surface as `ui.Terminal`
(tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the
window reuses `console.Session` and `Tools` unchanged. `window.TurnRunner` runs `Session.send` on a
QThread. `Bridge.confirm` blocks the worker on an Event until a ToolCard's Allow/Deny calls
`answer()`, and `close()` releases it as "no". `window.MainWindow` reacts only to Bridge signals.
`widgets.py` holds the transcript pieces (ToolCard, AssistantMessage, Composer, …). `theme.py` holds
the colours and the one stylesheet (widgets set object names and dynamic properties for it, then
`repolish()`). `icons.py` draws every icon with QPainter, so there are no asset files.
`markdown_html` swaps Qt's fixed font for code (Courier New on Windows) with the app's mono font.
- **`dictation.py`**: the mic button. It streams 16 kHz int16 to the server's `/desk/stt`
websocket (Bolt-Pet's route: the server relays to Deepgram) and buffers every frame. If the live
socket never opened or died, `_fallback()` sends the whole buffer once from an empty transcript,
so a half-live session's words don't come back twice. A healthy socket is never re-sent, even
when it heard nothing, because that would pay twice. Stops on click, on `silence_seconds` after
speech, or on `max_seconds`. No Qt and no numpy (the array module does RMS), so it's identical
in the Linux single file. The mic and socket are injected. The window turns the callbacks into
`DictationSignals`, and the composer is read-only while a recording rewrites it. PySide6 6.10
needs `QTextCursor.MoveOperation.End`, not `cursor.End`.
- **Chats**: `server_client.list_chats()`/`create_chat()` wrap `GET`/`POST /desk/chats` (the server
mints `chat-…` ids; a chat is only listed once used). Switching is `config.set_session(id)`: a
plain reassignment of the `SESSION_ID` every call reads, persisted to `session.json`, and
deliberately not locked. The window refuses to switch mid-turn instead. `gui/chat_list_window.py`
runs list/create as `ChatTask`s on `QThreadPool.globalInstance()` and hands results back through
`ChatSignals`, never calling the network from a slot. The header New chat uses the same pool.
`Session.new_conversation()` (terminal `/new`) is blocking and falls back to a local id if the
server can't mint one. `chats.py` holds the Qt-free row helpers (the preview drops the
`[Bolt Console: …]` and `[on screen right now: …]` notes).
- **`file_delivery.py`**: saves files the server queues via `deliver_files`. The name is reduced to
its basename and never overwrites an existing file. `Session.check_deliveries` runs after every
turn.
- **`doctor.py`**: independent checks, each reporting ok/warn/fail plus what to do about it. A check
never raises.
### Wake-word detection
## Testing conventions
`audio/wake_word.py` uses a custom-trained openWakeWord model,
`thunderbolt.onnx` (ships in the project root), the same way the server
repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey bolt" — same
runtime (openWakeWord, ONNX inference framework), same per-frame
`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.
`tests/test_gui.py` sets `QT_QPA_PLATFORM=offscreen` itself and drives real turns through
`MainWindow` and its worker thread against the loopback server (`pump_until` processes events).
Offscreen Qt has **no font database**, so check the look by rendering with the real platform plus
`WA_DontShowOnScreen` and `grab()`, not offscreen.
**openwakeword's `Model.reset()` is not enough to forget a detection.** It
clears the *prediction* buffer only; the rolling audio window the classifier
actually scores lives in `model.preprocessor` (`raw_data_buffer` — 10s of raw
audio — plus `melspectrogram_buffer` and a ~120-frame `feature_buffer`) and
`AudioFeatures` has no reset method at all. So after a detection the wake
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.
Tests cover logic with injected fakes (`FakeUI`, `FakeShell`, a `Terminal` writing to `StringIO`
with a scripted `input_fn`). `tests/test_console.py` is the end-to-end file: a real threaded HTTP
server on a loopback port driven through the real `server_client`, `Tools` and `Terminal`. Add a
case there when a bug crosses module boundaries. Tests that embed a path in JSON must use
`json.dumps(str(path))`, because Windows backslashes are otherwise invalid escapes.
The threshold is tunable at runtime: the tray's **Wake word tuning…** window
(`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`.
## Fabricated tool results
### Testing conventions
`tests/` covers pure logic only (state machine, wake-word scoring loop, mic
VAD, HTTP client against mocks) — nothing there needs real audio hardware or
a display. Modules under test are written to accept fake streams/models/
`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.
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
this against the live server, and spelling out `command: ...` fixed it. `console.fabricated()`
flags a reply shaped like tool output when `Tools.calls` didn't move, and the next message carries
`CORRECTION`. A polluted session keeps copying its own history, so the remedy is New chat.
## Security notes
The server can relay a shell command back to this machine to execute as the
desktop user (see `server_client.run_local_command`). This is intentional
("full desktop control" for things like "open firefox" or disk checks) and
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`.
Approved shell commands, writes and edits run as the user with no sandbox. `auto` mode removes the
prompt. That is the same "full desktop control" trust model as the server's other desk clients,
which is why `ask` is the default here. Keep `DESK_API_KEY` private.
+108 -161
View File
@@ -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
of your Bolt server — same brain, memory, tools, and persona as Discord
chat and the Linux desk client. It talks to `ai/desk_api.py` on the server
exactly the way `desk_client/bolt_desk.py` does; this project only adds the
on-screen pet and swaps Deepgram/ElevenLabs playback to be cross-platform
(no `mpv`/`ffplay`/`espeak-ng` subprocess calls — pure `sounddevice`).
A desktop app for Bolt, in the style of Claude Code (with a terminal version too). You type, Bolt
answers, and when he needs to look at or change something on this machine you see each step as it
happens: reading files, listing folders, editing, writing, running commands. Changes wait for your
**Allow** on the step itself, and edits show a diff first.
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
→ Deepgram STT (+ the focused window's title, for "what's this error?")
→ POST /desk/converse on your Bolt server → [server may relay a shell
command back to run on THIS machine, or a `petctl` command that moves
or emotes the pet] → reply → ElevenLabs streaming TTS → speakers
→ shown in a speech bubble + the pet's sprite state (idle/listening/
thinking/talking) updates the whole time
> what's taking up space in this repo?
● List(C:\work\site, recursive)
⎿ assets/
assets/hero.mp4 48211332B
... +212 lines
● 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
1. Copy this whole `bolt-pet/` folder to the machine you want the pet to run
on (if that isn't already this machine).
2. `cp .env.example .env` and fill in:
- `BOLT_SERVER_URL` + `DESK_API_KEY` — same as `desk_client/.env` on the
server side. Use the server's master `DESK_API_KEY`, or mint yourself a
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
```bash
cp .env.example .env # set BOLT_SERVER_URL and DESK_API_KEY
run.bat # Windows: the window
run-cli.bat # Windows: the terminal version
./run.sh [--cli] # macOS/Linux
```
## 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
back to this machine ("full desktop control" — "open firefox", "how full is
my disk", etc.), which this client executes as your desktop user with a
30-second timeout (`COMMAND_TIMEOUT_SECONDS`). That's the same trust model
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.
```bash
run.bat --auto # don't ask before commands, writes and edits
run-cli.bat -p "summarise README.md" # one message, print the reply, exit
run-cli.bat --doctor [--deep] # check the install (--deep contacts the server)
```
`petctl` commands (move/emote/say/wander/nap) are handled inside the pet and
never reach a shell, so that channel can't run anything.
## Using the terminal version
Two features widen what leaves this machine, both off-switchable in `.env`:
`SCREEN_CONTEXT=true` (default) appends the focused window's *title* to what
you say — titles often contain file paths, document names or email subjects —
and `NOTIFICATION_BRIDGE=false` (default) can forward matching desktop
notifications. No screenshots or images are ever sent.
| Input | What it does |
|---|---|
| any text | sent to Bolt |
| `!<command>` | runs a shell command yourself. Nothing is sent to Bolt |
| `/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.
- Wandering is a straight walk to a random point — no Shimeji-style physics,
wall-climbing or falling.
- Push-to-talk and the notification bridge are platform-limited: the hotkey
needs a session that allows global key hooks (most Wayland setups don't),
and the notification bridge is Linux/D-Bus only.
- Barge-in listens through the same mic that hears the pet's own voice. It
wants headphones or a decent gap between speaker and mic; if playback
interrupts itself, raise `BARGE_IN_RMS_THRESHOLD` or set `BARGE_IN=false`.
- Screen context is the window *title* only — the desk API takes text, so
there's no screenshot understanding.
## How Bolt reaches this machine
The desk API has one tool channel: the server sends back a `command` string, the client runs it and
posts the output to `/desk/tool_result`, and this repeats until a final reply. The console decides
what each string means (`bolt_console/tools.py`):
- **`filectl {json}`**: list, read, write or edit a file (`file_ops.py`). This is what makes file
editing reliable, because the model never has to hand-quote a shell heredoc. Edits must match
exactly once. Common JSON slips, such as a raw `C:\Users\...` path, are repaired and the model is
told about the repair.
- **`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.
+3
View File
@@ -0,0 +1,3 @@
"""Bolt Console — a terminal client for Bolt's desk API."""
__version__ = "0.1.0"
+32
View File
@@ -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())
+53
View File
@@ -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 "")
+162
View File
@@ -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
+398
View File
@@ -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()
+339
View File
@@ -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
+163
View File
@@ -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
+54
View File
@@ -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
+287
View File
@@ -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}"
+2
View File
@@ -0,0 +1,2 @@
"""PySide6 front end. Imported only when the window is wanted, so the
terminal client works without Qt installed."""
+106
View File
@@ -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")
+235
View File
@@ -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)
+219
View File
@@ -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)
+196
View File
@@ -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
+699
View File
@@ -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 "&nbsp;"}</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}")
+528
View File
@@ -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()
+157
View File
@@ -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.)"
)
+270
View File
@@ -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
+201
View File
@@ -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)
+257
View File
@@ -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)
-7
View File
@@ -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"
-8
View File
@@ -1,8 +0,0 @@
"""Entry point: python -m bolt_pet"""
import sys
from .ui.app import run
if __name__ == "__main__":
sys.exit(run())
-45
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File
-190
View File
@@ -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)
-118
View File
@@ -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,
)
-42
View File
@@ -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
-163
View File
@@ -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
-259
View File
@@ -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
-252
View File
@@ -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
-481
View File
@@ -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)
-62
View File
@@ -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)
-105
View File
@@ -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
-166
View File
@@ -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
-137
View File
@@ -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"
-78
View File
@@ -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)
-184
View File
@@ -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())
-138
View File
@@ -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
-146
View File
@@ -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()
-71
View File
@@ -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)
-145
View File
@@ -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
View File
-124
View File
@@ -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
-83
View File
@@ -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)
-582
View File
@@ -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
-111
View File
@@ -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]
-133
View File
@@ -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")
-118
View File
@@ -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)
-323
View File
@@ -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"])
+12
View File
@@ -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())
+8 -32
View File
@@ -1,37 +1,13 @@
# Desktop UI
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
# HTTP client to the Bolt desk API.
requests>=2.31
# Wake-word detection (local, offline after first run) — runs the
# custom-trained thunderbolt.onnx model shipped in this repo, same runtime
# 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
# The window (python -m bolt_console). Not needed for --cli.
PySide6>=6.6
# Offline TTS fallback if ElevenLabs isn't configured or a request fails.
# Uses SAPI5 on Windows, NSSpeechSynthesizer on macOS, espeak on Linux
# (Linux also needs: sudo apt install espeak-ng).
pyttsx3>=2.90
# Dictation (the mic button): mic capture + the server's /desk/stt websocket.
# Optional: without them the mic button explains what's missing.
sounddevice>=0.4.6
websocket-client>=1.7
# Global push-to-talk hotkey (PUSH_TO_TALK_HOTKEY). Optional: the pet
# 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).
# Test runner (tests/ — no real server or terminal needed).
pytest>=8.0
+14
View File
@@ -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 %*
+12 -7
View File
@@ -1,11 +1,16 @@
@echo off
REM Convenience launcher for Windows.
cd /d "%~dp0"
REM Opens the Bolt window (no console left behind). For the terminal version
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 (
python -m venv .venv
.venv\Scripts\pip install --upgrade pip
.venv\Scripts\pip install -r requirements.txt
if not exist "%ROOT%.venv" (
echo First run: creating .venv and installing requirements...
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"
)
.venv\Scripts\python -m bolt_pet
set "PYTHONPATH=%ROOT%;%PYTHONPATH%"
start "" "%ROOT%.venv\Scripts\pythonw.exe" -m bolt_console %*
+10 -7
View File
@@ -1,12 +1,15 @@
#!/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
cd "$(dirname "$0")"
ROOT="$(cd "$(dirname "$0")" && pwd)"
if [ ! -d .venv ]; then
python3 -m venv .venv
./.venv/bin/pip install --upgrade pip
./.venv/bin/pip install -r requirements.txt
if [ ! -d "$ROOT/.venv" ]; then
python3 -m venv "$ROOT/.venv"
"$ROOT/.venv/bin/pip" install --upgrade pip
"$ROOT/.venv/bin/pip" install -r "$ROOT/requirements.txt"
fi
exec ./.venv/bin/python -m bolt_pet
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
exec "$ROOT/.venv/bin/python" -m bolt_console "$@"
-50
View File
@@ -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())
-218
View File
@@ -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)
+209
View File
@@ -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)
+293
View File
@@ -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() == ""
-195
View File
@@ -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
-385
View File
@@ -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"] == []
+295
View File
@@ -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()
+54
View File
@@ -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"
+343
View File
@@ -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"
+189
View File
@@ -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)
-92
View File
@@ -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
-114
View File
@@ -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()
-90
View File
@@ -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
-71
View File
@@ -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
-181
View File
@@ -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
-54
View File
@@ -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
-56
View File
@@ -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") == ""
+180 -19
View File
@@ -6,14 +6,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest
from bolt_pet import server_client
from bolt_console import server_client
@pytest.fixture(autouse=True)
def _configure(monkeypatch):
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, "SESSION_ID", "pet-test")
monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-console-test")
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:
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
result = server_client.converse("hi")
assert result == "hello there"
assert result.text == "hello there"
post.assert_called_once()
args, kwargs = post.call_args
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"}
@@ -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:
on_command = MagicMock(return_value="[exit 0]\nhi")
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")
# second call was to /desk/tool_result with the command's output
second_call = post.call_args_list[1]
assert second_call.args[0] == "http://test-server:5002/desk/tool_result"
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")
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():
with patch.object(server_client.requests, "get") as get:
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
result = server_client.check_health()
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"
-79
View File
@@ -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)
-73
View File
@@ -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
-122
View File
@@ -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
+164
View File
@@ -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
-82
View File
@@ -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
-237
View File
@@ -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
-148
View File
@@ -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
-89
View File
@@ -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())
BIN
View File
Binary file not shown.