started prompt mechanics
This commit is contained in:
+19
-1
@@ -16,6 +16,8 @@ The system is complete in three layers:
|
||||
2. Catalog resources provide normalized discovery.
|
||||
3. Zensical builds a static site from those same Markdown sources and the FastAPI app serves it in the FastMCP runtime process.
|
||||
|
||||
Prompt documents under `docs/prompts/` are also indexed and exposed as first-class catalog and prompt surfaces.
|
||||
|
||||
This architecture is anchored by three contracts:
|
||||
|
||||
1. Docs-first authored content contract under `docs/` with strict per-skill ownership.
|
||||
@@ -55,6 +57,17 @@ Each skill publishes resource families:
|
||||
|
||||
The document resource returns canonical Markdown, while clients can perform any downstream section extraction they need.
|
||||
|
||||
### Prompt Modules
|
||||
|
||||
Prompt guidance can be authored in `docs/prompts/` using either canonical prompt directories (`docs/prompts/<prompt-id>/PROMPT.md`) or legacy markdown files during migration.
|
||||
|
||||
Prompt modules publish two additive surfaces:
|
||||
|
||||
1. prompt resources for catalog and document retrieval
|
||||
2. MCP prompt objects for prompt-list/get-prompt style client workflows
|
||||
|
||||
This keeps authored markdown as source-of-truth while allowing clients to discover and invoke prompts directly.
|
||||
|
||||
### Catalog Module
|
||||
|
||||
The catalog is the canonical discovery layer and publishes normalized records for all modules. It may also expose a minimal set of read-only discovery tools that resolve back to the same canonical markdown content when a client chat surface does not expose MCP resource attachment.
|
||||
@@ -63,6 +76,8 @@ Typical catalog resources:
|
||||
|
||||
1. resource://catalog/skills_index
|
||||
2. resource://catalog/skills/{skill_id}
|
||||
3. resource://catalog/prompts_index
|
||||
4. resource://catalog/prompts/{prompt_id}
|
||||
|
||||
Only canonical catalog resources are part of the runtime contract in this phase.
|
||||
|
||||
@@ -139,6 +154,9 @@ For the full URI semantics, parameter validation rules, and compatibility policy
|
||||
3. resource://catalog/skills_index
|
||||
4. resource://catalog/skills/{skill_id}
|
||||
5. resource://docs/{path*}
|
||||
6. resource://catalog/prompts_index
|
||||
7. resource://catalog/prompts/{prompt_id}
|
||||
8. resource://prompts/{prompt_id}/document
|
||||
|
||||
Validation rules:
|
||||
|
||||
@@ -200,7 +218,7 @@ Markdown remains easy to review, while contracts remain stable for clients.
|
||||
|
||||
### Client Independence
|
||||
|
||||
Clients can use Ask, Edit, or Agent modes without requiring server-owned prompt orchestration. However, MCP affordances are still chat-surface-dependent: some clients or sessions expose resource attachment directly, while others make tool invocation the more reliable retrieval path.
|
||||
Clients can use Ask, Edit, or Agent modes without requiring prompt-first orchestration. Prompt objects are available as an additive MCP surface, while resource retrieval remains the canonical source path. MCP affordances are still chat-surface-dependent: some clients or sessions expose resource attachment directly, while others make tool invocation the more reliable retrieval path.
|
||||
|
||||
## Authoring and Publishing Lifecycle
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ icon: lucide/braces
|
||||
|
||||
This page defines the `SKILL.md` frontmatter and FastMCP metadata contract.
|
||||
|
||||
Prompt modules use the same contract style in `docs/prompts/<prompt-id>/PROMPT.md` with prompt-specific capability and optional typed argument metadata.
|
||||
|
||||
## Anthropic Frontmatter Support
|
||||
|
||||
Across Anthropic API and Agent Skills surfaces:
|
||||
@@ -97,6 +99,35 @@ Rules for `x-personal-mcp`:
|
||||
5. `depends_on` is optional and lists other skill ids.
|
||||
6. `references` is an optional map keyed by `ref-id` for overrides and nested entries.
|
||||
|
||||
Prompt-specific additions:
|
||||
|
||||
1. `arguments` is an optional map keyed by argument name.
|
||||
2. Each argument supports `type`, optional `description`, optional `required`, optional `default`, and optional `enum`.
|
||||
3. `type` must be one of `string`, `number`, `integer`, `boolean`, `array`, or `object`.
|
||||
4. Prompt `capabilities` must include `resource://prompts/<prompt-id>/document`.
|
||||
|
||||
Example prompt frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: initial-test-structure
|
||||
description: Generate a baseline pytest test layout for a target scope.
|
||||
x-personal-mcp:
|
||||
id: initial-test-structure
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- pytest
|
||||
- testing
|
||||
capabilities:
|
||||
- resource://prompts/initial-test-structure/document
|
||||
arguments:
|
||||
target_scope:
|
||||
type: string
|
||||
description: Target package or module under test.
|
||||
required: true
|
||||
---
|
||||
```
|
||||
|
||||
Reference entry rules:
|
||||
|
||||
1. `ref-id` is lowercase kebab-case.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Prompt For Creating Initial Test Structure
|
||||
|
||||
Create an initial test structure in `./tests` based around pytest best practices.
|
||||
|
||||
This is a placeholder until there's more content
|
||||
@@ -27,6 +27,20 @@ Use this skill to bootstrap a new skill in the docs-first architecture. Try to u
|
||||
2. One-sentence capability statement (what it does and when to use it)
|
||||
3. Optional list of references to include under `references/`
|
||||
|
||||
## Progressive Discovery Requirement
|
||||
|
||||
Every new skill created from this template should be optimized for progressive discovery, so agents load only the most relevant references in the right order.
|
||||
|
||||
Required sections for new skills:
|
||||
|
||||
1. `When to Use` with concrete trigger conditions.
|
||||
2. `How To Use This Skill` with a short intent-first flow.
|
||||
3. `Intent Router` mapping common task intents to specific reference files.
|
||||
4. `Load Order` and `Load Budget` defaults (for example, start with one baseline reference, then one stack-specific reference).
|
||||
5. `Output Contract` requiring the references consulted and the discovery path used.
|
||||
|
||||
If the domain includes naming or hierarchy conventions, include a dedicated naming trigger section that is consulted before structure recommendations.
|
||||
|
||||
## Source of Truth and Required References
|
||||
|
||||
1. Use this file as the baseline template for new skill authoring.
|
||||
@@ -47,6 +61,7 @@ docs/
|
||||
<skill-id>/
|
||||
SKILL.md
|
||||
references/
|
||||
index.md (recommended reference router)
|
||||
... (optional markdown files, nested folders allowed)
|
||||
```
|
||||
|
||||
@@ -56,6 +71,7 @@ Rules:
|
||||
2. All skill-specific supporting docs live under `references/`.
|
||||
3. Skill directories are ownership boundaries; no cross-skill writes.
|
||||
4. `skill-id` is lowercase kebab-case and should remain stable.
|
||||
5. Include a progressive discovery section in `SKILL.md` that makes selective reference loading explicit.
|
||||
|
||||
### Framing
|
||||
|
||||
@@ -148,6 +164,9 @@ Compatibility rule:
|
||||
5. Include resource://skills/<skill-id>/document in capabilities.
|
||||
6. For each top-level `references/<name>.md`, expect `resource://skills/<skill-id>/references/<name>` (normalized to lowercase kebab-case).
|
||||
7. Add explicit `x-personal-mcp.references` entries only for nested paths or metadata overrides.
|
||||
8. Add `When to Use`, `How To Use This Skill`, `Intent Router`, `Load Order`, and `Load Budget` sections in the skill body.
|
||||
9. Ensure the `Output Contract` requires reporting consulted references and the decision path.
|
||||
10. If naming conventions are part of the domain, include a naming trigger section and place naming guidance early in the flow.
|
||||
|
||||
## Required Outcomes
|
||||
|
||||
@@ -156,6 +175,7 @@ Compatibility rule:
|
||||
3. Ensure frontmatter follows repository contract, including `x-personal-mcp` fields and canonical capabilities.
|
||||
4. Keep URI and reference mapping consistent with repository conventions.
|
||||
5. Reconcile all updates with repository implementation and avoid introducing parallel metadata systems.
|
||||
6. Ensure the skill body is structured for progressive discovery and selective reference loading.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pytest-scaffolding
|
||||
description: "Reference hub for pytest suite structure, naming, markers, and stack-specific testing patterns. Use when you need best-practice guidance and source links for core pytest, FastAPI testing, and SQLAlchemy testing."
|
||||
description: "Reference hub for pytest suite structure, naming, markers, and stack-specific testing patterns. Optimized for progressive discovery so naming and hierarchy guidance are loaded first when shaping or reorganizing tests."
|
||||
argument-hint: "Target scope plus stack details (pure Python, FastAPI, SQLAlchemy sync, SQLAlchemy async, or mixed)"
|
||||
x-personal-mcp:
|
||||
id: pytest-scaffolding
|
||||
@@ -29,24 +29,50 @@ Repository defaults:
|
||||
- pytest settings live in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
- strict marker checking is expected (`--strict-markers`).
|
||||
|
||||
## Reference Map
|
||||
## Progressive Discovery Start
|
||||
|
||||
Use this map to open only the reference that matches your immediate need.
|
||||
Use this load order by default so guidance stays targeted and naming conventions are pulled in early:
|
||||
|
||||
- Core pytest practices and command patterns: [pytest-docs.md](./references/pytest-docs.md)
|
||||
- Naming conventions and hierarchy organization: [naming-and-organization.md](./references/naming-and-organization.md)
|
||||
- FastAPI-specific testing patterns: [fastapi-testing.md](./references/fastapi-testing.md)
|
||||
- SQLAlchemy-specific testing patterns: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md)
|
||||
1. Classify intent first: naming and organization, baseline pytest mechanics, FastAPI testing, SQLAlchemy testing, or mixed.
|
||||
2. For create/restructure/rename tasks, load [naming-and-organization.md](./references/naming-and-organization.md) first.
|
||||
3. Load [pytest-docs.md](./references/pytest-docs.md) next for fixture and marker defaults.
|
||||
4. Load at most one stack-specific reference unless the request is explicitly mixed stack.
|
||||
5. If confidence is low after two references, ask one clarifying question before loading more.
|
||||
|
||||
Load budget defaults:
|
||||
|
||||
1. Single-stack task: 1 to 2 references.
|
||||
2. Mixed-stack task: up to 3 references.
|
||||
3. Avoid loading all references unless the user explicitly asks for a broad audit.
|
||||
|
||||
## Intent Router
|
||||
|
||||
Open only the reference that matches the immediate task.
|
||||
|
||||
1. Naming, file layout, discovery prefixes, class/function naming: [naming-and-organization.md](./references/naming-and-organization.md)
|
||||
2. Fixture layering, marker policy, collect-only and fast-path commands: [pytest-docs.md](./references/pytest-docs.md)
|
||||
3. Route tests, dependency overrides, lifespan handling: [fastapi-testing.md](./references/fastapi-testing.md)
|
||||
4. Session and transaction fixtures, async ORM behavior: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md)
|
||||
|
||||
## Naming Pull-In Triggers
|
||||
|
||||
Always consult [naming-and-organization.md](./references/naming-and-organization.md) before recommending structure when any of these are true:
|
||||
|
||||
1. New tests are being added.
|
||||
2. Existing tests are being reorganized or renamed.
|
||||
3. The request mentions conventions, readability, hierarchy, or discoverability.
|
||||
4. The task introduces parametrization where case naming affects failure readability.
|
||||
|
||||
## Baseline Best Practices
|
||||
|
||||
These are stable defaults regardless of stack:
|
||||
|
||||
1. Mirror `src/` into `tests/` so ownership and coverage are obvious.
|
||||
2. Keep fixtures explicit and layered (`tests/conftest.py` globally, subtree `conftest.py` for domain-specific fixtures).
|
||||
3. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled.
|
||||
4. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
||||
5. Validate structure early with collection checks before expanding assertions.
|
||||
1. Apply pytest naming and hierarchy conventions first so discovery and ownership stay predictable; see [naming-and-organization.md](./references/naming-and-organization.md).
|
||||
2. Mirror `src/` into `tests/` so ownership and coverage are obvious.
|
||||
3. Keep fixtures explicit and layered (`tests/conftest.py` globally, subtree `conftest.py` for domain-specific fixtures).
|
||||
4. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled.
|
||||
5. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
||||
6. Validate structure early with collection checks before expanding assertions.
|
||||
|
||||
## Stack-Specific Guidance
|
||||
|
||||
@@ -76,7 +102,9 @@ Use these commands to check structure and execution lanes:
|
||||
## Output Contract
|
||||
When this skill is applied, return:
|
||||
1. Which references were consulted.
|
||||
2. Recommended structure, naming, fixture, and marker decisions.
|
||||
3. Exact validation commands.
|
||||
4. Relevant source-doc links for any non-trivial recommendation.
|
||||
5. Risks, assumptions, or open questions.
|
||||
2. The discovery path used (intent classification, load order, and why).
|
||||
3. Recommended structure, naming, fixture, and marker decisions.
|
||||
4. Concrete naming outcomes: file/module naming pattern, class usage decision, and any parametrization `ids` conventions.
|
||||
5. Exact validation commands.
|
||||
6. Relevant source-doc links for any non-trivial recommendation.
|
||||
7. Risks, assumptions, or open questions.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Testing
|
||||
@@ -15,6 +15,9 @@ The public, preferred URIs are:
|
||||
3. `resource://skills/{skill_id}/document`
|
||||
4. `resource://skills/{skill_id}/references/{ref_id}`
|
||||
5. `resource://docs/{path*}`
|
||||
6. `resource://catalog/prompts_index`
|
||||
7. `resource://catalog/prompts/{prompt_id}`
|
||||
8. `resource://prompts/{prompt_id}/document`
|
||||
|
||||
Contract intent:
|
||||
|
||||
@@ -52,6 +55,23 @@ Contract intent:
|
||||
2. Supports nested paths via RFC6570 wildcard expansion.
|
||||
3. Typical examples include `index.md`, `usage.md`, `skills/<skill-id>/SKILL.md`, and `skills/<skill-id>/references/<file>.md`.
|
||||
|
||||
### `resource://catalog/prompts_index`
|
||||
|
||||
1. Returns a compact list of prompt records for discovery.
|
||||
2. Contains one entry per `prompt_id`.
|
||||
3. Includes `id`, `name`, `description`, `tags`, `version`, and canonical document URI.
|
||||
|
||||
### `resource://catalog/prompts/{prompt_id}`
|
||||
|
||||
1. Returns one normalized record for `prompt_id`.
|
||||
2. Includes prompt argument metadata when declared in frontmatter.
|
||||
3. Returns not found when `prompt_id` does not exist.
|
||||
|
||||
### `resource://prompts/{prompt_id}/document`
|
||||
|
||||
1. Returns the canonical prompt markdown document.
|
||||
2. `prompt_id` must satisfy lowercase kebab-case rules.
|
||||
|
||||
## Template Parameter And Validation Rules
|
||||
|
||||
### `skill_id`
|
||||
@@ -72,6 +92,11 @@ Contract intent:
|
||||
4. Resolves only inside `docs/`.
|
||||
5. Markdown-only in the end state, meaning `.md` files.
|
||||
|
||||
### `prompt_id`
|
||||
|
||||
1. Lowercase kebab-case.
|
||||
2. Must be unique across prompt ids and must not collide with skill ids.
|
||||
|
||||
## URI Versioning Policy
|
||||
|
||||
Default rule:
|
||||
|
||||
@@ -22,6 +22,8 @@ In Copilot Chat, there are two distinct mechanisms:
|
||||
|
||||
In this repository, skill guidance is exposed as MCP resources, not as server-owned prompt execution. Copilot remains the orchestrator.
|
||||
|
||||
Prompt guidance is now exposed through both prompt resources and MCP prompt objects. Prompt objects are additive; authored markdown remains the canonical source.
|
||||
|
||||
## Background Mechanics
|
||||
|
||||
### What the server publishes
|
||||
@@ -30,12 +32,18 @@ In this repository, skill guidance is exposed as MCP resources, not as server-ow
|
||||
|
||||
1. `resource://catalog/skills_index`
|
||||
2. `resource://catalog/skills/{skill_id}`
|
||||
3. `resource://catalog/prompts_index`
|
||||
4. `resource://catalog/prompts/{prompt_id}`
|
||||
|
||||
Each skill publishes a canonical Markdown document resource:
|
||||
|
||||
1. `resource://skills/<skill-id>/document`
|
||||
2. `resource://skills/<skill-id>/references/<ref-id>`
|
||||
|
||||
Prompts publish a canonical prompt document resource:
|
||||
|
||||
1. `resource://prompts/<prompt-id>/document`
|
||||
|
||||
The document payload is loaded from `docs/skills/<skill-id>/SKILL.md` and returned with metadata.
|
||||
|
||||
### What Copilot does as the client
|
||||
@@ -74,6 +82,10 @@ In practice, there are two reliable ways to make skill content available in chat
|
||||
1. explicit resource attachment through `Add Context > MCP Resources` or `MCP: Browse Resources`
|
||||
2. MCP tool invocation using `list_resources`/`read_resource` (ResourcesAsTools), with thin catalog tools as parity fallback
|
||||
|
||||
For prompt content, there is a third option when the client supports MCP prompt APIs:
|
||||
|
||||
1. prompt-object discovery and invocation through MCP prompt lists and `get_prompt`
|
||||
|
||||
Instruction quality and metadata quality still matter, because they influence whether Copilot recognizes that the MCP server is relevant and chooses the tool path well.
|
||||
|
||||
## Operating Pattern
|
||||
@@ -209,6 +221,8 @@ Compatibility aliases for clients that use `catalog_*` naming are also available
|
||||
1. `catalog_search_patterns`
|
||||
2. `catalog_get_pattern_by_id`
|
||||
3. `catalog_get_skill_document_by_id`
|
||||
4. `catalog_search_prompts`
|
||||
5. `catalog_get_prompt_by_id`
|
||||
|
||||
Use canonical names first; aliases exist only to preserve interoperability when a client emits non-canonical names.
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from personal_mcp.catalog.server import (
|
||||
build_prompt_detail_payload,
|
||||
build_prompts_index_payload,
|
||||
build_skill_detail_payload,
|
||||
build_skills_index_payload,
|
||||
get_pattern_by_id_payload,
|
||||
get_prompt_by_id_payload,
|
||||
search_patterns_payload,
|
||||
search_prompts_payload,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_skill_detail_payload",
|
||||
"build_prompt_detail_payload",
|
||||
"build_prompts_index_payload",
|
||||
"build_skills_index_payload",
|
||||
"get_prompt_by_id_payload",
|
||||
"get_pattern_by_id_payload",
|
||||
"search_prompts_payload",
|
||||
"search_patterns_payload",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from personal_mcp.skills.document_loader import DocsRegistry, SkillRecord
|
||||
from personal_mcp.skills.document_loader import DocsRegistry, PromptRecord, SkillRecord
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
@@ -41,6 +41,19 @@ def _summary_payload(skill: SkillRecord) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _prompt_summary_payload(prompt: PromptRecord) -> dict[str, Any]:
|
||||
return {
|
||||
"id": prompt.prompt_id,
|
||||
"name": prompt.name,
|
||||
"description": prompt.description,
|
||||
"tags": list(prompt.tags),
|
||||
"capabilities": list(prompt.capabilities),
|
||||
"version": prompt.version,
|
||||
"document_uri": prompt.document_uri,
|
||||
"detail_uri": f"resource://catalog/prompts/{prompt.prompt_id}",
|
||||
}
|
||||
|
||||
|
||||
def _skill_matches(
|
||||
skill: SkillRecord,
|
||||
*,
|
||||
@@ -72,6 +85,34 @@ def _skill_matches(
|
||||
return True
|
||||
|
||||
|
||||
def _prompt_matches(
|
||||
prompt: PromptRecord,
|
||||
*,
|
||||
query: str | None,
|
||||
tag: str | None,
|
||||
) -> bool:
|
||||
if query:
|
||||
lowered = query.strip().lower()
|
||||
if lowered:
|
||||
haystack = " ".join(
|
||||
[
|
||||
prompt.prompt_id,
|
||||
prompt.name,
|
||||
prompt.description,
|
||||
" ".join(prompt.tags),
|
||||
" ".join(sorted(prompt.arguments)),
|
||||
]
|
||||
).lower()
|
||||
terms = [term for term in lowered.replace("-", " ").split() if term]
|
||||
if any(term not in haystack for term in terms):
|
||||
return False
|
||||
|
||||
if tag and tag not in prompt.tags:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def build_skills_index_payload(
|
||||
registry: DocsRegistry,
|
||||
*,
|
||||
@@ -136,6 +177,64 @@ def build_skill_detail_payload(registry: DocsRegistry, skill_id: str) -> dict[st
|
||||
}
|
||||
|
||||
|
||||
def build_prompts_index_payload(
|
||||
registry: DocsRegistry,
|
||||
*,
|
||||
query: str | None = None,
|
||||
tag: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_limit = DEFAULT_LIMIT if limit is None else max(1, min(limit, MAX_LIMIT))
|
||||
try:
|
||||
start = 0 if cursor is None else max(0, int(cursor))
|
||||
except ValueError as exc:
|
||||
raise ValueError("cursor must be an integer string") from exc
|
||||
|
||||
ordered = [
|
||||
registry.prompts_by_id[prompt_id]
|
||||
for prompt_id in registry.prompts_in_load_order
|
||||
]
|
||||
matches = [
|
||||
prompt for prompt in ordered if _prompt_matches(prompt, query=query, tag=tag)
|
||||
]
|
||||
|
||||
page = matches[start : start + normalized_limit]
|
||||
next_cursor = start + normalized_limit
|
||||
|
||||
return {
|
||||
"prompts": [_prompt_summary_payload(prompt) for prompt in page],
|
||||
"total": len(matches),
|
||||
"cursor": str(start),
|
||||
"limit": normalized_limit,
|
||||
"next_cursor": str(next_cursor) if next_cursor < len(matches) else None,
|
||||
}
|
||||
|
||||
|
||||
def build_prompt_detail_payload(
|
||||
registry: DocsRegistry, prompt_id: str
|
||||
) -> dict[str, Any]:
|
||||
if prompt_id not in registry.prompts_by_id:
|
||||
raise KeyError(prompt_id)
|
||||
|
||||
prompt = registry.prompts_by_id[prompt_id]
|
||||
return {
|
||||
"id": prompt.prompt_id,
|
||||
"name": prompt.name,
|
||||
"description": prompt.description,
|
||||
"version": prompt.version,
|
||||
"tags": list(prompt.tags),
|
||||
"capabilities": list(prompt.capabilities),
|
||||
"resources": {
|
||||
"document": prompt.document_uri,
|
||||
},
|
||||
"arguments": {
|
||||
arg_name: arg.model_dump(exclude_none=True)
|
||||
for arg_name, arg in sorted(prompt.arguments.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def search_patterns_payload(
|
||||
registry: DocsRegistry,
|
||||
*,
|
||||
@@ -171,3 +270,43 @@ def get_pattern_by_id_payload(registry: DocsRegistry, skill_id: str) -> dict[str
|
||||
if skill_id not in registry.skills_by_id:
|
||||
return {"found": False, "id": skill_id}
|
||||
return {"found": True, "pattern": _pattern_payload(registry.skills_by_id[skill_id])}
|
||||
|
||||
|
||||
def search_prompts_payload(
|
||||
registry: DocsRegistry,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: list[str] | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
normalized_skip = max(skip, 0)
|
||||
normalized_limit = max(1, min(limit, MAX_LIMIT))
|
||||
|
||||
requested_tags = [tag.strip() for tag in (tags or []) if tag and tag.strip()]
|
||||
|
||||
matches: list[PromptRecord] = []
|
||||
for prompt_id in registry.prompts_in_load_order:
|
||||
prompt = registry.prompts_by_id[prompt_id]
|
||||
if not _prompt_matches(prompt, query=query, tag=None):
|
||||
continue
|
||||
if requested_tags and any(tag not in prompt.tags for tag in requested_tags):
|
||||
continue
|
||||
matches.append(prompt)
|
||||
|
||||
page = matches[normalized_skip : normalized_skip + normalized_limit]
|
||||
return {
|
||||
"prompts": [_prompt_summary_payload(prompt) for prompt in page],
|
||||
"total": len(matches),
|
||||
"skip": normalized_skip,
|
||||
"limit": normalized_limit,
|
||||
}
|
||||
|
||||
|
||||
def get_prompt_by_id_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]:
|
||||
if prompt_id not in registry.prompts_by_id:
|
||||
return {"found": False, "id": prompt_id}
|
||||
return {
|
||||
"found": True,
|
||||
"prompt": build_prompt_detail_payload(registry, prompt_id),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from inspect import Parameter, Signature
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
@@ -8,15 +10,20 @@ from fastmcp.server.transforms import ResourcesAsTools
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform
|
||||
|
||||
from personal_mcp.catalog.server import (
|
||||
build_prompt_detail_payload,
|
||||
build_prompts_index_payload,
|
||||
build_skill_detail_payload,
|
||||
build_skills_index_payload,
|
||||
get_pattern_by_id_payload,
|
||||
get_prompt_by_id_payload,
|
||||
search_patterns_payload,
|
||||
search_prompts_payload,
|
||||
)
|
||||
from personal_mcp.skills.document_loader import (
|
||||
DocsRegistry,
|
||||
load_docs_registry,
|
||||
read_docs_markdown_path,
|
||||
read_prompt_document,
|
||||
read_skill_document,
|
||||
read_skill_reference,
|
||||
)
|
||||
@@ -77,6 +84,70 @@ def _ro_annotations() -> dict[str, bool]:
|
||||
}
|
||||
|
||||
|
||||
def _render_prompt_markdown(content: str, arguments: dict[str, Any]) -> str:
|
||||
rendered = content
|
||||
for key, value in arguments.items():
|
||||
rendered = rendered.replace(f"{{{{{key}}}}}", str(value))
|
||||
return rendered
|
||||
|
||||
|
||||
def _python_type(prompt_arg_type: str) -> type[Any]:
|
||||
if prompt_arg_type == "string":
|
||||
return str
|
||||
if prompt_arg_type == "number":
|
||||
return float
|
||||
if prompt_arg_type == "integer":
|
||||
return int
|
||||
if prompt_arg_type == "boolean":
|
||||
return bool
|
||||
if prompt_arg_type == "array":
|
||||
return list
|
||||
if prompt_arg_type == "object":
|
||||
return dict
|
||||
return str
|
||||
|
||||
|
||||
def _register_prompt_objects() -> None:
|
||||
for prompt_id in REGISTRY.prompts_in_load_order:
|
||||
prompt = REGISTRY.prompts_by_id[prompt_id]
|
||||
annotations: dict[str, Any] = {}
|
||||
params: list[Parameter] = []
|
||||
|
||||
for arg_name, arg in sorted(prompt.arguments.items()):
|
||||
arg_type = _python_type(arg.type)
|
||||
annotations[arg_name] = arg_type
|
||||
if arg.required:
|
||||
default = Parameter.empty
|
||||
else:
|
||||
default = arg.default
|
||||
params.append(
|
||||
Parameter(
|
||||
arg_name,
|
||||
kind=Parameter.KEYWORD_ONLY,
|
||||
default=default,
|
||||
annotation=arg_type,
|
||||
)
|
||||
)
|
||||
|
||||
signature = Signature(parameters=params, return_annotation=str)
|
||||
|
||||
prompt_content = prompt.document_content
|
||||
|
||||
def prompt_handler(**kwargs: Any) -> str:
|
||||
return _render_prompt_markdown(prompt_content, kwargs)
|
||||
|
||||
prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id)
|
||||
prompt_handler.__doc__ = prompt.description
|
||||
prompt_handler.__annotations__ = annotations
|
||||
prompt_handler.__signature__ = signature # type: ignore[attr-defined]
|
||||
mcp.prompt(
|
||||
prompt_handler,
|
||||
name=prompt_id,
|
||||
description=prompt.description,
|
||||
tags=set(prompt.tags),
|
||||
)
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"resource://catalog/skills_index",
|
||||
mime_type="application/json",
|
||||
@@ -150,6 +221,57 @@ def docs_markdown(path: str) -> dict[str, str]:
|
||||
return read_docs_markdown_path(REGISTRY, path)
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"resource://catalog/prompts_index",
|
||||
mime_type="application/json",
|
||||
tags={"catalog"},
|
||||
annotations=_ro_annotations(),
|
||||
)
|
||||
def prompts_index() -> dict[str, Any]:
|
||||
return build_prompts_index_payload(REGISTRY)
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
|
||||
mime_type="application/json",
|
||||
tags={"catalog"},
|
||||
annotations=_ro_annotations(),
|
||||
)
|
||||
def prompts_index_query(
|
||||
q: str | None = None,
|
||||
tag: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return build_prompts_index_payload(
|
||||
REGISTRY,
|
||||
query=q,
|
||||
tag=tag,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"resource://catalog/prompts/{prompt_id}",
|
||||
mime_type="application/json",
|
||||
tags={"catalog"},
|
||||
annotations=_ro_annotations(),
|
||||
)
|
||||
def prompt_detail(prompt_id: str) -> dict[str, Any]:
|
||||
return build_prompt_detail_payload(REGISTRY, prompt_id)
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"resource://prompts/{prompt_id}/document",
|
||||
mime_type="text/markdown",
|
||||
tags={"prompt-doc"},
|
||||
annotations=_ro_annotations(),
|
||||
)
|
||||
def prompt_document(prompt_id: str) -> dict[str, str]:
|
||||
return read_prompt_document(REGISTRY, prompt_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def search_patterns(
|
||||
query: str = "",
|
||||
@@ -185,6 +307,29 @@ def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def search_prompts(
|
||||
query: str = "",
|
||||
tags: list[str] | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Search prompt metadata with optional tags and pagination."""
|
||||
return search_prompts_payload(
|
||||
REGISTRY,
|
||||
query=query,
|
||||
tags=tags,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
||||
"""Return one prompt by stable id."""
|
||||
return get_prompt_by_id_payload(REGISTRY, prompt_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def catalog_search_patterns(
|
||||
query: str = "",
|
||||
@@ -213,4 +358,27 @@ def catalog_get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
||||
return get_skill_document_by_id(skill_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def catalog_search_prompts(
|
||||
query: str = "",
|
||||
tags: list[str] | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Compatibility alias for clients expecting catalog_* tool naming."""
|
||||
return search_prompts(
|
||||
query=query,
|
||||
tags=tags,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def catalog_get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
||||
"""Compatibility alias for clients expecting catalog_* tool naming."""
|
||||
return get_prompt_by_id(prompt_id)
|
||||
|
||||
|
||||
_install_tool_fallback_transforms()
|
||||
_register_prompt_objects()
|
||||
|
||||
@@ -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