Skip to content

Commit a7dd2d1

Browse files
ClaydeCodeclaude
andcommitted
Fix #78: use voice-command builtin skill for Pebble voice context
Move the detailed voice-command handling instructions (speech-to-text error handling, KB path, phonetic disambiguation guidance) out of the hardcoded _SYSTEM_PROMPT_TEMPLATE and into a dedicated built-in skill at src/clayde/skills_builtin/voice-command.md. The system prompt is now minimal structural glue: identity, the skills catalog, skill-usage policy, and the JSON output contract. Evolving the voice-command behavior is done by editing the skill file (or placing an override at ~/skills/personal/voice-command.md), without touching Python. Also fix discover_skills() priority so non-builtin skills (personal/ shared) are processed before builtin ones, letting user-mounted overrides win on name collision rather than the shipped default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 618cbfc commit a7dd2d1

3 files changed

Lines changed: 72 additions & 46 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
name: voice-command
3+
description: Primary instructions for handling Pebble watch voice commands (speech-to-text input).
4+
---
5+
6+
The input you receive is speech-to-text output from a Pebble watch. It MAY contain
7+
transcription errors. Consider phonetically similar words and the most likely intent —
8+
e.g. "calendar" might arrive as "colander". Use judgement.
9+
10+
Default working target: /home/clayde/knowledge_base (mounted RW, synced via Syncthing).
11+
If the command implies "remember this", "note", "save", "log", or "capture", write a
12+
file there. No git operations — Syncthing handles sync.
13+
14+
Disambiguate against the KB structure. Before acting on a phrase that seems nonsensical
15+
or oddly worded, list the top level of the knowledge base
16+
(e.g. `ls /home/clayde/knowledge_base`). Its top-level directories are stable nouns the
17+
user actually uses ("people", "specs", "inbox", "freeshard", ...). If a confusing token
18+
has a phonetic neighbour that matches one of those folders or a common verb pair ("add
19+
a", "note that", "capture"), prefer that reading. Worked example: "after people and tree
20+
for my brother-in-law" → "add a people entry for my brother-in-law", because
21+
"after" ≈ "add a" and "tree" ≈ "entry", and `people/` is a real folder. State the
22+
interpretation you picked in your narrative so the user can spot a wrong guess in the
23+
ntfy summary.

src/clayde/webhook/skills.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -42,28 +42,7 @@ def _parse_skill(path: Path) -> Skill:
4242

4343

4444
_SYSTEM_PROMPT_TEMPLATE = """\
45-
You are Clayde, executing a voice command from the user via a Pebble watch.
46-
47-
The text you receive is speech-to-text output. It MAY contain transcription
48-
errors. Consider phonetically similar words and the most likely intent —
49-
e.g. "calendar" might arrive as "colander". Use judgement.
50-
51-
Default working target: /home/clayde/knowledge_base (mounted RW, synced
52-
via Syncthing). If the command implies "remember this", "note", "save",
53-
"log", or "capture", write a file there. No git operations — Syncthing
54-
handles sync.
55-
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.
45+
You are Clayde, executing a request from the user via a Pebble watch.
6746
6847
{skill_section}
6948
@@ -105,17 +84,27 @@ def build_user_prompt(text: str, timestamp: int) -> str:
10584
return f"(timestamp {timestamp})\n{text}"
10685

10786

87+
def _is_builtin(path: Path) -> bool:
88+
"""Return True if *path* lives under the ``builtin/`` subdirectory."""
89+
return "builtin" in {p.name for p in path.parents}
90+
91+
10892
def discover_skills(root: Path = SKILLS_ROOT) -> list[Skill]:
10993
"""Recursively discover all skills under ``root``.
11094
111-
Returns a list ordered alphabetically by full path. On duplicate
112-
``name`` fields, the first-discovered skill wins; subsequent
113-
duplicates are logged at WARNING and ignored. Malformed files are
114-
logged at WARNING and skipped.
95+
Returns a list ordered alphabetically by full path. Non-builtin skills
96+
(those NOT under a ``builtin/`` subdirectory) are processed before
97+
builtin skills so that user-mounted overrides take priority over
98+
shipped defaults. On duplicate ``name`` fields after ordering, the
99+
first-encountered skill wins; subsequent duplicates are logged at
100+
WARNING and ignored. Malformed files are logged at WARNING and skipped.
115101
"""
116102
if not root.exists():
117103
return []
118-
files = sorted(root.rglob("*.md"))
104+
all_files = sorted(root.rglob("*.md"))
105+
# Non-builtin first so user skills override shipped builtins on name collision.
106+
files = [f for f in all_files if not _is_builtin(f)]
107+
files += [f for f in all_files if _is_builtin(f)]
119108
seen: dict[str, Skill] = {}
120109
for path in files:
121110
try:

tests/test_webhook_skills.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,6 @@ def test_build_system_prompt_with_skills():
107107
]
108108
prompt = build_system_prompt(skills)
109109
assert "Pebble watch" in prompt
110-
assert "speech-to-text" in prompt
111-
assert "phonetically similar" in prompt
112110
assert "- add-note: Save a note." in prompt
113111
assert "- add-event: Create a calendar event." in prompt
114112
assert "/skills/personal/add-note.md" in prompt
@@ -138,13 +136,6 @@ def test_prompt_no_longer_caps_to_one_skill():
138136
assert "as many as the command needs" in p
139137

140138

141-
def test_prompt_mentions_kb_default():
142-
from clayde.webhook.skills import build_system_prompt
143-
p = build_system_prompt([])
144-
assert "/home/clayde/knowledge_base" in p
145-
assert "Syncthing" in p
146-
147-
148139
def test_prompt_contains_json_contract():
149140
from clayde.webhook.skills import build_system_prompt
150141
p = build_system_prompt([])
@@ -160,16 +151,6 @@ def test_prompt_when_no_skills_still_invites_judgement():
160151
assert "judgement" in p.lower() or "judgment" in p.lower()
161152

162153

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-
173154
def test_discovers_builtin_alongside_host(tmp_path):
174155
from clayde.webhook.skills import discover_skills
175156
# Simulate the in-container layout: /skills/builtin + /skills/personal.
@@ -184,3 +165,36 @@ def test_discovers_builtin_alongside_host(tmp_path):
184165
skills = discover_skills(tmp_path)
185166
names = {s.name for s in skills}
186167
assert names == {"ping", "add-note"}
168+
169+
170+
def test_discover_personal_overrides_builtin(tmp_path, caplog):
171+
"""Non-builtin skills (personal/shared) win over builtin on name collision."""
172+
from clayde.webhook.skills import discover_skills
173+
(tmp_path / "builtin").mkdir()
174+
(tmp_path / "personal").mkdir()
175+
(tmp_path / "builtin" / "voice-command.md").write_text(
176+
"---\nname: voice-command\ndescription: Builtin version.\n---\n\nBuiltin body.\n"
177+
)
178+
(tmp_path / "personal" / "voice-command.md").write_text(
179+
"---\nname: voice-command\ndescription: Personal override.\n---\n\nCustom body.\n"
180+
)
181+
with caplog.at_level("WARNING", logger="clayde.webhook"):
182+
skills = discover_skills(tmp_path)
183+
assert len(skills) == 1
184+
assert skills[0].description == "Personal override."
185+
assert any("Duplicate skill name" in r.getMessage() for r in caplog.records)
186+
187+
188+
def test_voice_command_builtin_skill_exists():
189+
"""The shipped voice-command builtin skill has the expected frontmatter."""
190+
from clayde.webhook import skills as skills_mod
191+
import importlib.resources
192+
builtin_dir = Path(skills_mod.__file__).parent.parent / "skills_builtin"
193+
vc_path = builtin_dir / "voice-command.md"
194+
assert vc_path.exists(), "voice-command.md missing from skills_builtin/"
195+
skill = _parse_skill(vc_path)
196+
assert skill.name == "voice-command"
197+
# Behavioral content belongs in the skill, not in the system prompt.
198+
body = vc_path.read_text()
199+
assert "speech-to-text" in body or "voice" in body.lower()
200+
assert "/home/clayde/knowledge_base" in body

0 commit comments

Comments
 (0)