migration
This commit is contained in:
@@ -1,9 +1,3 @@
|
||||
from .models.registry import DocsRegistry
|
||||
from .models.registry import PromptRecord
|
||||
from .models.registry import PromptSummaryRecord
|
||||
|
||||
__all__ = [
|
||||
"DocsRegistry",
|
||||
"PromptRecord",
|
||||
"PromptSummaryRecord",
|
||||
]
|
||||
__all__ = ["DocsRegistry"]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from importlib.resources.abc import Traversable
|
||||
from itertools import starmap
|
||||
from pathlib import PurePosixPath
|
||||
@@ -17,10 +15,8 @@ class MarkdownDocument:
|
||||
|
||||
relpath: DocsPath
|
||||
"""The relative path of the document within the package resources."""
|
||||
content: str = field(repr=False)
|
||||
content: str
|
||||
"""The raw markdown content of the document."""
|
||||
frontmatter: str | None = field(repr=False, default=None)
|
||||
"""The raw YAML frontmatter of the document, if present."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
|
||||
@@ -34,15 +30,7 @@ class MarkdownDocument:
|
||||
@classmethod
|
||||
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
|
||||
"""Load a markdown document from a package resource."""
|
||||
raw = resource.read_text(encoding="utf-8")
|
||||
frontmatter = get_raw_frontmatter(raw)
|
||||
return cls(relpath=relpath, content=raw, frontmatter=frontmatter)
|
||||
|
||||
@property
|
||||
def prompt_slug(self) -> str | None:
|
||||
parts = self.relpath.parts
|
||||
if parts[0] == "prompts" and len(parts) >= 3:
|
||||
return parts[1]
|
||||
return cls(relpath=relpath, content=resource.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def walk_resources(
|
||||
@@ -59,19 +47,3 @@ def walk_resources(
|
||||
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||
elif child.is_file() and child.name.lower().endswith(suffix):
|
||||
yield relpath, child
|
||||
|
||||
|
||||
def get_raw_frontmatter(raw: str) -> str | None:
|
||||
delimiter = iter(get_frontmatter_delim_idx(raw, delimiter="---"))
|
||||
try:
|
||||
start = next(delimiter) + 1
|
||||
end = next(delimiter)
|
||||
except StopIteration:
|
||||
return None
|
||||
return "\n".join(raw.splitlines()[start:end])
|
||||
|
||||
|
||||
def get_frontmatter_delim_idx(raw: str, *, delimiter: str = "---") -> Generator[int]:
|
||||
for i, line in enumerate(raw.splitlines()):
|
||||
if line.strip().startswith(delimiter):
|
||||
yield i
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources.abc import Traversable
|
||||
from itertools import starmap
|
||||
from typing import Self
|
||||
|
||||
from .document import MarkdownDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptFilesBundle:
|
||||
"""Represents a prompt and all of its associated markdown files."""
|
||||
|
||||
slug: str
|
||||
prompt: MarkdownDocument
|
||||
other: tuple[MarkdownDocument, ...]
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Traversable) -> list[Self]:
|
||||
# Should only be used for testing
|
||||
return list(cls.from_docs(MarkdownDocument.from_root(root).values()))
|
||||
|
||||
@classmethod
|
||||
def from_docs(cls, docs: Iterable[MarkdownDocument]) -> tuple[Self, ...]:
|
||||
return tuple(starmap(cls.from_paths, group_prompt_paths(docs).items()))
|
||||
|
||||
@classmethod
|
||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||
prompt = next(iter(p for p in paths if p.relpath.name == "PROMPT.md"))
|
||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
|
||||
other = tuple(p for p in sorted_paths if p != prompt)
|
||||
return cls(
|
||||
slug=slug,
|
||||
prompt=prompt,
|
||||
other=other,
|
||||
)
|
||||
|
||||
|
||||
def group_prompt_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[MarkdownDocument]]:
|
||||
"""Group prompts from a list of markdown documents by their prompt slug."""
|
||||
grouped: dict[str, set[MarkdownDocument]] = {}
|
||||
for doc in sorted(
|
||||
filter(lambda d: d.prompt_slug is not None, docs),
|
||||
key=lambda d: d.relpath,
|
||||
):
|
||||
if doc.prompt_slug:
|
||||
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
||||
return grouped
|
||||
@@ -1,44 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
from importlib.resources import files
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||
from personal_mcp.registry.models.prompt import StoredPrompt
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
from personal_mcp.registry.models.registry import PromptRecord
|
||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||
|
||||
|
||||
def _build_prompt_record(*, bundle: PromptFilesBundle) -> PromptRecord:
|
||||
stored = StoredPrompt.from_bundle(bundle)
|
||||
metadata = stored.frontmatter.x_personal_mcp
|
||||
|
||||
return PromptRecord(
|
||||
prompt_id=metadata.id,
|
||||
name=stored.frontmatter.name,
|
||||
description=stored.frontmatter.description,
|
||||
version=metadata.version,
|
||||
tags=tuple(metadata.tags),
|
||||
capabilities=tuple(metadata.capabilities),
|
||||
arguments=dict(metadata.arguments),
|
||||
document_uri=f"resource://prompts/{metadata.id}/document",
|
||||
document_relpath=stored.relpath,
|
||||
document_content=stored.content,
|
||||
)
|
||||
|
||||
|
||||
def _build_tag_index_prompts(
|
||||
prompts_in_order: tuple[str, ...],
|
||||
prompts_by_id: dict[str, PromptRecord],
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
tag_index: defaultdict[str, list[str]] = defaultdict(list)
|
||||
for prompt_id in prompts_in_order:
|
||||
for tag in prompts_by_id[prompt_id].tags:
|
||||
tag_index[tag].append(prompt_id)
|
||||
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
|
||||
|
||||
|
||||
@cache
|
||||
@@ -48,28 +14,9 @@ def get_docs_registry() -> DocsRegistry:
|
||||
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
||||
docs = MarkdownDocument.from_root(root)
|
||||
|
||||
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items()}
|
||||
|
||||
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
|
||||
|
||||
prompts_by_id: dict[str, PromptRecord] = {}
|
||||
prompts_in_load_order: list[str] = []
|
||||
for bundle in prompt_bundles:
|
||||
record = _build_prompt_record(bundle=bundle)
|
||||
if record.prompt_id in prompts_by_id:
|
||||
raise ValueError(f"duplicate prompt_id detected: {record.prompt_id}")
|
||||
prompts_by_id[record.prompt_id] = record
|
||||
prompts_in_load_order.append(record.prompt_id)
|
||||
|
||||
prompts_in_order_tuple = tuple(prompts_in_load_order)
|
||||
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items() if relpath.parts[0] != "skills"}
|
||||
|
||||
return DocsRegistry(
|
||||
docs_markdown_by_path=docs_markdown_by_path,
|
||||
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
||||
prompts_by_id=prompts_by_id,
|
||||
prompts_in_load_order=prompts_in_order_tuple,
|
||||
prompts_summary_in_load_order=tuple(
|
||||
PromptSummaryRecord.from_record(prompts_by_id[prompt_id]) for prompt_id in prompts_in_order_tuple
|
||||
),
|
||||
tag_to_prompt_ids=_build_tag_index_prompts(prompts_in_order_tuple, prompts_by_id),
|
||||
)
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import PurePosixPath
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated
|
||||
from typing import ClassVar
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic import ConfigDict
|
||||
|
||||
SKILL_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
SEMVER_RE: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
class StrictFrozenModel(BaseModel):
|
||||
"""Immutable base model with strict field validation rules."""
|
||||
@@ -20,8 +15,6 @@ class StrictFrozenModel(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||
extra="forbid",
|
||||
frozen=True,
|
||||
validate_by_alias=True,
|
||||
validate_by_name=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from .common import SEMVER_RE
|
||||
from .common import SKILL_ID_RE
|
||||
from .common import DocsPath
|
||||
from .common import StrictFrozenModel
|
||||
from .common import frozen_mapping
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||
|
||||
|
||||
class PromptArgumentEntry(StrictFrozenModel):
|
||||
"""Schema for a single prompt argument definition."""
|
||||
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
required: bool = False
|
||||
|
||||
|
||||
class PromptMetadata(StrictFrozenModel):
|
||||
"""Canonical metadata describing a prompt contract and arguments."""
|
||||
|
||||
id: str
|
||||
version: str
|
||||
tags: tuple[str, ...] = ()
|
||||
capabilities: tuple[str, ...] = Field(min_length=1)
|
||||
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, value: str) -> str:
|
||||
if not SKILL_ID_RE.fullmatch(value):
|
||||
raise ValueError("id must be lowercase kebab-case and start with a letter")
|
||||
return value
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version(cls, value: str) -> str:
|
||||
if not SEMVER_RE.fullmatch(value):
|
||||
raise ValueError("version must be semver")
|
||||
return value
|
||||
|
||||
@field_validator("tags")
|
||||
@classmethod
|
||||
def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
for tag in value:
|
||||
if not SKILL_ID_RE.fullmatch(tag):
|
||||
raise ValueError(f"invalid tag: {tag}")
|
||||
return value
|
||||
|
||||
@field_validator("arguments", mode="before")
|
||||
@classmethod
|
||||
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
|
||||
return frozen_mapping(value)
|
||||
|
||||
@field_validator("arguments")
|
||||
@classmethod
|
||||
def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]:
|
||||
for name in value:
|
||||
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
||||
raise ValueError(f"invalid prompt argument name: {name}")
|
||||
return value
|
||||
|
||||
|
||||
class PromptFrontmatter(StrictFrozenModel):
|
||||
"""Parsed PROMPT frontmatter including personal-mcp metadata."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
description: str = Field(min_length=1, max_length=1024)
|
||||
x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str) -> str:
|
||||
if not SKILL_ID_RE.fullmatch(value):
|
||||
raise ValueError("name must be lowercase kebab-case and start with a letter")
|
||||
if "anthropic" in value or "claude" in value:
|
||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_raw_yaml(cls, raw: str | None) -> "PromptFrontmatter":
|
||||
if raw is None:
|
||||
raise ValueError("missing YAML frontmatter")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("frontmatter must parse to an object")
|
||||
return cls.model_validate(data)
|
||||
|
||||
|
||||
class StoredPrompt(StrictFrozenModel):
|
||||
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
|
||||
|
||||
prompt_id: str
|
||||
relpath: DocsPath
|
||||
content: str
|
||||
frontmatter: PromptFrontmatter
|
||||
|
||||
@field_validator("frontmatter", mode="before")
|
||||
@classmethod
|
||||
def parse_frontmatter_yaml(cls, value: PromptFrontmatter | str | None) -> PromptFrontmatter:
|
||||
if isinstance(value, PromptFrontmatter):
|
||||
return value
|
||||
return PromptFrontmatter.from_raw_yaml(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contract(self) -> "StoredPrompt":
|
||||
parts = self.relpath.parts
|
||||
if len(parts) < 3 or parts[0] != "prompts":
|
||||
raise ValueError("prompt relpath must be under prompts/<slug>/")
|
||||
|
||||
prompt_dir_name = parts[1]
|
||||
if self.frontmatter.name != prompt_dir_name:
|
||||
raise ValueError("frontmatter name must exactly match prompt directory name")
|
||||
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
|
||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||
|
||||
expected_capability = f"resource://prompts/{self.frontmatter.name}/document"
|
||||
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
|
||||
raise ValueError(f"capabilities must include {expected_capability}")
|
||||
|
||||
if self.prompt_id != self.frontmatter.x_personal_mcp.id:
|
||||
raise ValueError("prompt_id must exactly match x-personal-mcp.id")
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_bundle(cls, bundle: "PromptFilesBundle") -> "StoredPrompt":
|
||||
frontmatter = PromptFrontmatter.from_raw_yaml(bundle.prompt.frontmatter)
|
||||
return cls.model_validate(
|
||||
{
|
||||
"prompt_id": frontmatter.x_personal_mcp.id,
|
||||
"relpath": bundle.prompt.relpath,
|
||||
"content": bundle.prompt.content,
|
||||
"frontmatter": frontmatter,
|
||||
}
|
||||
)
|
||||
@@ -6,105 +6,21 @@ from pydantic import field_validator
|
||||
from .common import DocsPath
|
||||
from .common import StrictFrozenModel
|
||||
from .common import frozen_mapping
|
||||
from .prompt import PromptArgumentEntry
|
||||
|
||||
|
||||
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
||||
return frozen_mapping()
|
||||
|
||||
|
||||
class PromptRecord(StrictFrozenModel):
|
||||
"""Registry record containing a fully resolved prompt document."""
|
||||
|
||||
prompt_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
||||
document_uri: str
|
||||
document_relpath: DocsPath
|
||||
document_content: str
|
||||
|
||||
@field_validator("arguments", mode="before")
|
||||
@classmethod
|
||||
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
|
||||
return frozen_mapping(value)
|
||||
|
||||
|
||||
class PromptSummaryRecord(StrictFrozenModel):
|
||||
"""Compact prompt summary exposed by catalog listing APIs."""
|
||||
|
||||
prompt_id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
document_uri: str
|
||||
version: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryRecord":
|
||||
return cls(
|
||||
prompt_id=record.prompt_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=record.tags,
|
||||
capabilities=record.capabilities,
|
||||
document_uri=record.document_uri,
|
||||
version=record.version,
|
||||
)
|
||||
|
||||
|
||||
class PromptSummaryPayload(StrictFrozenModel):
|
||||
"""Catalog payload model for prompt index summaries."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
capabilities: list[str]
|
||||
version: str
|
||||
document_uri: str
|
||||
detail_uri: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryPayload":
|
||||
return cls(
|
||||
id=record.prompt_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=list(record.tags),
|
||||
capabilities=list(record.capabilities),
|
||||
version=record.version,
|
||||
document_uri=record.document_uri,
|
||||
detail_uri=f"resource://catalog/prompts/{record.prompt_id}",
|
||||
)
|
||||
|
||||
|
||||
class DocsRegistry(StrictFrozenModel):
|
||||
"""In-memory index of loaded prompts and documentation content."""
|
||||
"""In-memory index of documentation content."""
|
||||
|
||||
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
||||
"""Maps each documentation path to its loaded Markdown content."""
|
||||
docs_markdown_path_index: tuple[DocsPath, ...]
|
||||
"""Lists documentation paths in deterministic index order."""
|
||||
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
||||
"""Maps each prompt identifier to its fully resolved registry record."""
|
||||
prompts_in_load_order: tuple[str, ...] = ()
|
||||
"""Preserves prompt identifiers in deterministic source loading order."""
|
||||
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||
"""Stores compact prompt summaries in the same deterministic loading order."""
|
||||
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||
"""Indexes prompt identifiers by tag for catalog filtering and search."""
|
||||
|
||||
@field_validator(
|
||||
"docs_markdown_by_path",
|
||||
"prompts_by_id",
|
||||
"tag_to_prompt_ids",
|
||||
mode="before",
|
||||
)
|
||||
@field_validator("docs_markdown_by_path", mode="before")
|
||||
@classmethod
|
||||
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||
return frozen_mapping(value)
|
||||
|
||||
@@ -4,6 +4,8 @@ from .models.registry import DocsRegistry
|
||||
|
||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||
docs_path = parse_docs_path(path)
|
||||
if docs_path.parts[0] == "skills":
|
||||
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||
if docs_path not in registry.docs_markdown_by_path:
|
||||
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||
return {
|
||||
@@ -12,16 +14,3 @@ def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]
|
||||
"source_path": f"docs/{docs_path.as_posix()}",
|
||||
"content": registry.docs_markdown_by_path[docs_path],
|
||||
}
|
||||
|
||||
|
||||
def read_prompt_document(registry: DocsRegistry, prompt_id: str) -> dict[str, str]:
|
||||
if prompt_id not in registry.prompts_by_id:
|
||||
raise KeyError(f"unknown prompt_id: {prompt_id}")
|
||||
prompt = registry.prompts_by_id[prompt_id]
|
||||
return {
|
||||
"id": prompt.prompt_id,
|
||||
"uri": prompt.document_uri,
|
||||
"format": "markdown",
|
||||
"source_path": f"docs/{prompt.document_relpath.as_posix()}",
|
||||
"content": prompt.document_content,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user