From 5cc54822cd09b22af2e8dedbd38a3f67ced2d651 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 19 Apr 2026 17:24:05 +0200 Subject: [PATCH] fix(skills): tell the model where each skill body lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system-prompt addendum now tags every skill with a source pointer: '[builtin]' for skills embedded in the zot binary, or the SKILL.md path (HOME collapsed to ~) for user-installed ones. The body still loads on demand through the 'skill' tool by name, so no behaviour change for execution, this is pure disambiguation. Before: - write-zot-extension — Help the user create a new zot extension... After: - write-zot-extension [builtin]: Help the user create a new zot extension... - code-review [~/Library/Application Support/zot/skills/code-review/SKILL.md]: ... Why: built-in skills have no filesystem path because their markdown is embedded in the binary. Without the [builtin] tag the model had no way to distinguish them from user skills, and could mistakenly try to read a nonexistent file. The path pointer for user skills also helps the model cite where guidance came from and reason about trust (builtin vs project-local vs global). Test updated; addendum grew to ~123 tokens for a typical 3-skill setup (code-review, test-fix, write-zot-extension). --- internal/skills/skills.go | 40 ++++++++++++++++++++++++++++++---- internal/skills/skills_test.go | 11 ++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 857862d..a38218e 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -155,24 +155,56 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err // SystemPromptAddendum returns the text to append to the system // prompt when at least one skill is loaded. Empty string if none. // -// Format kept short and explicit so the model reliably calls the -// `skill` tool with a name from the list rather than guessing. +// The format is deliberately compact: name, one-line description, +// and a source pointer telling the model where the full body +// lives. Built-in skills show "builtin" since their markdown is +// embedded in the zot binary and not on the filesystem; user +// skills show their SKILL.md path (shortened with ~ for HOME). +// +// Loading still goes through the `skill` tool with just the name. +// The pointer is there so the model can (a) mention the source +// honestly in explanations and (b) distinguish between built-ins +// and user-authored instruction sets when reasoning about trust. func SystemPromptAddendum(skills []*Skill) string { if len(skills) == 0 { return "" } + home, _ := os.UserHomeDir() var sb strings.Builder - sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n") + sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n") for _, s := range skills { desc := strings.TrimSpace(s.Description) if desc == "" { desc = "(no description)" } - fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc) + pointer := skillSourcePointer(s, home) + fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc) } return sb.String() } +// skillSourcePointer returns a short tag describing where a skill +// originates. Built-ins are tagged "builtin" because their markdown +// is embedded in the zot binary and not reachable through the +// filesystem. User skills are tagged with their SKILL.md path, +// collapsed to use ~ for the user home when possible. +func skillSourcePointer(s *Skill, home string) string { + if s == nil { + return "unknown" + } + if s.Builtin { + return "builtin" + } + p := s.Path + if p == "" { + return "unknown" + } + if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) { + return "~" + p[len(home):] + } + return p +} + // FindByName returns the skill with the given name, or nil. func FindByName(skills []*Skill, name string) *Skill { for _, s := range skills { diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 9aca524..6b49a65 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -108,12 +108,15 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) { func TestSystemPromptAddendum(t *testing.T) { skills := []*Skill{ - {Name: "a", Description: "Do A."}, - {Name: "b", Description: "Do B."}, + {Name: "built-a", Description: "Do A.", Builtin: true}, + {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"}, } out := SystemPromptAddendum(skills) - if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) { - t.Errorf("addendum missing entries:\n%s", out) + if !contains(out, "- built-a [builtin]: Do A.\n") { + t.Errorf("builtin entry missing or wrong:\n%s", out) + } + if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") { + t.Errorf("user entry missing path pointer:\n%s", out) } }