Files
krow_backend/go-api/internal/definition/skill.go
2026-08-24 13:06:29 +05:30

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
}