fix(skills): tell the model where each skill body lives
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).
- commit
- 5cc54822cd09b22af2e8dedbd38a3f67ced2d651
- author
- patriceckhart · · 2026-04-19 15:24 UTC
- parent
- 1a2ab42
- signature
- unsigned
- refs
- tag: v0.0.38
2 files · +43 -8
M internal/skills/skills.go +36 -4
| @@ -155,24 +155,56 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err | |||
| 155 | 155 | // SystemPromptAddendum returns the text to append to the system | |
| 156 | 156 | // prompt when at least one skill is loaded. Empty string if none. | |
| 157 | 157 | // | |
| 158 | - | // Format kept short and explicit so the model reliably calls the | |
| 159 | - | // `skill` tool with a name from the list rather than guessing. | |
| 158 | + | // The format is deliberately compact: name, one-line description, | |
| 159 | + | // and a source pointer telling the model where the full body | |
| 160 | + | // lives. Built-in skills show "builtin" since their markdown is | |
| 161 | + | // embedded in the zot binary and not on the filesystem; user | |
| 162 | + | // skills show their SKILL.md path (shortened with ~ for HOME). | |
| 163 | + | // | |
| 164 | + | // Loading still goes through the `skill` tool with just the name. | |
| 165 | + | // The pointer is there so the model can (a) mention the source | |
| 166 | + | // honestly in explanations and (b) distinguish between built-ins | |
| 167 | + | // and user-authored instruction sets when reasoning about trust. | |
| 160 | 168 | func SystemPromptAddendum(skills []*Skill) string { | |
| 161 | 169 | if len(skills) == 0 { | |
| 162 | 170 | return "" | |
| 163 | 171 | } | |
| 172 | + | home, _ := os.UserHomeDir() | |
| 164 | 173 | var sb strings.Builder | |
| 165 | - | sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n") | |
| 174 | + | sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n") | |
| 166 | 175 | for _, s := range skills { | |
| 167 | 176 | desc := strings.TrimSpace(s.Description) | |
| 168 | 177 | if desc == "" { | |
| 169 | 178 | desc = "(no description)" | |
| 170 | 179 | } | |
| 171 | - | fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc) | |
| 180 | + | pointer := skillSourcePointer(s, home) | |
| 181 | + | fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc) | |
| 172 | 182 | } | |
| 173 | 183 | return sb.String() | |
| 174 | 184 | } | |
| 175 | 185 | ||
| 186 | + | // skillSourcePointer returns a short tag describing where a skill | |
| 187 | + | // originates. Built-ins are tagged "builtin" because their markdown | |
| 188 | + | // is embedded in the zot binary and not reachable through the | |
| 189 | + | // filesystem. User skills are tagged with their SKILL.md path, | |
| 190 | + | // collapsed to use ~ for the user home when possible. | |
| 191 | + | func skillSourcePointer(s *Skill, home string) string { | |
| 192 | + | if s == nil { | |
| 193 | + | return "unknown" | |
| 194 | + | } | |
| 195 | + | if s.Builtin { | |
| 196 | + | return "builtin" | |
| 197 | + | } | |
| 198 | + | p := s.Path | |
| 199 | + | if p == "" { | |
| 200 | + | return "unknown" | |
| 201 | + | } | |
| 202 | + | if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) { | |
| 203 | + | return "~" + p[len(home):] | |
| 204 | + | } | |
| 205 | + | return p | |
| 206 | + | } | |
| 207 | + | ||
| 176 | 208 | // FindByName returns the skill with the given name, or nil. | |
| 177 | 209 | func FindByName(skills []*Skill, name string) *Skill { | |
| 178 | 210 | for _, s := range skills { | |
| @@ -155,24 +155,56 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err | |||||
| 155 | // SystemPromptAddendum returns the text to append to the system | 155 | // SystemPromptAddendum returns the text to append to the system | ||
| 156 | // prompt when at least one skill is loaded. Empty string if none. | 156 | // prompt when at least one skill is loaded. Empty string if none. | ||
| 157 | // | 157 | // | ||
| 158 | - | // Format kept short and explicit so the model reliably calls the | 158 | + | // The format is deliberately compact: name, one-line description, |
| 159 | - | // `skill` tool with a name from the list rather than guessing. | 159 | + | // and a source pointer telling the model where the full body |
| 160 | + | // lives. Built-in skills show "builtin" since their markdown is | |||
| 161 | + | // embedded in the zot binary and not on the filesystem; user | |||
| 162 | + | // skills show their SKILL.md path (shortened with ~ for HOME). | |||
| 163 | + | // | |||
| 164 | + | // Loading still goes through the `skill` tool with just the name. | |||
| 165 | + | // The pointer is there so the model can (a) mention the source | |||
| 166 | + | // honestly in explanations and (b) distinguish between built-ins | |||
| 167 | + | // and user-authored instruction sets when reasoning about trust. | |||
| 160 | func SystemPromptAddendum(skills []*Skill) string { | 168 | func SystemPromptAddendum(skills []*Skill) string { | ||
| 161 | if len(skills) == 0 { | 169 | if len(skills) == 0 { | ||
| 162 | return "" | 170 | return "" | ||
| 163 | } | 171 | } | ||
| 172 | + | home, _ := os.UserHomeDir() | |||
| 164 | var sb strings.Builder | 173 | var sb strings.Builder | ||
| 165 | - | sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n") | 174 | + | sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n") |
| 166 | for _, s := range skills { | 175 | for _, s := range skills { | ||
| 167 | desc := strings.TrimSpace(s.Description) | 176 | desc := strings.TrimSpace(s.Description) | ||
| 168 | if desc == "" { | 177 | if desc == "" { | ||
| 169 | desc = "(no description)" | 178 | desc = "(no description)" | ||
| 170 | } | 179 | } | ||
| 171 | - | fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc) | 180 | + | pointer := skillSourcePointer(s, home) |
| 181 | + | fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc) | |||
| 172 | } | 182 | } | ||
| 173 | return sb.String() | 183 | return sb.String() | ||
| 174 | } | 184 | } | ||
| 175 | 185 | ||||
| 186 | + | // skillSourcePointer returns a short tag describing where a skill | |||
| 187 | + | // originates. Built-ins are tagged "builtin" because their markdown | |||
| 188 | + | // is embedded in the zot binary and not reachable through the | |||
| 189 | + | // filesystem. User skills are tagged with their SKILL.md path, | |||
| 190 | + | // collapsed to use ~ for the user home when possible. | |||
| 191 | + | func skillSourcePointer(s *Skill, home string) string { | |||
| 192 | + | if s == nil { | |||
| 193 | + | return "unknown" | |||
| 194 | + | } | |||
| 195 | + | if s.Builtin { | |||
| 196 | + | return "builtin" | |||
| 197 | + | } | |||
| 198 | + | p := s.Path | |||
| 199 | + | if p == "" { | |||
| 200 | + | return "unknown" | |||
| 201 | + | } | |||
| 202 | + | if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) { | |||
| 203 | + | return "~" + p[len(home):] | |||
| 204 | + | } | |||
| 205 | + | return p | |||
| 206 | + | } | |||
| 207 | + | ||||
| 176 | // FindByName returns the skill with the given name, or nil. | 208 | // FindByName returns the skill with the given name, or nil. | ||
| 177 | func FindByName(skills []*Skill, name string) *Skill { | 209 | func FindByName(skills []*Skill, name string) *Skill { | ||
| 178 | for _, s := range skills { | 210 | for _, s := range skills { | ||
M internal/skills/skills_test.go +7 -4
| @@ -108,12 +108,15 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |||
| 108 | 108 | ||
| 109 | 109 | func TestSystemPromptAddendum(t *testing.T) { | |
| 110 | 110 | skills := []*Skill{ | |
| 111 | - | {Name: "a", Description: "Do A."}, | |
| 112 | - | {Name: "b", Description: "Do B."}, | |
| 111 | + | {Name: "built-a", Description: "Do A.", Builtin: true}, | |
| 112 | + | {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"}, | |
| 113 | 113 | } | |
| 114 | 114 | out := SystemPromptAddendum(skills) | |
| 115 | - | if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) { | |
| 116 | - | t.Errorf("addendum missing entries:\n%s", out) | |
| 115 | + | if !contains(out, "- built-a [builtin]: Do A.\n") { | |
| 116 | + | t.Errorf("builtin entry missing or wrong:\n%s", out) | |
| 117 | + | } | |
| 118 | + | if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") { | |
| 119 | + | t.Errorf("user entry missing path pointer:\n%s", out) | |
| 117 | 120 | } | |
| 118 | 121 | } | |
| 119 | 122 | ||
| @@ -108,12 +108,15 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |||||
| 108 | 108 | ||||
| 109 | func TestSystemPromptAddendum(t *testing.T) { | 109 | func TestSystemPromptAddendum(t *testing.T) { | ||
| 110 | skills := []*Skill{ | 110 | skills := []*Skill{ | ||
| 111 | - | {Name: "a", Description: "Do A."}, | 111 | + | {Name: "built-a", Description: "Do A.", Builtin: true}, |
| 112 | - | {Name: "b", Description: "Do B."}, | 112 | + | {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"}, |
| 113 | } | 113 | } | ||
| 114 | out := SystemPromptAddendum(skills) | 114 | out := SystemPromptAddendum(skills) | ||
| 115 | - | if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) { | 115 | + | if !contains(out, "- built-a [builtin]: Do A.\n") { |
| 116 | - | t.Errorf("addendum missing entries:\n%s", out) | 116 | + | t.Errorf("builtin entry missing or wrong:\n%s", out) |
| 117 | + | } | |||
| 118 | + | if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") { | |||
| 119 | + | t.Errorf("user entry missing path pointer:\n%s", out) | |||
| 117 | } | 120 | } | ||
| 118 | } | 121 | } | ||
| 119 | 122 | ||||