Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/clayde/webhook/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/clayde/webhook/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions tests/test_webhook_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
10 changes: 10 additions & 0 deletions tests/test_webhook_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading