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
23 changes: 23 additions & 0 deletions src/clayde/skills_builtin/voice-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
name: voice-command
description: Primary instructions for handling Pebble watch voice commands (speech-to-text input).
---

The input you receive is speech-to-text output from a Pebble watch. It MAY contain
transcription errors. Consider phonetically similar words and the most likely intent —
e.g. "calendar" might arrive as "colander". Use judgement.

Default working target: /home/clayde/knowledge_base (mounted RW, synced via Syncthing).
If the command implies "remember this", "note", "save", "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.
43 changes: 16 additions & 27 deletions src/clayde/webhook/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,7 @@ def _parse_skill(path: Path) -> Skill:


_SYSTEM_PROMPT_TEMPLATE = """\
You are Clayde, executing a voice command from the user via a Pebble watch.

The text you receive is speech-to-text output. It MAY contain transcription
errors. Consider phonetically similar words and the most likely intent —
e.g. "calendar" might arrive as "colander". Use judgement.

Default working target: /home/clayde/knowledge_base (mounted RW, synced
via Syncthing). If the command implies "remember this", "note", "save",
"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.
You are Clayde, executing a request from the user via a Pebble watch.

{skill_section}

Expand Down Expand Up @@ -105,17 +84,27 @@ def build_user_prompt(text: str, timestamp: int) -> str:
return f"(timestamp {timestamp})\n{text}"


def _is_builtin(path: Path) -> bool:
"""Return True if *path* lives under the ``builtin/`` subdirectory."""
return "builtin" in {p.name for p in path.parents}


def discover_skills(root: Path = SKILLS_ROOT) -> list[Skill]:
"""Recursively discover all skills under ``root``.

Returns a list ordered alphabetically by full path. On duplicate
``name`` fields, the first-discovered skill wins; subsequent
duplicates are logged at WARNING and ignored. Malformed files are
logged at WARNING and skipped.
Returns a list ordered alphabetically by full path. Non-builtin skills
(those NOT under a ``builtin/`` subdirectory) are processed before
builtin skills so that user-mounted overrides take priority over
shipped defaults. On duplicate ``name`` fields after ordering, the
first-encountered skill wins; subsequent duplicates are logged at
WARNING and ignored. Malformed files are logged at WARNING and skipped.
"""
if not root.exists():
return []
files = sorted(root.rglob("*.md"))
all_files = sorted(root.rglob("*.md"))
# Non-builtin first so user skills override shipped builtins on name collision.
files = [f for f in all_files if not _is_builtin(f)]
files += [f for f in all_files if _is_builtin(f)]
seen: dict[str, Skill] = {}
for path in files:
try:
Expand Down
52 changes: 33 additions & 19 deletions tests/test_webhook_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,6 @@ def test_build_system_prompt_with_skills():
]
prompt = build_system_prompt(skills)
assert "Pebble watch" in prompt
assert "speech-to-text" in prompt
assert "phonetically similar" in prompt
assert "- add-note: Save a note." in prompt
assert "- add-event: Create a calendar event." in prompt
assert "/skills/personal/add-note.md" in prompt
Expand Down Expand Up @@ -138,13 +136,6 @@ def test_prompt_no_longer_caps_to_one_skill():
assert "as many as the command needs" in p


def test_prompt_mentions_kb_default():
from clayde.webhook.skills import build_system_prompt
p = build_system_prompt([])
assert "/home/clayde/knowledge_base" in p
assert "Syncthing" in p


def test_prompt_contains_json_contract():
from clayde.webhook.skills import build_system_prompt
p = build_system_prompt([])
Expand All @@ -160,16 +151,6 @@ 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 All @@ -184,3 +165,36 @@ def test_discovers_builtin_alongside_host(tmp_path):
skills = discover_skills(tmp_path)
names = {s.name for s in skills}
assert names == {"ping", "add-note"}


def test_discover_personal_overrides_builtin(tmp_path, caplog):
"""Non-builtin skills (personal/shared) win over builtin on name collision."""
from clayde.webhook.skills import discover_skills
(tmp_path / "builtin").mkdir()
(tmp_path / "personal").mkdir()
(tmp_path / "builtin" / "voice-command.md").write_text(
"---\nname: voice-command\ndescription: Builtin version.\n---\n\nBuiltin body.\n"
)
(tmp_path / "personal" / "voice-command.md").write_text(
"---\nname: voice-command\ndescription: Personal override.\n---\n\nCustom body.\n"
)
with caplog.at_level("WARNING", logger="clayde.webhook"):
skills = discover_skills(tmp_path)
assert len(skills) == 1
assert skills[0].description == "Personal override."
assert any("Duplicate skill name" in r.getMessage() for r in caplog.records)


def test_voice_command_builtin_skill_exists():
"""The shipped voice-command builtin skill has the expected frontmatter."""
from clayde.webhook import skills as skills_mod
import importlib.resources
builtin_dir = Path(skills_mod.__file__).parent.parent / "skills_builtin"
vc_path = builtin_dir / "voice-command.md"
assert vc_path.exists(), "voice-command.md missing from skills_builtin/"
skill = _parse_skill(vc_path)
assert skill.name == "voice-command"
# Behavioral content belongs in the skill, not in the system prompt.
body = vc_path.read_text()
assert "speech-to-text" in body or "voice" in body.lower()
assert "/home/clayde/knowledge_base" in body
Loading