From 0abfd90bbe79894f206f9ea301cde2ae49fe8af3 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/134346822297520" Date: Sun, 17 May 2026 20:28:15 +0000 Subject: [PATCH] fix(pebble): ASCII-coerce ntfy title; add KB-structure disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ntfy `Title` header travels through httpx, which encodes headers as latin-1. A title containing an em dash (e.g. "Thomas Stegger — plant prefs saved") raises UnicodeEncodeError before the request leaves the container, so the Pebble outcome notification is silently dropped — the work itself completes, but the user never sees the confirmation on the watch. Coerce title input through a small translation table (em/en dash, smart quotes, ellipsis, NBSP) and ASCII-replace anything left over in the `NotificationPayload` field validator. Apply before length clamping so multi-char replacements like "…" → "..." still fit the 40-char window. Also tighten the Pebble system prompt to disambiguate speech-to-text errors against the KB's actual folder layout. Worked example added so Claude reaches for `ls /home/clayde/knowledge_base` and treats top-level folders ("people", "specs", "inbox", ...) as candidate matches for phonetically similar transcription tokens. Co-Authored-By: Claude Opus 4.7 --- src/clayde/webhook/notify.py | 29 ++++++++++++++++++++++++++++- src/clayde/webhook/skills.py | 12 ++++++++++++ tests/test_webhook_notify.py | 30 ++++++++++++++++++++++++++++++ tests/test_webhook_skills.py | 10 ++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/clayde/webhook/notify.py b/src/clayde/webhook/notify.py index 0c3f1ee..8d1f064 100644 --- a/src/clayde/webhook/notify.py +++ b/src/clayde/webhook/notify.py @@ -16,11 +16,36 @@ log = logging.getLogger("clayde.webhook.notify") +# ntfy header values are sent through httpx, which encodes headers as +# latin-1. Anything outside that range raises UnicodeEncodeError before +# the request goes out, so the user never sees the notification. We +# normalise common typographic Unicode to ASCII and replace anything +# left over with '?'. +_UNICODE_TO_ASCII = str.maketrans({ + "—": "-", # em dash + "–": "-", # en dash + "−": "-", # minus sign + "‘": "'", # left single quote + "’": "'", # right single quote / apostrophe + "“": '"', # left double quote + "”": '"', # right double quote + "…": "...", # ellipsis + " ": " ", # non-breaking space +}) + + +def _to_ascii(text: str) -> str: + """Coerce arbitrary text to safe ASCII for use in HTTP headers.""" + return text.translate(_UNICODE_TO_ASCII).encode("ascii", "replace").decode("ascii") + + class NotificationPayload(BaseModel): """Outcome of a Pebble run, as emitted by Claude in the JSON tail. Title is clamped to 40 chars and body to 300 chars at construction time so accidental over-long values never propagate to ntfy headers. + Title is additionally coerced to ASCII because it travels as an HTTP + header and httpx rejects non-latin-1 header values. """ title: str @@ -30,7 +55,9 @@ class NotificationPayload(BaseModel): @field_validator("title", mode="before") @classmethod def _clamp_title(cls, v): - return v[:40] if isinstance(v, str) else v + if not isinstance(v, str): + return v + return _to_ascii(v)[:40] @field_validator("body", mode="before") @classmethod diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 780460a..82f8cd0 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -53,6 +53,18 @@ def _parse_skill(path: Path) -> Skill: "log", or "capture", write a file there. No git operations — Syncthing handles sync. +Disambiguate against the KB structure. Before acting on a phrase that +seems nonsensical or oddly worded, list the top level of the knowledge +base (e.g. `ls /home/clayde/knowledge_base`). Its top-level directories +are stable nouns the user actually uses ("people", "specs", "inbox", +"freeshard", ...). If a confusing token has a phonetic neighbour that +matches one of those folders or a common verb pair ("add a", "note +that", "capture"), prefer that reading. Worked example: "after people +and tree for my brother-in-law" → "add a people entry for my +brother-in-law", because "after" ≈ "add a" and "tree" ≈ "entry", and +`people/` is a real folder. State the interpretation you picked in your +narrative so the user can spot a wrong guess in the ntfy summary. + {skill_section} Skills are suggestions, not constraints. Use as many as the command needs, diff --git a/tests/test_webhook_notify.py b/tests/test_webhook_notify.py index 9566660..7e01809 100644 --- a/tests/test_webhook_notify.py +++ b/tests/test_webhook_notify.py @@ -22,6 +22,36 @@ def test_notification_payload_accepts_short(): assert p.success is True +def test_notification_payload_em_dash_in_title_normalised(): + # Real prod failure: em dash in title raised UnicodeEncodeError when + # httpx serialised the header as latin-1. + p = NotificationPayload(title="Thomas Stegger — plant prefs saved", body="ok", success=True) + assert "—" not in p.title + assert p.title == "Thomas Stegger - plant prefs saved" + # Must round-trip cleanly through latin-1 (the header codec httpx uses). + p.title.encode("latin-1") + + +def test_notification_payload_smart_quotes_in_title_normalised(): + p = NotificationPayload(title="“hi” ‘there’", body="ok", success=True) + assert p.title == '"hi" \'there\'' + + +def test_notification_payload_unknown_unicode_in_title_replaced(): + p = NotificationPayload(title="emoji \U0001f600 tail", body="ok", success=True) + assert "\U0001f600" not in p.title + p.title.encode("ascii") + + +def test_notification_payload_ascii_coercion_runs_before_clamp(): + # "..." (3 chars) replaces "…" (1 char); clamp comes after, so a + # title that fit pre-replacement may not fit after — and that's fine. + long = "a" * 38 + "…" # 39 chars in, 41 chars after replacement + p = NotificationPayload(title=long, body="ok", success=True) + assert len(p.title) == 40 + p.title.encode("ascii") + + @pytest.mark.asyncio @respx.mock async def test_send_ntfy_success_headers(): diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index a4e3cef..6d5eb38 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -160,6 +160,16 @@ def test_prompt_when_no_skills_still_invites_judgement(): assert "judgement" in p.lower() or "judgment" in p.lower() +def test_prompt_mentions_kb_structure_disambiguation(): + from clayde.webhook.skills import build_system_prompt + p = build_system_prompt([]) + # Tells Claude to inspect KB layout and prefer phonetic neighbours + # that match real folders ("after people and tree" → "add a people entry"). + assert "ls /home/clayde/knowledge_base" in p + assert "phonetic" in p.lower() + assert "people" in p + + def test_discovers_builtin_alongside_host(tmp_path): from clayde.webhook.skills import discover_skills # Simulate the in-container layout: /skills/builtin + /skills/personal.