350 lines
12 KiB
Go
350 lines
12 KiB
Go
package definition
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// One Markdown definition → one skill.
|
|
//
|
|
// A port of parseSkill and validateSkillSource in src/lib/skills/registry.js,
|
|
// restricted to the definition contract — see the package documentation in
|
|
// definition.go for exactly where that boundary is and why the `ui:` and
|
|
// `owliver:` blocks are on the other side of it.
|
|
|
|
// Level is one rung of a workforce ladder, read from the body's own headings.
|
|
type Level struct {
|
|
Level string `json:"level"`
|
|
Label string `json:"label"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// Skill is a definition as the backend reads it.
|
|
//
|
|
// The five fields migration 000005 projects into columns — ID, Name,
|
|
// Description, Status, Pages — are the compatibility contract; the rest is
|
|
// carried because it is free once the frontmatter is parsed and because the
|
|
// conformance suite compares it.
|
|
type Skill struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
|
|
// Pages as the AUTHOR WROTE THEM, not canonicalised.
|
|
//
|
|
// This is not an oversight and must not be "fixed": parseSkill keeps the
|
|
// declared strings, so a skill written against `Talent Pool` is registered
|
|
// under `Talent Pool` and resolved through normalizeKey at every use.
|
|
// Agents are the other way round — see Agent.Pages. Canonicalising here
|
|
// would make the backend's projection disagree with the editor's.
|
|
Pages []string `json:"pages"`
|
|
|
|
Kind string `json:"kind"`
|
|
Category string `json:"category"`
|
|
Actions []string `json:"actions"`
|
|
Triggers []string `json:"triggers"`
|
|
DeclaredTriggers bool `json:"declaredTriggers"`
|
|
Prompt *string `json:"prompt"`
|
|
SkillID *string `json:"skillId"`
|
|
Levels []Level `json:"levels"`
|
|
|
|
// Body is the Markdown after the frontmatter, trimmed. The definition
|
|
// itself is NEVER rewritten — see Definition.Markdown.
|
|
Body string `json:"-"`
|
|
|
|
// Deferred names the frontmatter blocks whose semantics this package does
|
|
// not check and the frontend does. Empty for every definition the backend
|
|
// can fully validate on its own. See package documentation.
|
|
Deferred []string `json:"deferred,omitempty"`
|
|
}
|
|
|
|
// AuthoredPath is the origin an authored definition has when the caller names
|
|
// none. It is a value rather than an absence for one reason: it is the
|
|
// frontend's own default parameter.
|
|
//
|
|
// parseSkill(raw, { path = 'custom', custom = false } = {})
|
|
// parseAgent(raw, { path = 'custom', custom = false } = {})
|
|
//
|
|
// validateSkillSource and validateAgentSource both call their parser with no
|
|
// path, so every definition the EDITOR checks derives its last-resort id from
|
|
// the literal string `custom`. That is the same call the backend is making — a
|
|
// definition submitted to the API is authored, not shipped — so the backend
|
|
// must derive the same id.
|
|
//
|
|
// The difference is reachable and it is not cosmetic. A definition with no
|
|
// `id:`, no `name:` and a valid `pages:` list gets the id `custom` on the
|
|
// frontend, passes the id-format check and is ACCEPTED. Deriving no id here
|
|
// would refuse it with "The frontmatter needs an `id`." — a definition that
|
|
// validates in the editor and fails on save, which is the exact failure mode
|
|
// this package exists to prevent. Fixture case: id-omitted-unnamed.
|
|
const AuthoredPath = "custom"
|
|
|
|
// Options carries what the caller knows that the definition does not.
|
|
type Options struct {
|
|
// Path is the definition's origin, used only as the last fallback for an
|
|
// id. Leave it empty for anything authored rather than shipped — which is
|
|
// what the backend always has — and it becomes AuthoredPath, exactly as the
|
|
// frontend's default parameter does.
|
|
Path string
|
|
}
|
|
|
|
// path is the origin an id is derived from, with the frontend's default
|
|
// applied.
|
|
func (o Options) path() string {
|
|
if o.Path == "" {
|
|
return AuthoredPath
|
|
}
|
|
return o.Path
|
|
}
|
|
|
|
// ParseSkill reads a skill definition.
|
|
//
|
|
// Returns a *Error when the frontmatter cannot be read. A definition with no
|
|
// frontmatter at all is not an error here: it parses to a skill carrying the
|
|
// derived id and no pages, and ValidateSkill is what refuses it — the same
|
|
// division of labour the frontend has.
|
|
func ParseSkill(raw string, opts Options) (*Skill, error) {
|
|
doc, err := ParseFrontmatter(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data := doc.Data
|
|
|
|
declaredPages, _ := data["pages"].([]any)
|
|
|
|
// `id:` always wins. An explicit id is the address other definitions and
|
|
// stored preferences refer to, and deriving over the top of one would
|
|
// silently rename a skill. Slugging the name is what an author means by
|
|
// leaving it out; the filename is right only for a file, which is why it
|
|
// is last.
|
|
id := jsTrim(jsString(data["id"]))
|
|
if !jsTruthy(data["id"]) {
|
|
id = slugify(data["name"])
|
|
if id == "" {
|
|
id = fileStem(opts.path())
|
|
}
|
|
}
|
|
|
|
levels := sectionLevels(doc.Body)
|
|
|
|
// Two things wear the same format. A definition that names a ladder is a
|
|
// workforce skill; nothing else distinguishes them, so an author declares
|
|
// one by writing one rather than by setting a flag.
|
|
kind := jsTrim(jsString(data["kind"]))
|
|
if !jsTruthy(data["kind"]) {
|
|
kind = "assistant"
|
|
if len(levels) > 0 {
|
|
kind = "workforce"
|
|
}
|
|
}
|
|
|
|
skill := &Skill{
|
|
ID: id,
|
|
Kind: kind,
|
|
Levels: levels,
|
|
Body: doc.Body,
|
|
Name: "Untitled skill",
|
|
Pages: stringsOf(declaredPages),
|
|
}
|
|
|
|
if jsTruthy(data["name"]) {
|
|
skill.Name = jsString(data["name"])
|
|
}
|
|
if jsTruthy(data["description"]) {
|
|
skill.Description = jsString(data["description"])
|
|
}
|
|
if s, ok := data["category"].(string); ok {
|
|
skill.Category = jsTrim(s)
|
|
}
|
|
|
|
// The whole of a skill's lifecycle, and deliberately a coercion rather
|
|
// than a check: the frontend reads anything that is not `inactive` as
|
|
// `active`, so `status: bogus` registers as active rather than being
|
|
// refused. Reproduced, not corrected — see the divergence note in
|
|
// docs/phase-4d-parser-contract.md.
|
|
skill.Status = "active"
|
|
if s, ok := data["status"].(string); ok && s == "inactive" {
|
|
skill.Status = "inactive"
|
|
}
|
|
|
|
if actions, ok := data["actions"].([]any); ok {
|
|
skill.Actions = stringsOf(actions)
|
|
} else {
|
|
skill.Actions = []string{}
|
|
}
|
|
|
|
// A skill with no declared triggers answers to its own name, so a
|
|
// definition that omits the field is still reachable by asking for it.
|
|
// Explicit triggers replace the fallback rather than adding to it.
|
|
triggers, hasTriggers := data["triggers"].([]any)
|
|
skill.DeclaredTriggers = hasTriggers && len(triggers) > 0
|
|
skill.Triggers = []string{}
|
|
if skill.DeclaredTriggers {
|
|
for _, t := range triggers {
|
|
skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(t)))
|
|
}
|
|
} else if jsTruthy(data["name"]) {
|
|
skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(data["name"])))
|
|
}
|
|
|
|
if jsTruthy(data["prompt"]) {
|
|
p := jsString(data["prompt"])
|
|
skill.Prompt = &p
|
|
}
|
|
|
|
// The capability in the skill graph a workforce definition governs:
|
|
// `skill:`, or the id with a `-training` suffix dropped and dashes swapped
|
|
// for underscores.
|
|
if kind == "workforce" {
|
|
base := id
|
|
if jsTruthy(data["skill"]) {
|
|
base = jsString(data["skill"])
|
|
} else {
|
|
base = strings.TrimSuffix(base, "-training")
|
|
}
|
|
s := strings.ReplaceAll(base, "-", "_")
|
|
skill.SkillID = &s
|
|
}
|
|
|
|
skill.Deferred = deferredBlocks(data)
|
|
return skill, nil
|
|
}
|
|
|
|
// sectionLevels reads the ladder a workforce definition defines, in order, from
|
|
// the body's own headings. A rung with no prose is not a rung.
|
|
func sectionLevels(body string) []Level {
|
|
out := []Level{}
|
|
for _, heading := range levelHeadings {
|
|
summary := SectionText(body, heading)
|
|
if summary == "" {
|
|
continue
|
|
}
|
|
out = append(out, Level{
|
|
Level: strings.ToLower(heading),
|
|
Label: heading,
|
|
Summary: summary,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// deferredBlocks names the frontmatter this package does not semantically
|
|
// check. See the package documentation for why they are deferred rather than
|
|
// validated or rejected.
|
|
func deferredBlocks(data map[string]any) []string {
|
|
out := []string{}
|
|
for _, key := range []string{"ui", "owliver"} {
|
|
if _, present := data[key]; present {
|
|
out = append(out, key)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// stringsOf renders a parsed sequence as the strings the frontend would read
|
|
// out of it. Non-string entries are stringified rather than dropped, because
|
|
// that is what every consumer of `pages` and `actions` does with them.
|
|
func stringsOf(list []any) []string {
|
|
out := make([]string, 0, len(list))
|
|
for _, v := range list {
|
|
out = append(out, jsString(v))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fileStem(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
|
path = path[i+1:]
|
|
}
|
|
return strings.TrimSuffix(path, ".md")
|
|
}
|
|
|
|
// ValidateSkill decides whether a skill definition may be stored.
|
|
//
|
|
// Returns nil when it may. The order is the order an author would fix things
|
|
// in, and every message below is the frontend's message character for
|
|
// character — an author who sees one in the editor and a different one from
|
|
// the API is being told about two different problems.
|
|
//
|
|
// Two rules are the backend's own and are marked as such: the size bound and
|
|
// the deferred-block rule. Both are explained in
|
|
// docs/phase-4d-parser-contract.md.
|
|
func ValidateSkill(raw string) error {
|
|
if jsTrim(raw) == "" {
|
|
return &Rejection{Message: "Paste or upload a Markdown definition."}
|
|
}
|
|
|
|
// The backend's own rule, from migration 000005's markdown_size CHECK. The
|
|
// editor does not enforce it, so a definition over the bound is one the
|
|
// frontend accepts and the DATABASE refuses; refusing it here turns a
|
|
// constraint violation into a message. See the contract document.
|
|
if n := len([]rune(raw)); n > MaxMarkdownLength {
|
|
return &Rejection{
|
|
BackendOnly: true,
|
|
Message: fmt.Sprintf(
|
|
"That definition is %d characters. The limit is %d.", n, MaxMarkdownLength),
|
|
}
|
|
}
|
|
|
|
skill, err := ParseSkill(raw, Options{})
|
|
if err != nil {
|
|
// The subset reports the line it failed on, which is far more useful
|
|
// than "could not be parsed".
|
|
return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())}
|
|
}
|
|
|
|
if skill.ID == "" {
|
|
return &Rejection{Message: "The frontmatter needs an `id`."}
|
|
}
|
|
if !isDefinitionID(skill.ID) {
|
|
return &Rejection{Message: "`id` must be lower-case letters, numbers and dashes."}
|
|
}
|
|
// Faithful to the frontend, where `name` has already fallen back to
|
|
// `Untitled skill` and this check can therefore never fire. Kept so the
|
|
// two validators have the same shape and the same order.
|
|
if skill.Name == "" {
|
|
return &Rejection{Message: "The frontmatter needs a `name`."}
|
|
}
|
|
// The backend's own rule, and it must be asked BEFORE the generic one
|
|
// below. A `ui:` block declares the pages it draws on, and parseSkill falls
|
|
// back to those pages when `pages:` is absent — a fallback this package
|
|
// cannot compute, because it does not read the `ui:` vocabulary. Rather
|
|
// than report an empty page list it never really established, say what is
|
|
// actually missing. No shipped definition relies on the fallback: all
|
|
// nineteen that carry a `ui:` or `owliver:` block also declare `pages:`.
|
|
if len(skill.Pages) == 0 && len(skill.Deferred) > 0 {
|
|
return &Rejection{
|
|
BackendOnly: true,
|
|
Message: "A definition with a `ui:` block needs an explicit `pages:` list.",
|
|
}
|
|
}
|
|
if len(skill.Pages) == 0 {
|
|
return &Rejection{Message: "The frontmatter needs at least one `pages` entry."}
|
|
}
|
|
|
|
unknown := []string{}
|
|
for _, p := range skill.Pages {
|
|
if !SurfaceExists(p) {
|
|
unknown = append(unknown, p)
|
|
}
|
|
}
|
|
if len(unknown) > 0 {
|
|
plural := ""
|
|
if len(unknown) > 1 {
|
|
plural = "s"
|
|
}
|
|
return &Rejection{Message: fmt.Sprintf(
|
|
"Unsupported page%s: %s. Supported pages: %s.",
|
|
plural, strings.Join(unknown, ", "), strings.Join(SupportedPages, ", "))}
|
|
}
|
|
|
|
return nil
|
|
}
|