diff --git a/CLAUDE.md b/CLAUDE.md index 626a498..727f890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` +**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` and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`, `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 (`parse`) is separated from the filesystem I/O (`execute`), matching 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, osascript) appended to each utterance via `context_for()`, plus `is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes diff --git a/bolt_pet/speech_text.py b/bolt_pet/speech_text.py index 5aa2b13..8d161aa 100644 --- a/bolt_pet/speech_text.py +++ b/bolt_pet/speech_text.py @@ -119,19 +119,17 @@ def for_speech(text: str) -> str: 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. + """True if the spoken reply contains a question mark — 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.""" + The check 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] == "?"): + while spoken and not (spoken[-1].isalnum() or spoken[-1] in "?."): spoken = spoken[:-1] - return spoken.endswith("?") + return "?" in spoken def for_display(text: str) -> str: diff --git a/tests/test_speech_text.py b/tests/test_speech_text.py index 53c9d5f..6bfbd7f 100644 --- a/tests/test_speech_text.py +++ b/tests/test_speech_text.py @@ -59,11 +59,12 @@ def test_display_keeps_emoji_but_drops_markdown(): 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("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("What time is it? It's 7:15 AM.") # asked in passing def test_is_question_ignores_trailing_decoration():