Update CLAUDE.md with release tagging instructions and enhance is_question logic to detect question marks anywhere in the text

This commit is contained in:
2026-08-05 17:53:32 -06:00
parent c4e805defd
commit 8d4751d80f
3 changed files with 34 additions and 12 deletions
+23
View File
@@ -47,6 +47,12 @@ python scripts/generate_bolt_sprites.py # --out /tmp/x to preview fi
python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1 python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
``` ```
**Cutting a release:** bump `__version__` in `bolt_pet/__init__.py` in the same
commit you tag, because that string — not the git history — is what every
already-installed pet compares against the newest Gitea tag (`updater.py`). A
tag without the bump means nobody updates; a bump without the tag means the
next tag looks older than what's running.
There is no lint/build step configured beyond pytest. `cp .env.example .env` 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`, and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
`ELEVENLABS_API_KEY`) before running — without server config the controller `ELEVENLABS_API_KEY`) before running — without server config the controller
@@ -207,6 +213,23 @@ logs a missing-config message and exits its thread instead of starting.
notes below); it's a safer path to the same capability. Pure parsing notes below); it's a safer path to the same capability. Pure parsing
(`parse`) is separated from the filesystem I/O (`execute`), matching (`parse`) is separated from the filesystem I/O (`execute`), matching
pet_actions.py's parse/describe split. pet_actions.py's parse/describe split.
- **`relay_json.py`** — the JSON parser both `filectl` and `dialoguectl` use
instead of `json.loads`, because their payload is hand-typed by a model into
a tool marker and fails in a small, repeatable set of ways (stray quote after
a bare literal, trailing comma, single or smart quotes, Python `True`/`False`,
a markdown fence). Strict parsing already cost a live turn: the call was
rejected, the model re-sent the identical line, was rejected again, and then
told the user "I'll check now" without ever calling anything. So `loads()`
tries strict first, then applies **named, individually-narrow repairs** and
accepts one only if the result parses — and on total failure raises
`RelayJsonError` carrying a caret pointed at the offending character, since
a model can act on a pointed-at fragment but not on "Expecting ',' delimiter:
char 74". Two conventions matter for any new relayed-JSON command: repairs
are **never silent**`parse` stashes them on the action as `_repairs` and
`describe` appends `relay_json.repair_note(...)` to the tool result, so the
model is told it sent something broken while it still has the turn — and new
repairs go in the `_REPAIRS` tuple ordered cheapest/safest first. Tested
inside `tests/test_file_ops.py`, not a file of its own.
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32, - **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
osascript) appended to each utterance via `context_for()`, plus osascript) appended to each utterance via `context_for()`, plus
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes `is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
+8 -10
View File
@@ -119,19 +119,17 @@ def for_speech(text: str) -> str:
def is_question(text: str) -> bool: def is_question(text: str) -> bool:
"""True if the reply *ends* by asking the user something — the cue for """True if the spoken reply contains a question mark — the cue for the
the pet to keep listening instead of making you say the wake word again. 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 The check runs on the spoken form, so a '?' that only exists inside a
passing ("What time is it? It's 7:15.") isn't waiting on an answer, stripped code block or a URL doesn't count, and trailing decoration
whereas one that finishes on a question mark is. The test runs on the (emoji, quotes, brackets) is peeled off first so "Ready to go? 🚀"
spoken form, so a '?' that only exists inside a stripped code block or a still reads as a question."""
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) spoken = for_speech(text)
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"): while spoken and not (spoken[-1].isalnum() or spoken[-1] in "?."):
spoken = spoken[:-1] spoken = spoken[:-1]
return spoken.endswith("?") return "?" in spoken
def for_display(text: str) -> str: def for_display(text: str) -> str:
+3 -2
View File
@@ -59,11 +59,12 @@ def test_display_keeps_emoji_but_drops_markdown():
assert for_display("* one\n* two") == "• one • two" assert for_display("* one\n* two") == "• one • two"
def test_is_question_only_fires_on_a_trailing_question(): def test_is_question_fires_when_a_question_mark_appears_anywhere():
assert is_question("Ready to run a command or start a project?") 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 is_question("It's 7:15 AM. Want me to set a timer?")
assert is_question("What time is it? It's 7:15 AM.")
assert is_question("Can you help me with this? I need a quick answer.")
assert not is_question("It's 7:15 AM on July 23, 2026.") 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(): def test_is_question_ignores_trailing_decoration():