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).
This commit is contained in:
patriceckhart 2026-04-19 17:24:05 +02:00
parent 1a2ab427fe
commit 5cc54822cd
2 changed files with 43 additions and 8 deletions

View file

@ -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 {

View file

@ -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)
}
}