started prompt mechanics
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.resources import files
|
||||
from importlib.resources.abc import Traversable
|
||||
from pathlib import PurePosixPath
|
||||
@@ -111,6 +111,80 @@ class PersonalMcpMetadata(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class PromptArgumentEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
type: str = Field(min_length=1)
|
||||
description: str | None = None
|
||||
required: bool = False
|
||||
default: Any | None = None
|
||||
enum: list[str] | None = None
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, value: str) -> str:
|
||||
allowed_types = {
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"array",
|
||||
"object",
|
||||
}
|
||||
if value not in allowed_types:
|
||||
raise ValueError(f"unsupported prompt argument type: {value}")
|
||||
return value
|
||||
|
||||
@field_validator("enum")
|
||||
@classmethod
|
||||
def validate_enum(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is not None and not value:
|
||||
raise ValueError("enum must contain at least one value when provided")
|
||||
return value
|
||||
|
||||
|
||||
class PromptMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
id: str
|
||||
version: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
capabilities: list[str] = Field(min_length=1)
|
||||
arguments: dict[str, PromptArgumentEntry] = Field(default_factory=dict)
|
||||
|
||||
@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: list[str]) -> list[str]:
|
||||
for tag in value:
|
||||
if not SKILL_ID_RE.fullmatch(tag):
|
||||
raise ValueError(f"invalid tag: {tag}")
|
||||
return value
|
||||
|
||||
@field_validator("arguments")
|
||||
@classmethod
|
||||
def validate_argument_names(
|
||||
cls, value: dict[str, PromptArgumentEntry]
|
||||
) -> dict[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 SkillFrontmatter(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
@@ -146,6 +220,25 @@ class SkillFrontmatter(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class PromptFrontmatter(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReferenceRecord:
|
||||
ref_id: str
|
||||
@@ -182,6 +275,31 @@ class SkillSummaryRecord:
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptRecord:
|
||||
prompt_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
arguments: dict[str, PromptArgumentEntry]
|
||||
document_uri: str
|
||||
document_relpath: str
|
||||
document_content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptSummaryRecord:
|
||||
prompt_id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
document_uri: str
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsRegistry:
|
||||
skills_by_id: dict[str, SkillRecord]
|
||||
@@ -191,6 +309,10 @@ class DocsRegistry:
|
||||
docs_markdown_path_index: tuple[str, ...]
|
||||
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
||||
capability_to_skill_ids: dict[str, tuple[str, ...]]
|
||||
prompts_by_id: dict[str, PromptRecord] = field(default_factory=dict)
|
||||
prompts_in_load_order: tuple[str, ...] = ()
|
||||
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||
tag_to_prompt_ids: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
||||
@@ -249,6 +371,20 @@ def _validate_skill_frontmatter(
|
||||
return model
|
||||
|
||||
|
||||
def _validate_prompt_frontmatter(
|
||||
raw: dict[str, Any], *, prompt_dir_name: str
|
||||
) -> PromptFrontmatter:
|
||||
model = PromptFrontmatter.model_validate(raw)
|
||||
if model.name != prompt_dir_name:
|
||||
raise ValueError("frontmatter name must exactly match prompt directory name")
|
||||
if model.x_personal_mcp.id != model.name:
|
||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||
expected_capability = f"resource://prompts/{model.name}/document"
|
||||
if expected_capability not in model.x_personal_mcp.capabilities:
|
||||
raise ValueError(f"capabilities must include {expected_capability}")
|
||||
return model
|
||||
|
||||
|
||||
def _normalize_docs_path(path: str) -> str:
|
||||
normalized = PurePosixPath(path)
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
@@ -371,6 +507,8 @@ def load_docs_registry(
|
||||
|
||||
skills_by_id: dict[str, SkillRecord] = {}
|
||||
summaries: list[SkillSummaryRecord] = []
|
||||
prompts_by_id: dict[str, PromptRecord] = {}
|
||||
prompt_summaries: list[PromptSummaryRecord] = []
|
||||
|
||||
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
||||
if not skill_dir.is_dir():
|
||||
@@ -481,6 +619,154 @@ def load_docs_registry(
|
||||
)
|
||||
)
|
||||
|
||||
prompts_root = docs_dir.joinpath("prompts")
|
||||
discovered_prompt_docs: set[str] = set()
|
||||
if prompts_root.is_dir():
|
||||
for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name):
|
||||
if not prompt_dir.is_dir():
|
||||
continue
|
||||
|
||||
prompt_dir_name = prompt_dir.name
|
||||
prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name)
|
||||
prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix()
|
||||
prompt_doc_file = prompt_dir.joinpath("PROMPT.md")
|
||||
|
||||
if not prompt_doc_file.is_file():
|
||||
continue
|
||||
|
||||
discovered_prompt_docs.add(prompt_doc_relpath)
|
||||
|
||||
prompt_markdown = prompt_doc_file.read_text(encoding="utf-8")
|
||||
try:
|
||||
raw_frontmatter, _ = _parse_frontmatter(
|
||||
prompt_markdown,
|
||||
path=prompt_doc_relpath,
|
||||
)
|
||||
frontmatter = _validate_prompt_frontmatter(
|
||||
raw_frontmatter,
|
||||
prompt_dir_name=prompt_dir_name,
|
||||
)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="invalid_prompt_frontmatter",
|
||||
message=str(exc),
|
||||
skill_id=prompt_dir_name,
|
||||
path=prompt_doc_relpath,
|
||||
hint="fix PROMPT.md YAML frontmatter to match the contract",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
prompt_id = frontmatter.name
|
||||
if prompt_id in prompts_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="duplicate_prompt_id",
|
||||
message="duplicate prompt id discovered",
|
||||
skill_id=prompt_id,
|
||||
path=prompt_doc_relpath,
|
||||
hint="ensure each prompt directory has a unique id",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if prompt_id in skills_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="prompt_skill_id_collision",
|
||||
message="prompt id collides with an existing skill id",
|
||||
skill_id=prompt_id,
|
||||
path=prompt_doc_relpath,
|
||||
hint="use a unique prompt id that does not match a skill id",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
prompt_record = PromptRecord(
|
||||
prompt_id=prompt_id,
|
||||
name=frontmatter.name,
|
||||
description=frontmatter.description,
|
||||
version=frontmatter.x_personal_mcp.version,
|
||||
tags=tuple(frontmatter.x_personal_mcp.tags),
|
||||
capabilities=tuple(frontmatter.x_personal_mcp.capabilities),
|
||||
arguments=frontmatter.x_personal_mcp.arguments,
|
||||
document_uri=f"resource://prompts/{prompt_id}/document",
|
||||
document_relpath=prompt_doc_relpath,
|
||||
document_content=prompt_markdown,
|
||||
)
|
||||
prompts_by_id[prompt_id] = prompt_record
|
||||
prompt_summaries.append(
|
||||
PromptSummaryRecord(
|
||||
prompt_id=prompt_record.prompt_id,
|
||||
name=prompt_record.name,
|
||||
description=prompt_record.description,
|
||||
tags=prompt_record.tags,
|
||||
capabilities=prompt_record.capabilities,
|
||||
document_uri=prompt_record.document_uri,
|
||||
version=prompt_record.version,
|
||||
)
|
||||
)
|
||||
|
||||
for relpath, prompt_file in _walk_markdown(
|
||||
prompts_root,
|
||||
prefix=PurePosixPath("prompts"),
|
||||
):
|
||||
if relpath in discovered_prompt_docs or relpath.endswith("/PROMPT.md"):
|
||||
continue
|
||||
|
||||
prompt_markdown = prompt_file.read_text(encoding="utf-8")
|
||||
prompt_id = _reference_id_from_filename(PurePosixPath(relpath).name)
|
||||
if prompt_id is None:
|
||||
continue
|
||||
if prompt_id in prompts_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="duplicate_prompt_id",
|
||||
message="duplicate prompt id discovered",
|
||||
skill_id=prompt_id,
|
||||
path=relpath,
|
||||
hint="ensure each prompt id is unique",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if prompt_id in skills_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="prompt_skill_id_collision",
|
||||
message="prompt id collides with an existing skill id",
|
||||
skill_id=prompt_id,
|
||||
path=relpath,
|
||||
hint="use a unique prompt id that does not match a skill id",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
prompt_record = PromptRecord(
|
||||
prompt_id=prompt_id,
|
||||
name=prompt_id,
|
||||
description=f"Legacy prompt loaded from docs/{relpath}",
|
||||
version="1.0.0",
|
||||
tags=("prompt", "legacy"),
|
||||
capabilities=(f"resource://prompts/{prompt_id}/document",),
|
||||
arguments={},
|
||||
document_uri=f"resource://prompts/{prompt_id}/document",
|
||||
document_relpath=relpath,
|
||||
document_content=prompt_markdown,
|
||||
)
|
||||
prompts_by_id[prompt_id] = prompt_record
|
||||
prompt_summaries.append(
|
||||
PromptSummaryRecord(
|
||||
prompt_id=prompt_record.prompt_id,
|
||||
name=prompt_record.name,
|
||||
description=prompt_record.description,
|
||||
tags=prompt_record.tags,
|
||||
capabilities=prompt_record.capabilities,
|
||||
document_uri=prompt_record.document_uri,
|
||||
version=prompt_record.version,
|
||||
)
|
||||
)
|
||||
|
||||
for skill_id, record in sorted(skills_by_id.items()):
|
||||
for dependency in record.depends_on:
|
||||
if dependency == skill_id:
|
||||
@@ -531,15 +817,37 @@ def load_docs_registry(
|
||||
)
|
||||
seen_uris.add(uri)
|
||||
|
||||
for prompt_id, record in sorted(prompts_by_id.items()):
|
||||
for uri in [record.document_uri]:
|
||||
if uri in seen_uris:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="duplicate_uri",
|
||||
message=f"duplicate resource URI generated: {uri}",
|
||||
skill_id=prompt_id,
|
||||
path=record.document_relpath,
|
||||
hint="ensure unique prompt ids",
|
||||
)
|
||||
)
|
||||
seen_uris.add(uri)
|
||||
|
||||
if issues:
|
||||
raise DocsRegistryValidationError(issues)
|
||||
|
||||
skill_ids = tuple(sorted(skills_by_id))
|
||||
summary_by_id = {summary.skill_id: summary for summary in summaries}
|
||||
ordered_summaries = tuple(summary_by_id[skill_id] for skill_id in skill_ids)
|
||||
prompt_ids = tuple(sorted(prompts_by_id))
|
||||
prompt_summary_by_id = {
|
||||
summary.prompt_id: summary for summary in prompt_summaries
|
||||
}
|
||||
ordered_prompt_summaries = tuple(
|
||||
prompt_summary_by_id[prompt_id] for prompt_id in prompt_ids
|
||||
)
|
||||
|
||||
tag_index: dict[str, list[str]] = {}
|
||||
capability_index: dict[str, list[str]] = {}
|
||||
prompt_tag_index: dict[str, list[str]] = {}
|
||||
for skill_id in skill_ids:
|
||||
record = skills_by_id[skill_id]
|
||||
for tag in record.tags:
|
||||
@@ -547,6 +855,11 @@ def load_docs_registry(
|
||||
for capability in record.capabilities:
|
||||
capability_index.setdefault(capability, []).append(skill_id)
|
||||
|
||||
for prompt_id in prompt_ids:
|
||||
record = prompts_by_id[prompt_id]
|
||||
for tag in record.tags:
|
||||
prompt_tag_index.setdefault(tag, []).append(prompt_id)
|
||||
|
||||
return DocsRegistry(
|
||||
skills_by_id=skills_by_id,
|
||||
skills_in_load_order=skill_ids,
|
||||
@@ -560,6 +873,13 @@ def load_docs_registry(
|
||||
key: tuple(sorted(values))
|
||||
for key, values in sorted(capability_index.items())
|
||||
},
|
||||
prompts_by_id=prompts_by_id,
|
||||
prompts_in_load_order=prompt_ids,
|
||||
prompts_summary_in_load_order=ordered_prompt_summaries,
|
||||
tag_to_prompt_ids={
|
||||
key: tuple(sorted(values))
|
||||
for key, values in sorted(prompt_tag_index.items())
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -608,3 +928,16 @@ def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]
|
||||
"source_path": f"docs/{normalized_path}",
|
||||
"content": registry.docs_markdown_by_path[normalized_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}",
|
||||
"content": prompt.document_content,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user