Skip to content

Commit f444ca8

Browse files
authored
Merge pull request #73 from ClaydeCode/fix/ntfy-ascii-title
fix(pebble): ASCII-coerce ntfy title; disambiguate STT against KB folders
2 parents 91137af + 0abfd90 commit f444ca8

4 files changed

Lines changed: 80 additions & 1 deletion

File tree

src/clayde/webhook/notify.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,36 @@
1616
log = logging.getLogger("clayde.webhook.notify")
1717

1818

19+
# ntfy header values are sent through httpx, which encodes headers as
20+
# latin-1. Anything outside that range raises UnicodeEncodeError before
21+
# the request goes out, so the user never sees the notification. We
22+
# normalise common typographic Unicode to ASCII and replace anything
23+
# left over with '?'.
24+
_UNICODE_TO_ASCII = str.maketrans({
25+
"—": "-", # em dash
26+
"–": "-", # en dash
27+
"−": "-", # minus sign
28+
"‘": "'", # left single quote
29+
"’": "'", # right single quote / apostrophe
30+
"“": '"', # left double quote
31+
"”": '"', # right double quote
32+
"…": "...", # ellipsis
33+
" ": " ", # non-breaking space
34+
})
35+
36+
37+
def _to_ascii(text: str) -> str:
38+
"""Coerce arbitrary text to safe ASCII for use in HTTP headers."""
39+
return text.translate(_UNICODE_TO_ASCII).encode("ascii", "replace").decode("ascii")
40+
41+
1942
class NotificationPayload(BaseModel):
2043
"""Outcome of a Pebble run, as emitted by Claude in the JSON tail.
2144
2245
Title is clamped to 40 chars and body to 300 chars at construction
2346
time so accidental over-long values never propagate to ntfy headers.
47+
Title is additionally coerced to ASCII because it travels as an HTTP
48+
header and httpx rejects non-latin-1 header values.
2449
"""
2550

2651
title: str
@@ -30,7 +55,9 @@ class NotificationPayload(BaseModel):
3055
@field_validator("title", mode="before")
3156
@classmethod
3257
def _clamp_title(cls, v):
33-
return v[:40] if isinstance(v, str) else v
58+
if not isinstance(v, str):
59+
return v
60+
return _to_ascii(v)[:40]
3461

3562
@field_validator("body", mode="before")
3663
@classmethod

src/clayde/webhook/skills.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ def _parse_skill(path: Path) -> Skill:
5353
"log", or "capture", write a file there. No git operations — Syncthing
5454
handles sync.
5555
56+
Disambiguate against the KB structure. Before acting on a phrase that
57+
seems nonsensical or oddly worded, list the top level of the knowledge
58+
base (e.g. `ls /home/clayde/knowledge_base`). Its top-level directories
59+
are stable nouns the user actually uses ("people", "specs", "inbox",
60+
"freeshard", ...). If a confusing token has a phonetic neighbour that
61+
matches one of those folders or a common verb pair ("add a", "note
62+
that", "capture"), prefer that reading. Worked example: "after people
63+
and tree for my brother-in-law" → "add a people entry for my
64+
brother-in-law", because "after" ≈ "add a" and "tree" ≈ "entry", and
65+
`people/` is a real folder. State the interpretation you picked in your
66+
narrative so the user can spot a wrong guess in the ntfy summary.
67+
5668
{skill_section}
5769
5870
Skills are suggestions, not constraints. Use as many as the command needs,

tests/test_webhook_notify.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,36 @@ def test_notification_payload_accepts_short():
2222
assert p.success is True
2323

2424

25+
def test_notification_payload_em_dash_in_title_normalised():
26+
# Real prod failure: em dash in title raised UnicodeEncodeError when
27+
# httpx serialised the header as latin-1.
28+
p = NotificationPayload(title="Thomas Stegger — plant prefs saved", body="ok", success=True)
29+
assert "—" not in p.title
30+
assert p.title == "Thomas Stegger - plant prefs saved"
31+
# Must round-trip cleanly through latin-1 (the header codec httpx uses).
32+
p.title.encode("latin-1")
33+
34+
35+
def test_notification_payload_smart_quotes_in_title_normalised():
36+
p = NotificationPayload(title="“hi” ‘there’", body="ok", success=True)
37+
assert p.title == '"hi" \'there\''
38+
39+
40+
def test_notification_payload_unknown_unicode_in_title_replaced():
41+
p = NotificationPayload(title="emoji \U0001f600 tail", body="ok", success=True)
42+
assert "\U0001f600" not in p.title
43+
p.title.encode("ascii")
44+
45+
46+
def test_notification_payload_ascii_coercion_runs_before_clamp():
47+
# "..." (3 chars) replaces "…" (1 char); clamp comes after, so a
48+
# title that fit pre-replacement may not fit after — and that's fine.
49+
long = "a" * 38 + "…" # 39 chars in, 41 chars after replacement
50+
p = NotificationPayload(title=long, body="ok", success=True)
51+
assert len(p.title) == 40
52+
p.title.encode("ascii")
53+
54+
2555
@pytest.mark.asyncio
2656
@respx.mock
2757
async def test_send_ntfy_success_headers():

tests/test_webhook_skills.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ def test_prompt_when_no_skills_still_invites_judgement():
160160
assert "judgement" in p.lower() or "judgment" in p.lower()
161161

162162

163+
def test_prompt_mentions_kb_structure_disambiguation():
164+
from clayde.webhook.skills import build_system_prompt
165+
p = build_system_prompt([])
166+
# Tells Claude to inspect KB layout and prefer phonetic neighbours
167+
# that match real folders ("after people and tree" → "add a people entry").
168+
assert "ls /home/clayde/knowledge_base" in p
169+
assert "phonetic" in p.lower()
170+
assert "people" in p
171+
172+
163173
def test_discovers_builtin_alongside_host(tmp_path):
164174
from clayde.webhook.skills import discover_skills
165175
# Simulate the in-container layout: /skills/builtin + /skills/personal.

0 commit comments

Comments
 (0)