mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 16:23:18 -10:00
* feat(skills): install a skill's bundled files and point the skill tool at them A skill was only its SKILL.md: install copied that one file, and the skill tool returned only the markdown body, so a skill whose instructions run its own scripts was broken by installing it, and a hand-placed skill directory gave the model no way to find the files beside it. Install now copies the whole skill directory, leaving out hidden entries (.git, .env) and symlinks, which it neither follows nor copies but reports. A script stays executable; everything else is installed 0644. A tree over 1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the SKILL.md hash; skill info reports drift in bundled files, and an entry from before this is still checked on SKILL.md alone. A reinstall that changed only a bundled file is reported as an update. Both skill tools end their output with the skill's directory and a list of up to 50 bundled files when there are any; a SKILL.md-only skill is returned unchanged. Closes #584 * fix(skills): compare a reinstall over an old lock entry with SKILL.md alone A lock entry written before tree hashes has no treeHash, so the reinstall compared the new tree hash with an empty string and always counted as an update. Re-adding an unchanged skill that is only a SKILL.md printed "Updated" and "the files bundled with it changed" when there were none. That entry comes from an install that copied SKILL.md alone, so compare with that: it is an update when SKILL.md changed or the reinstall now brings bundled files. InstallResult gains previousTreeHash, and the CLI uses it to say the bundled files are now installed rather than changed on the first reinstall over such an entry. * fix(skills): match the tree hash when recovering an interrupted install lockReconciler matched both trees of an interrupted workspace on the SKILL.md hash alone. A reinstall that changes only a bundled file keeps SKILL.md, so a kill between the tree swap and the lockfile publish left two trees carrying the recorded hash, and recovery kept the new one and retired the backup the lockfile still described. A tree now matches an entry with a tree hash only when both hashes match. An entry from before tree hashes is still matched on SKILL.md alone. A tree hash that cannot be computed is returned as an error, the way an unreadable SKILL.md already is, so recovery decides nothing about a tree it could not read. Also bring the package comment up to date: install copies the skill directory, not SKILL.md alone.
82 lines
2.8 KiB
Go
82 lines
2.8 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/Twigpine/zero/internal/skills"
|
|
)
|
|
|
|
// skillTool lets the model pull a reusable instruction "skill" into context on
|
|
// demand (PRD F15). It reads the skills directory itself via the internal/skills
|
|
// loader and returns the named skill's markdown body as its Output, so the model
|
|
// can opt into reusable guidance only when relevant. It is read-only.
|
|
type skillTool struct {
|
|
baseTool
|
|
dir string
|
|
}
|
|
|
|
// NewSkillTool builds the skill tool. An empty dir resolves to the standard
|
|
// skills data directory (skills.DefaultDir); pass an explicit dir in tests.
|
|
func NewSkillTool(dir string) *skillTool {
|
|
if strings.TrimSpace(dir) == "" {
|
|
dir = skills.DefaultDir(nil)
|
|
}
|
|
return &skillTool{
|
|
dir: dir,
|
|
baseTool: baseTool{
|
|
name: "skill",
|
|
description: "Load a named Zero skill and return its instructions as the tool output. " +
|
|
"Skills are reusable, on-demand instruction sets (e.g. project conventions or confirmation policies). " +
|
|
"Call this when a relevant skill exists; an unknown name returns the list of available skills.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"name": {
|
|
Type: "string",
|
|
Description: "The name of the skill to load.",
|
|
},
|
|
"skill": {
|
|
Type: "string",
|
|
Description: "Alias for name; supply either name or skill.",
|
|
},
|
|
},
|
|
// Intentionally no strict Required: the tool needs exactly one of
|
|
// name/skill, which Run enforces via aliasedStringArg. Declaring both
|
|
// here keeps the alias usable under schema validators that reject
|
|
// unknown keys (AdditionalProperties:false).
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
|
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run loads the named skill and returns its Content. Unknown names return a
|
|
// clear error listing the available skill names so the model can self-correct.
|
|
func (tool *skillTool) Run(_ context.Context, args map[string]any) Result {
|
|
name, err := aliasedStringArg(args, []string{"name", "skill"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for skill: " + err.Error())
|
|
}
|
|
|
|
loaded, err := skills.Load(tool.dir)
|
|
if err != nil {
|
|
return errorResult("Error: failed to load skills: " + err.Error())
|
|
}
|
|
if len(loaded) == 0 {
|
|
return errorResult(fmt.Sprintf("Error: no skills are available (looked in %s).", tool.dir))
|
|
}
|
|
|
|
names := make([]string, 0, len(loaded))
|
|
for _, skill := range loaded {
|
|
if skill.Name == name {
|
|
return okResult(skills.ToolOutput(skill))
|
|
}
|
|
names = append(names, skill.Name)
|
|
}
|
|
return errorResult(fmt.Sprintf("Error: unknown skill %q. Available skills: %s.", name, strings.Join(names, ", ")))
|
|
}
|