WIP simplifying load/startup
This commit is contained in:
@@ -1,4 +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
|
||||
@@ -6,26 +7,32 @@ from itertools import starmap
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Self
|
||||
|
||||
from personal_mcp.registry.models.common import DocsPath
|
||||
from personal_mcp.registry.models.common import parse_docs_path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarkdownDocument:
|
||||
"""Represents a loaded markdown document with its content and frontmatter."""
|
||||
|
||||
relpath: PurePosixPath
|
||||
relpath: DocsPath
|
||||
"""The relative path of the document within the package resources."""
|
||||
content: str = field(repr=False)
|
||||
"""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))
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Traversable):
|
||||
def from_root(cls, root: Traversable) -> dict[DocsPath, Self]:
|
||||
"""Recursively load all markdown documents from the root resource."""
|
||||
mapped = starmap(cls.from_resource, walk_resources(root))
|
||||
return {d.relpath: d for d in mapped}
|
||||
|
||||
@classmethod
|
||||
def from_resource(cls, relpath: PurePosixPath, resource: Traversable) -> Self:
|
||||
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)
|
||||
@@ -49,17 +56,15 @@ def walk_resources(
|
||||
*,
|
||||
suffix: str = ".md",
|
||||
prefix: PurePosixPath | None = None,
|
||||
) -> Generator[tuple[PurePosixPath, Traversable]]:
|
||||
) -> Iterator[tuple[PurePosixPath, Traversable]]:
|
||||
"""Recursively yield all resources in node, with their full path."""
|
||||
prefix = prefix if prefix is not None else PurePosixPath()
|
||||
prefix = prefix or PurePosixPath()
|
||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||
relpath = prefix.joinpath(child.name)
|
||||
relpath = prefix / child.name
|
||||
if child.is_dir():
|
||||
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||
continue
|
||||
if not child.is_file() or not child.name.lower().endswith(suffix):
|
||||
continue
|
||||
yield relpath, child
|
||||
elif child.is_file() and child.name.lower().endswith(suffix):
|
||||
yield relpath, child
|
||||
|
||||
|
||||
def get_raw_frontmatter(raw: str) -> str | None:
|
||||
|
||||
@@ -27,7 +27,7 @@ class PromptFilesBundle:
|
||||
@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.as_posix()))
|
||||
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,
|
||||
@@ -41,7 +41,7 @@ def group_prompt_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdo
|
||||
grouped: dict[str, set[MarkdownDocument]] = {}
|
||||
for doc in sorted(
|
||||
filter(lambda d: d.prompt_slug is not None, docs),
|
||||
key=lambda d: d.relpath.as_posix(),
|
||||
key=lambda d: d.relpath,
|
||||
):
|
||||
if doc.prompt_slug:
|
||||
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from importlib.resources.abc import Traversable
|
||||
from itertools import groupby
|
||||
from itertools import starmap
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Self
|
||||
|
||||
from personal_mcp.registry.models.common import SKILL_ID_RE
|
||||
from personal_mcp.registry.models.common import DocsPath
|
||||
from personal_mcp.registry.models.common import ReferenceEntry
|
||||
from personal_mcp.registry.models.skill import SkillFrontmatter
|
||||
from personal_mcp.registry.models.skill import StoredSkill
|
||||
from personal_mcp.registry.models.skill import StoredSkillReference
|
||||
from personal_mcp.skills.document_loader import _reference_id_from_filename
|
||||
from personal_mcp.skills.document_loader import _title_from_reference_filename
|
||||
|
||||
from .document import MarkdownDocument
|
||||
|
||||
@@ -39,8 +39,9 @@ class SkillFilesBundle:
|
||||
@classmethod
|
||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
|
||||
references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"))
|
||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
|
||||
references_dir = PurePosixPath("skills", slug, "references")
|
||||
references = tuple(p for p in sorted_paths if p.relpath.parent == references_dir)
|
||||
other = tuple(p for p in sorted_paths if p not in references and p != skill)
|
||||
return cls(
|
||||
slug=slug,
|
||||
@@ -60,6 +61,23 @@ def group_skill_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdow
|
||||
return {k: set(g) for k, g in grouped if k}
|
||||
|
||||
|
||||
def _title_from_reference_filename(filename: str) -> str:
|
||||
stem = PurePosixPath(filename).stem
|
||||
normalized = stem.replace("-", " ").replace("_", " ").split()
|
||||
if not normalized:
|
||||
return stem
|
||||
return " ".join(token.capitalize() for token in normalized)
|
||||
|
||||
|
||||
def _reference_id_from_filename(filename: str) -> str | None:
|
||||
stem = PurePosixPath(filename).stem.strip().lower().replace("_", "-")
|
||||
normalized = re.sub(r"[^a-z0-9-]+", "-", stem)
|
||||
normalized = re.sub(r"-+", "-", normalized).strip("-")
|
||||
if not normalized or not SKILL_ID_RE.fullmatch(normalized):
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
|
||||
discovered: dict[str, ReferenceEntry] = {}
|
||||
for reference_doc in bundle.references:
|
||||
@@ -67,7 +85,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
|
||||
if ref_id is None:
|
||||
continue
|
||||
discovered[ref_id] = ReferenceEntry(
|
||||
path=PurePosixPath("references").joinpath(reference_doc.relpath.name).as_posix(),
|
||||
path=PurePosixPath("references", reference_doc.relpath.name),
|
||||
title=_title_from_reference_filename(reference_doc.relpath.name),
|
||||
)
|
||||
return discovered
|
||||
@@ -76,7 +94,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
|
||||
def build_stored_skill(
|
||||
*,
|
||||
bundle: SkillFilesBundle,
|
||||
docs_by_relpath: Mapping[PurePosixPath, MarkdownDocument],
|
||||
docs_by_relpath: Mapping[DocsPath, MarkdownDocument],
|
||||
) -> StoredSkill:
|
||||
frontmatter = SkillFrontmatter.from_raw_yaml(bundle.skill.frontmatter)
|
||||
metadata = frontmatter.x_personal_mcp
|
||||
@@ -85,7 +103,7 @@ def build_stored_skill(
|
||||
|
||||
references: dict[str, StoredSkillReference] = {}
|
||||
for ref_id, entry in sorted(merged_entries.items()):
|
||||
ref_relpath = PurePosixPath("skills").joinpath(bundle.slug).joinpath(entry.path)
|
||||
ref_relpath = PurePosixPath("skills", bundle.slug, entry.path)
|
||||
if ref_relpath not in docs_by_relpath:
|
||||
raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
|
||||
ref_doc = docs_by_relpath[ref_relpath]
|
||||
|
||||
Reference in New Issue
Block a user