Newer
Older
navi-1 / navi / skills / models.py
"""Declarative skills and protocols: format, validation, rendering.

A skill or protocol is one document: a YAML front matter block followed by a
markdown body. The front matter carries what the catalog needs (name, purpose,
triggers, tags, area, and — for protocols — the steps and their escape hatches);
the body is the instruction text the agent reads when it loads the entry.

Two kinds live in one registry because they share a catalog, search, versioning
and ownership. They differ in what they must declare:

- **skill** — flexible knowledge about an approach: when it is useful and how to
  go about it. Nothing is mandatory beyond a name, a purpose and a body.
- **protocol** — a strict order of actions. Steps, post-checks and the three
  escape hatches are mandatory: what to do when the run drifts off plan, when it
  cannot be completed, and when a step turns out to be already done. A protocol
  the agent cannot act on when reality differs from the plan is worse than no
  protocol at all, so validation refuses it.

Validation messages are English and agent-facing: the same text reaches the
model when it tries to save a malformed document.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from typing import Any

import yaml

KINDS = ("skill", "protocol")
AREAS = ("home", "infrastructure", "workflows", "general")
VISIBILITIES = ("personal", "shared")

# Mandatory front matter keys per kind. `steps` and the three handlers are the
# part that makes a protocol a protocol; a skill needs none of them.
_PROTOCOL_REQUIRED = ("steps", "post_checks", "on_deviation", "on_blocked", "on_already_done")

_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.S)

_MAX_DOC_CHARS = 200_000


class SkillError(ValueError):
    """A document cannot be used; the message is meant for the caller."""


@dataclass
class SkillDoc:
    kind: str
    name: str
    description: str = ""
    triggers: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    area: str = "general"
    steps: list[str] = field(default_factory=list)
    preconditions: list[str] = field(default_factory=list)
    post_checks: list[str] = field(default_factory=list)
    on_deviation: str = ""
    on_blocked: str = ""
    on_already_done: str = ""
    body: str = ""
    extra: dict[str, Any] = field(default_factory=dict)

    def frontmatter(self) -> dict[str, Any]:
        """The searchable metadata as stored in the JSONB column."""
        return {
            "kind": self.kind,
            "name": self.name,
            "description": self.description,
            "triggers": self.triggers,
            "tags": self.tags,
            "area": self.area,
            "steps": self.steps,
            "preconditions": self.preconditions,
            "post_checks": self.post_checks,
            "on_deviation": self.on_deviation,
            "on_blocked": self.on_blocked,
            "on_already_done": self.on_already_done,
            **self.extra,
        }


def content_hash(content: str) -> str:
    return hashlib.sha256(content.encode("utf-8")).hexdigest()


def _as_list(value: Any) -> list[str]:
    if value is None or value == "":
        return []
    if isinstance(value, str):
        return [value.strip()] if value.strip() else []
    if isinstance(value, (list, tuple)):
        return [str(v).strip() for v in value if str(v).strip()]
    raise SkillError(f"expected a list of strings, got {type(value).__name__}")


def _as_text(value: Any) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return value.strip()
    raise SkillError(f"expected a string, got {type(value).__name__}")


def parse_document(content: str, *, kind: str | None = None) -> SkillDoc:
    """Parse and validate one document.

    ``kind`` overrides the front matter (used when the caller already knows
    whether it is creating a skill or a protocol). The front matter's own
    ``kind`` otherwise decides; neither present is an error.
    """
    if not isinstance(content, str) or not content.strip():
        raise SkillError("document is empty")
    if len(content) > _MAX_DOC_CHARS:
        raise SkillError(f"document is too large (limit {_MAX_DOC_CHARS} characters)")

    match = _FRONTMATTER.match(content)
    if not match:
        raise SkillError(
            "document must start with a YAML front matter block delimited by '---' lines"
        )
    try:
        meta = yaml.safe_load(match.group(1)) or {}
    except yaml.YAMLError as e:
        raise SkillError(f"invalid YAML front matter: {e}") from e
    if not isinstance(meta, dict):
        raise SkillError("YAML front matter must be a mapping of keys to values")

    body = content[match.end():].strip()

    resolved_kind = (kind or meta.get("kind") or "").strip().lower()
    if not resolved_kind:
        raise SkillError("'kind' is required: 'skill' or 'protocol'")
    if resolved_kind not in KINDS:
        raise SkillError(f"'kind' must be one of {', '.join(KINDS)}; got {resolved_kind!r}")
    if kind and meta.get("kind") and str(meta["kind"]).strip().lower() != resolved_kind:
        raise SkillError(
            f"front matter says kind={meta['kind']!r} but the request says {kind!r}"
        )

    name = _as_text(meta.get("name"))
    if not name:
        raise SkillError("'name' is required in the front matter")

    description = _as_text(meta.get("description"))
    area = _as_text(meta.get("area")) or "general"
    if area not in AREAS:
        raise SkillError(f"'area' must be one of {', '.join(AREAS)}; got {area!r}")

    known = {
        "kind", "name", "description", "triggers", "tags", "area",
        "steps", "preconditions", "post_checks", "on_deviation",
        "on_blocked", "on_already_done",
    }
    doc = SkillDoc(
        kind=resolved_kind,
        name=name,
        description=description,
        triggers=_as_list(meta.get("triggers")),
        tags=_as_list(meta.get("tags")),
        area=area,
        steps=_as_list(meta.get("steps")),
        preconditions=_as_list(meta.get("preconditions")),
        post_checks=_as_list(meta.get("post_checks")),
        on_deviation=_as_text(meta.get("on_deviation")),
        on_blocked=_as_text(meta.get("on_blocked")),
        on_already_done=_as_text(meta.get("on_already_done")),
        body=body,
        extra={k: v for k, v in meta.items() if k not in known},
    )

    if doc.kind == "skill":
        if not doc.body:
            raise SkillError("a skill needs a body (the instructions below the front matter)")
        return doc

    missing = [
        key for key in _PROTOCOL_REQUIRED
        if not getattr(doc, key, None)
    ]
    if missing:
        raise SkillError(
            "a protocol must declare " + ", ".join(f"'{m}'" for m in missing) +
            " in the front matter — steps, how each step is checked, and what to "
            "do when the run drifts off plan, cannot be completed, or meets a step "
            "that is already done"
        )
    return doc


def render_for_agent(
    doc: SkillDoc,
    *,
    entry_id: str = "",
    version: int = 0,
    visibility: str = "personal",
) -> str:
    """The text the agent reads after loading an entry.

    Compact on purpose: the model already has the conversation; this is the
    methodology or the ordered steps, not a second copy of the catalog.
    """
    lines: list[str] = []
    title = "PROTOCOL" if doc.kind == "protocol" else "SKILL"
    lines.append(f"{title}: {doc.name}" + (f" (v{version})" if version else ""))
    if doc.description:
        lines.append(doc.description)
    meta_bits = []
    if doc.area:
        meta_bits.append(f"area: {doc.area}")
    if doc.tags:
        meta_bits.append("tags: " + ", ".join(doc.tags))
    if doc.triggers:
        meta_bits.append("use when: " + "; ".join(doc.triggers))
    if meta_bits:
        lines.append(" | ".join(meta_bits))

    if doc.kind == "protocol":
        if doc.preconditions:
            lines.append("")
            lines.append("Preconditions:")
            lines.extend(f"- {p}" for p in doc.preconditions)
        lines.append("")
        lines.append("Steps (follow in order):")
        lines.extend(f"{i}. {s}" for i, s in enumerate(doc.steps, 1))
        lines.append("")
        lines.append("After each step:" if len(doc.post_checks) == 1 else "Post-checks:")
        lines.extend(f"- {c}" for c in doc.post_checks)
        lines.append("")
        lines.append("If the run goes off plan: " + doc.on_deviation)
        lines.append("If the protocol cannot be completed: " + doc.on_blocked)
        lines.append("If a step is already done: " + doc.on_already_done)
        if doc.body:
            lines.append("")
            lines.append(doc.body)
    else:
        if doc.body:
            lines.append("")
            lines.append(doc.body)

    if entry_id:
        lines.append("")
        lines.append(f"[{doc.kind} id: {entry_id}]")
    return "\n".join(lines)