Files
LocalAI/core/services/studio/skills/parse.go
T
Ettore Di Giacinto 24c837e453 feat(studio): grant built-in capabilities to a saved agent
A saved agent now has a Builtins list: the capabilities its owner gave
it (memory.write, profile.write, skills.write, pages.write,
dashboard.write, triggers.propose, triggers.run, talkback.channels,
talkback.new_channel, files.upload, act.files, act.addons, act.send,
act.spend and the opt-in group saved_agents). The list is empty by
default, and nothing turns a grant on for a template, an import or a
write that omits the field.

A chat of the agent holds the grants the user may use at the turn
(agent scope). A tool exists when the grant is held, the chat did not
switch its group off and the run can hold rights: a sub-agent or a run
that started by itself never does. propose_change and revert_change
now exist only for a chat that holds the grant of at least one kind,
and the schema lists only those kinds.

propose_change gains the kinds skill, page and dashboard. A skill is a
SKILL.md for the personal or the agent layer, and a skill with
locked: true is refused, also as a shadow of a locked personal skill.
A page is a set of files checked against the Pages rules and written
as one change set. The dashboard is a home.json checked against its
schema. All three are re-checked when the owner confirms or edits.

GET /api/chats/agents/builtin-grants lists each grant with its title,
description and sensitive flag. The agent record, the write body and
the export file carry builtins; an old file imports as before and an
unknown name is dropped in an import and refused in a write.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
2026-11-03 14:56:42 +00:00

237 lines
6.5 KiB
Go

package skills
import (
"errors"
"regexp"
"slices"
"strings"
"unicode"
)
// Limits of one SKILL.md.
const (
// MaxFileSize is the largest SKILL.md that is read, in bytes.
MaxFileSize = 64 << 10
// MaxDescription is the longest description, in bytes after cleaning.
MaxDescription = 1024
)
// Reasons that Parse returns. Their text is what the product shows as the
// reason a skill is invalid.
var (
ErrNoFrontmatter = errors.New("no frontmatter")
ErrNoName = errors.New("no name")
ErrBadName = errors.New("bad name")
ErrNoDescription = errors.New("no description")
ErrDescriptionTooLong = errors.New("description too long")
ErrTooLarge = errors.New("too large")
)
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// ValidName reports whether s can be the name of a skill.
func ValidName(s string) bool { return nameRE.MatchString(s) }
// Parse reads the frontmatter of a SKILL.md and returns the name and the
// cleaned description.
//
// The frontmatter is the text between a first line "---" and the next line
// "---". Parse accepts the subset of YAML that skills use: "key: value"
// lines, values in single or double quotes, and a block scalar ("|" or ">",
// with an optional "-" or "+") for any key. Indented lines that follow a
// plain value, comments and unknown keys are ignored. The last value of a
// repeated key wins.
//
// The description loses control characters and invisible format characters
// (Unicode category Cf, which includes bidi overrides), and every run of
// white space becomes one space. The error is one of the Err values of this
// package.
func Parse(data []byte) (name, description string, err error) {
if len(data) > MaxFileSize {
return "", "", ErrTooLarge
}
fm, ok := frontmatter(string(data))
if !ok {
return "", "", ErrNoFrontmatter
}
kv := keyValues(fm)
name = strings.TrimSpace(kv["name"])
if name == "" {
return "", "", ErrNoName
}
if !nameRE.MatchString(name) {
return "", "", ErrBadName
}
description = Clean(kv["description"])
switch {
case description == "":
return "", "", ErrNoDescription
case len(description) > MaxDescription:
return "", "", ErrDescriptionTooLong
}
return name, description, nil
}
// Capabilities a skill can require. A product that cannot give one marks the
// skill unavailable instead of listing it as usable.
const (
// RequireExec is a way to run commands or code (a sandbox).
RequireExec = "exec"
)
var capabilityRE = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
// Requires reads the optional "requires" key of the frontmatter: the
// capabilities the procedure needs, as a flow list ("[exec]") or words split
// by commas or spaces. Values that are not capability words are dropped, so a
// typo never hides a skill. A file without frontmatter requires nothing.
func Requires(data []byte) []string {
if len(data) > MaxFileSize {
return nil
}
fm, ok := frontmatter(string(data))
if !ok {
return nil
}
v := strings.TrimSpace(keyValues(fm)["requires"])
v = strings.TrimSuffix(strings.TrimPrefix(v, "["), "]")
var out []string
for _, f := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) {
f = strings.ToLower(strings.Trim(f, `"'`))
if capabilityRE.MatchString(f) && !slices.Contains(out, f) {
out = append(out, f)
}
}
return out
}
// Locked reads the optional "locked" key of the frontmatter: the owner's mark
// that a skill is theirs to change and no agent may propose an edit of it. Only
// the value true (any case) locks; a file without frontmatter is not locked.
func Locked(data []byte) bool {
if len(data) > MaxFileSize {
return false
}
fm, ok := frontmatter(string(data))
if !ok {
return false
}
return strings.EqualFold(strings.TrimSpace(keyValues(fm)["locked"]), "true")
}
// Clean drops invalid UTF-8, control characters and format characters from s,
// turns every run of white space into one space and trims the ends.
func Clean(s string) string {
s = strings.ToValidUTF8(s, "")
var b strings.Builder
b.Grow(len(s))
pending := false // a space is owed before the next visible rune
for _, r := range s {
switch {
case unicode.IsSpace(r):
pending = true
case unicode.IsControl(r) || unicode.In(r, unicode.Cf):
// dropped
default:
if pending && b.Len() > 0 {
b.WriteByte(' ')
}
pending = false
b.WriteRune(r)
}
}
return b.String()
}
// frontmatter returns the lines between the first "---" line and the next one.
func frontmatter(s string) ([]string, bool) {
s = strings.TrimPrefix(s, "\xef\xbb\xbf")
s = strings.ReplaceAll(s, "\r\n", "\n")
lines := strings.Split(s, "\n")
if len(lines) == 0 || strings.TrimRight(lines[0], " \t") != "---" {
return nil, false
}
for i := 1; i < len(lines); i++ {
if strings.TrimRight(lines[i], " \t") == "---" {
return lines[1:i], true
}
}
return nil, false
}
var keyRE = regexp.MustCompile(`^([A-Za-z0-9_-]+):(?:[ \t]+(.*)|[ \t]*)$`)
// keyValues reads the top-level "key: value" lines.
func keyValues(lines []string) map[string]string {
out := map[string]string{}
for i := 0; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" || line[0] == ' ' || line[0] == '\t' || line[0] == '#' {
continue
}
m := keyRE.FindStringSubmatch(line)
if m == nil {
continue
}
key, val := m[1], strings.TrimSpace(m[2])
if style, ok := blockStyle(val); ok {
var block []string
for i+1 < len(lines) {
next := lines[i+1]
if strings.TrimSpace(next) != "" && next[0] != ' ' && next[0] != '\t' {
break
}
block = append(block, strings.TrimSpace(next))
i++
}
sep := "\n"
if style == '>' {
sep = " "
}
out[key] = strings.Join(block, sep)
continue
}
out[key] = unquote(val)
}
return out
}
// blockStyle reports whether v starts a block scalar: "|" or ">" with an
// optional chomping sign.
func blockStyle(v string) (byte, bool) {
switch v {
case "|", "|-", "|+", ">", ">-", ">+":
return v[0], true
}
return 0, false
}
// unquote removes one pair of matching quotes. A value with an unmatched
// quote is kept as written.
func unquote(v string) string {
if len(v) < 2 || (v[0] != '"' && v[0] != '\'') || v[len(v)-1] != v[0] {
return v
}
inner := v[1 : len(v)-1]
if v[0] == '\'' {
return strings.ReplaceAll(inner, "''", "'")
}
var b strings.Builder
for i := 0; i < len(inner); i++ {
if inner[i] == '\\' && i+1 < len(inner) {
switch inner[i+1] {
case '"', '\\':
b.WriteByte(inner[i+1])
i++
continue
case 'n', 't':
b.WriteByte(' ')
i++
continue
}
}
b.WriteByte(inner[i])
}
return b.String()
}