migration

This commit is contained in:
John Lancaster
2026-08-07 20:07:21 -05:00
parent 2a2700b78c
commit 5b6d5aaec4
70 changed files with 1182 additions and 3633 deletions
+1 -7
View File
@@ -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"]
+2 -30
View File
@@ -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 -54
View File
@@ -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,
)
-147
View File
@@ -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,
}
)
+2 -86
View File
@@ -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)
+2 -13
View File
@@ -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,
}