diff --git a/src/clayde/skills_builtin/voice-command.md b/src/clayde/skills_builtin/voice-command.md new file mode 100644 index 0000000..03a2601 --- /dev/null +++ b/src/clayde/skills_builtin/voice-command.md @@ -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. diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 82f8cd0..85d5037 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -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} @@ -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: diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index 6d5eb38..2502ed9 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -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 @@ -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([]) @@ -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. @@ -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