Skip to content

Latest commit

 

History

History
1094 lines (833 loc) · 36.3 KB

AS_LIBRARY.md

File metadata and controls

1094 lines (833 loc) · 36.3 KB

Grimoire — Library Integration Guide

How to use Grimoire as a Python library for runtime prompt assembly, tool schema generation, and in-memory binding — without touching the CLI.

This document is the definitive reference for integrating Grimoire into your Python application. It covers the Grimoire facade, the lower-level components for advanced use cases, real-world recipes, architecture, and error handling.


Table of Contents


Quick Start

from grimoire import Grimoire

# Load a grimoire repository
g = Grimoire("/path/to/my-grimoire")

# Conjure a spell → get OpenAI messages
result = g.conjure("engineering/root_cause_analysis", variables={
    "issue_title": "Database connection timeout",
    "symptoms": "Queries hang after 30s, connection pool exhausted",
})
messages = result.to_messages("openai")
# → [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]

# Get tool schemas for function calling
tools = g.tool_schemas(tags=["devtools"])
# → [{"type": "function", "function": {"name": "devtools__git__status", ...}}, ...]

# Check what variables a spell needs
missing = g.missing_vars("engineering/root_cause_analysis")
# → {"issue_title": VariableSpec(...), "symptoms": VariableSpec(...)}

That's the entire setup. No binders to instantiate, no engines to wire, no promptlet dicts to build. The facade handles it all.


The Grimoire Facade

Construction

from grimoire import Grimoire

# Load from explicit path
g = Grimoire("/path/to/grimoire-repo")

# Load from current directory
g = Grimoire()

# Non-strict mode: leave unresolved variables as {{ placeholders }}
# instead of raising MissingVariableError
g = Grimoire("/path/to/repo", strict=False)

Construction loads the repository, indexes all artifacts, wires the conjure engine with all discovered promptlets and runes, and prepares the bundle assembler. Typical load time is under 50ms for repositories with hundreds of artifacts.

Optional keyword arguments: procedural_retriever / procedural_indexer (a semantiscan-compatible retriever/indexer for intent discovery; without them an in-memory fallback is used). To compose several repositories into one view, use Grimoire.layered(...) instead of the constructor.

Reloading after disk changes

# If files were modified on disk, reload to pick up changes
g.reload()

Properties

g.name       # → "my-grimoire"  (from grimoire.yaml)
g.version    # → "1.0.0"
g.repo       # → GrimoireRepo instance (read-only access to internals)

Properties in layered mode

g.layers            # → ["builtin", "admin", "user"]  (None for a single-root facade)
g.resolve_layer("agent/system", kind="spell")   # → "user"
g.invalidate_caches()   # drop memoized catalog()/tool_schemas() (reload() does this for you)

Layered Control Plane

Grimoire.layered(roots) (0.4.0) builds the facade over ordered overlay layers — lowest → highest precedence, highest wins — so a shipped, read-only pack can be extended or overridden by admin and user overlays without touching shipped files. This is how llmcore and wairu compose their prompt/tool control plane (llmcore-builtin < wairu-core < user).

from grimoire import Grimoire

g = Grimoire.layered(
    [
        ("builtin", "/opt/app/grimoire",     False),   # read-only
        ("admin",   "/etc/app/grimoire",      True),   # writable overlay
        ("user",    "~/.config/app/grimoire", True),   # writable, highest precedence
    ],
    strict=True,            # conjure strictness (missing required variables raise)
    load_strict=True,       # fail-loud loading (see below)
    scaffold_writable=True, # create missing writable layer roots
)

What the composed view gives you:

  • Every artifact type resolves through the layers: spells, promptlets, runes, rituals, bundles, skilldocs, and vars/defaults.yaml (merged per key). All facade APIs — conjure, tool_schemas, catalog, bind, validate, lint, intent search, the MCP manifest — operate on the composed CompositeRepoView, which is duck-type compatible with GrimoireRepo (including in-memory runtime rune registration; runtime runes are dropped on reload() by design).
  • Deterministic precedence — discovery is sorted, so the winner for a duplicated id is stable across machines (layer order first, then path-list order, then file name).
  • Strict loading (load_strict=True, the default) — a parse failure or a duplicate id inside a layer raises RepoError naming the layer, instead of log-and-skip. Use this for control-plane overlays so a broken user file fails at startup.
  • Per-layer validation — g.validate(layer="user") validates one layer's repo in isolation (an overlay that merely shadows artifacts must still be valid on its own). validate() without layer validates the composed view.
  • Writes — write_spell / update_spell / delete_spell target the highest writable layer (or a named one via the underlying LayeredGrimoire: g._layered.write_spell(spell, layer="admin")). Deleting an overlay artifact un-shadows the lower definition.
  • Caches — catalog() and unfiltered tool_schemas() are memoized; static host/git conjure builtins are computed once per engine (no git subprocess per conjure). reload() and runtime rune registration invalidate the caches; invalidate_caches() does it manually.

The lower-level LayeredGrimoire / GrimoireLayer / CompositeRepoView classes are exported from the package for callers that need typed per-layer access (get_*/list_* for all artifact types, writable_layers, composite_view()).


Conjuring Prompts

Polymorphic conjure

The conjure() method auto-detects the artifact type by looking up the ID across spells, bundles, and rituals (in that order):

# Spell → returns ConjuredPrompt
result = g.conjure("engineering/root_cause_analysis", variables={
    "issue_title": "OOM crash",
    "symptoms": "Process killed by OOM killer at 03:42 UTC",
})
assert isinstance(result, ConjuredPrompt)

# Bundle → returns ConjuredPrompt (after assembly + variant selection)
result = g.conjure("bundles/engineering/rca_with_tools", variables={
    "issue_title": "OOM crash",
    "symptoms": "Process killed",
}, context={"provider": "anthropic"})  # selects the anthropic variant
assert isinstance(result, ConjuredPrompt)

# Ritual → returns list[ConjuredRitualStep]
steps = g.conjure("rituals/rca_loop", variables={
    "issue_title": "OOM crash",
    "symptoms": "Process killed",
})
assert isinstance(steps, list)
for step in steps:
    if not step.skipped:
        print(step.conjured.to_text())

Typed conjure methods

When you know the artifact type and want static type safety:

# Spell only — always returns ConjuredPrompt
prompt: ConjuredPrompt = g.conjure_spell("engineering/root_cause_analysis", variables={...})

# Bundle only — always returns ConjuredPrompt
prompt: ConjuredPrompt = g.conjure_bundle(
    "bundles/engineering/rca_with_tools",
    variables={...},
    context={"provider": "anthropic"},
)

# Ritual only — always returns list[ConjuredRitualStep]
steps: list[ConjuredRitualStep] = g.conjure_ritual("rituals/rca_loop", variables={...})

Parameter reference

Parameter Type Default Description
artifact_id str required Spell, bundle, or ritual ID
variables dict[str, Any] | None None Explicit variable values (highest precedence)
defaults dict[str, Any] | None grimoire defaults Default values (lowest precedence)
context dict[str, str] | None None Bundle variant selection context
strict bool | None instance default Override strict mode for this call

Variable Introspection

Get the full schema

schema = g.spell_vars("engineering/root_cause_analysis")
# Returns: dict[str, VariableSpec]

for name, spec in schema.items():
    print(f"{name}: type={spec.type.value}, required={spec.required}, "
          f"default={spec.default}, ask={spec.ask!r}")

Output:

issue_title: type=string, required=True, default=None, ask='Brief title of the issue or incident'
symptoms: type=multiline, required=True, default=None, ask='Observed symptoms (one per line)'
environment: type=string, required=False, default='production', ask='Environment where the issue occurred'
context: type=multiline, required=False, default='', ask='Relevant codebase or system context'

Find missing variables

# What's still needed for this spell?
missing = g.missing_vars("engineering/root_cause_analysis")
# → {"issue_title": VariableSpec(...), "symptoms": VariableSpec(...)}
# (environment has a default, context has a default → not missing)

# After the user provides some values:
missing = g.missing_vars("engineering/root_cause_analysis", provided={
    "issue_title": "Timeout bug",
})
# → {"symptoms": VariableSpec(...)}

# Works for bundles too:
missing = g.missing_vars("bundles/engineering/rca_with_tools")

This is ideal for building interactive UIs or agent loops that progressively collect required inputs.


Tool Schema Generation

Generate OpenAI-compatible function-calling tool definitions from runes:

# All runes
tools = g.tool_schemas()

# Filter by tags
tools = g.tool_schemas(tags=["devtools"])

# Specific runes
tools = g.tool_schemas(rune_ids=["devtools/git", "semantiscan/query"])

Each rune command becomes one tool definition:

{
  "type": "function",
  "function": {
    "name": "devtools__git__status",
    "description": "Show working tree status",
    "parameters": {
      "type": "object",
      "properties": {
        "porcelain": {
          "type": "bool",
          "default": true
        }
      }
    }
  }
}

Tool names follow the pattern {rune_id}__{command_name} with / replaced by __ for JSON compatibility.


In-Memory Binding

Produce bind artifacts in memory without writing to disk:

# Bind for llmcore
result = g.bind("llmcore")
assert result.ok
print(f"Produced {len(result.files)} files, hash={result.compiled_hash}")

# Iterate over produced files
for f in result.files:
    print(f"  {f.relative_path} ({len(f.content)} bytes)")
    # f.content is the full file content as a string

# Filter by tags or specific artifacts
result = g.bind("llmcore", tags=["engineering"])
result = g.bind("llmcore", spell_ids=["examples/greet"])
result = g.bind("wairu", rune_ids=["devtools/git"])

# Specify sub-format
result = g.bind("semantiscan", fmt="toml")
result = g.bind("semantiscan", fmt="legacy_tmpl")

# Write to disk when ready
written_paths = result.write("/path/to/exports/llmcore/")

Supported targets: "llmcore", "semantiscan", "wairu" (or use BindTarget enum).


Validation & Linting

# Full repository validation (structure, references, schemas)
val_result = g.validate()
print(f"Valid: {val_result.ok}")
for diag in val_result.diagnostics:
    print(f"  [{diag.severity.value}] {diag.source}: {diag.message}")

# Lint a single spell (style rules)
lint_result = g.lint("engineering/root_cause_analysis")

# Lint all spells at once
lint_all = g.lint()

# Custom lint configuration
from grimoire.validate.rules import LintConfig
config = LintConfig(
    forbidden_tokens=["TODO", "FIXME", "PLACEHOLDER", "HACK"],
    max_user_block_chars=5000,
    check_engineering_quality=True,
    check_sensitivity=True,
)
lint_result = g.lint("engineering/rca", config=config)

Catalog & Discovery

Get an agent-friendly JSON catalog of everything in the grimoire:

import json

catalog = g.catalog()
print(json.dumps(catalog, indent=2))
{
  "grimoire": {"name": "my-grimoire", "version": "1.0.0"},
  "spells": [
    {"id": "engineering/root_cause_analysis", "name": "Root Cause Analysis", "version": "1.0.0", "tags": ["engineering", "debugging", "rca"]},
    {"id": "examples/greet", "name": "Greeting Spell", "version": "1.0.0", "tags": ["example", "test"]}
  ],
  "runes": [
    {"id": "devtools/git", "name": "Git (read-only diagnostics)", "version": "1.0.0", "tags": ["devtools", "vcs", "engineering"], "commands": ["status", "diff"]}
  ],
  "rituals": [
    {"id": "rituals/rca_loop", "name": "RCA Loop", "steps": 2}
  ],
  "bundles": [...],
  "skilldocs": [...],
  "promptlets": [...]
}

This catalog is designed for agent self-discovery: an LLM agent can read the catalog to decide which spell or rune to use for a given task.


Convenience Accessors

Direct access to individual artifacts and filtered listings:

# Get by ID (raises ArtifactNotFoundError if missing)
spell = g.get_spell("engineering/root_cause_analysis")
rune = g.get_rune("devtools/git")
bundle = g.get_bundle("bundles/engineering/rca_with_tools")
ritual = g.get_ritual("rituals/rca_loop")

# Filtered listings
eng_spells = g.list_spells(tags=["engineering"])
devtools = g.list_runes(tags=["devtools"])
all_bundles = g.list_bundles()
all_rituals = g.list_rituals()

Intent Discovery

Find artifacts from a natural-language intent instead of hard-coding ids (0.3.0). Both methods are async. With no retriever configured they use a deterministic token-overlap ranking over the in-memory catalog; pass a semantiscan-compatible procedural_retriever (and procedural_indexer for rebuild_procedural_index()) to rank with embeddings.

hits = await g.find_by_intent(
    "summarize a pull request for reviewers",
    top_k=3, filter_domain=None, filter_semantic_role=None, include_deprecated=False,
)
for m in hits:                      # IntentMatch(spell, score, highlight)
    print(m.spell.id, m.score, m.highlight)

tools = await g.find_tools_by_intent(
    "run the unit tests and report failures",
    top_k=5, tags=None, max_risk="medium", include_requires_approval=True,
)
for m in tools:                     # ToolIntentMatch(rune, command, score, highlight)
    print(m.rune.id, m.command.name, m.score)

await g.rebuild_procedural_index()  # (re)index spells + rune commands in the retriever

MCP Tool Manifest & Server

Rune commands can be published to MCP clients (0.3.0).

manifest = g.to_mcp_tool_manifest(tags=["devtools"])        # or rune_id=... / rune_ids=[...]
manifest["schema_version"]
manifest["tools"]      # MCP tools/list entries: name, description, inputSchema, and a
                       # per-tool "_meta" block (rune id, command, risk level, approval,
                       # OWASP categories, execution target, content hash) for routing

The bundled server (pip install "grimoire[mcp]") wraps the same manifest in a FastAPI JSON-RPC endpoint with bearer-token auth:

GRIMOIRE_MCP_TOKEN=change-me grimoire mcp serve --host 127.0.0.1 --port 8765 --endpoint /mcp

Methods: initialize, tools/list, tools/call, prompts/list, prompts/get; GET /health is unauthenticated. Programmatic embedding: grimoire.mcp_server.server.build_app(g, auth_token=..., endpoint_path="/mcp", tool_callables={...}, allowed_tools=[...]) returns the FastAPI app (tool_callables maps tool names to the functions tools/call dispatches to).


OWASP Metadata & Federation

Rune contracts accept owasp_categories at rune level and per command (OWASP LLM Top-10 ids such as LLM01). The metadata is preserved through parsing, carried into binder and MCP exports, and auditable with grimoire rune audit [--require-owasp].

grimoire.federation (extra federation, which pulls in llmcore) adapts MCP requests/responses/tool calls and rune command executions into llmcore's shared ecosystem event envelope: federate_mcp_request, federate_mcp_response, federate_mcp_tool_call, federate_rune_command, federate_rune_commands. The module imports llmcore lazily, so grimoire's core has no llmcore dependency.


Output Formats

A ConjuredPrompt can export to multiple formats:

result = g.conjure("examples/greet", variables={"user_name": "Alice"})

# Plain text with role headers
text = result.to_text()
# → "# SYSTEM\nYou are a careful...\n\n# USER\nPlease greet Alice in English."

# OpenAI message array
messages = result.to_messages("openai")
# → [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]

# Anthropic message array (DEVELOPER merged into system)
messages = result.to_messages("anthropic")

# Raw messages (original role names preserved)
messages = result.to_messages("raw")

# Individual blocks
for block in result.blocks:
    print(f"[{block.role.value}] {block.content[:80]}...")

Provenance Tracking

Every conjured prompt carries a provenance record:

result = g.conjure("engineering/rca", variables={"issue_title": "Bug", "symptoms": "crash"})

prov = result.provenance
print(f"Spell:    {prov.spell_id}")           # → "engineering/root_cause_analysis"
print(f"Hash:     {prov.spell_hash}")          # → "a1b2c3d4e5f6a7b8"
print(f"Includes: {prov.includes_resolved}")   # → ["safety/base_engineering", "style/principal_swe"]
print(f"Runes:    {prov.runes_referenced}")    # → []
print(f"Vars:     {prov.variables_used}")      # → {"issue_title": "Bug", "symptoms": "crash", ...}
# (SECRET variables are shown as "[redacted]")

Provenance enables: drift detection, audit trails, cache invalidation, and debugging which promptlets contributed to a final prompt.


Lower-Level Components

The Grimoire facade is built on top of these components. Use them directly when you need fine-grained control.

GrimoireRepo

The repository loader — discovers and indexes all artifacts from disk:

from grimoire import GrimoireRepo

repo = GrimoireRepo.load("/path/to/grimoire")

# Access artifacts
spell = repo.get_spell("engineering/rca")
rune = repo.get_rune("devtools/git")
promptlet = repo.get_promptlet("safety/base_engineering")

# Listings
spells = repo.list_spells(tags=["engineering"])
runes = repo.list_runes(tags=["devtools"])

# Defaults
defaults = repo.default_vars  # → dict from vars/defaults.yaml

# Catalog
catalog = repo.catalog()

# Facade accessors
repo.prompts.list(tags=["engineering"])
repo.prompts.get("engineering/rca")
repo.prompts.bundles()
repo.skills.contracts(tags=["devtools"])
repo.skills.docs()

ConjureEngine

The deterministic rendering engine — stateless, all context is passed in:

from grimoire import ConjureEngine

engine = ConjureEngine(
    promptlets={p.id: p for p in repo.list_promptlets()},
    runes={r.id: r for r in repo.list_runes()},
)

result = engine.conjure(
    spell,
    variables={"issue_title": "Bug", "symptoms": "crash"},
    defaults=repo.default_vars,
    strict=True,
)

BundleAssembler

Materializes a bundle into a conjurable spell:

from grimoire import BundleAssembler

assembler = BundleAssembler(repo)
assembled_spell = assembler.assemble(
    bundle,
    context={"provider": "anthropic"},  # variant selection
    variables={"issue_title": "Bug"},
)

# The assembled spell can be passed to ConjureEngine
result = engine.conjure(assembled_spell, variables={...})

RitualEvaluator

Evaluates multi-step ritual flows:

from grimoire import RitualEvaluator

evaluator = RitualEvaluator(repo, engine=engine)

# Dry run (inspect without conjuring)
plan = evaluator.dry_run(ritual)
print(f"OK: {plan.ok}, missing spells: {plan.missing_spells}")

# Full evaluation
steps = evaluator.evaluate(ritual, variables={"issue_title": "Bug", "symptoms": "crash"})
for step in steps:
    if step.skipped:
        print(f"  [{step.step_id}] SKIPPED: {step.skip_reason}")
    else:
        print(f"  [{step.step_id}] → {step.spell_id}")
        print(step.conjured.to_text())

Binders

Low-level binder classes for each target:

from grimoire.bind import LLMCoreBinder, SemantiscanBinder, WairuBinder

binder = LLMCoreBinder()
result = binder.bind(repo, tags=["engineering"])
result.compute_hash()
result.write("/path/to/exports/llmcore/")

Integration Recipes

llmcore: Chat with Grimoire Prompts

from grimoire import Grimoire
from llmcore import LLMCore

g = Grimoire("/path/to/grimoire")
llm = await LLMCore.create()

# Conjure a spell
prompt = g.conjure("engineering/root_cause_analysis", variables={
    "issue_title": "Connection pool exhaustion",
    "symptoms": "All database queries timeout after 30s",
    "environment": "production k8s cluster",
})

# Extract system message and user message
messages = prompt.to_messages("openai")
system_msg = next((m["content"] for m in messages if m["role"] == "system"), None)
user_msg = next((m["content"] for m in messages if m["role"] == "user"), "")

# Send to LLM
response = await llm.chat(
    message=user_msg,
    system_message=system_msg,
    enable_rag=False,  # Grimoire controls all context
)

llmcore: Agent with Grimoire Tools

g = Grimoire("/path/to/grimoire")

# Get tool schemas for the agent
tools = g.tool_schemas(tags=["devtools", "engineering"])

# Get the tool-calling policy prompt
policy = g.conjure("agentic/tool_calling_policy_low_risk")
system_msg = policy.to_messages("openai")[0]["content"]

# Use in an agent loop
response = await llm.chat(
    message="Investigate why tests are failing on main",
    system_message=system_msg,
    tools=[Tool.from_dict(t) for t in tools],
    enable_rag=False,
)

semantiscan: RAG Prompt Assembly

g = Grimoire("/path/to/grimoire")

# semantiscan retrieves chunks, then Grimoire assembles the prompt
result = g.conjure("semantiscan/rag_default", variables={
    "context": "\n---\n".join(chunk.text for chunk in retrieved_chunks),
    "question": user_query,
})

# Pass the full prompt to llmcore (external RAG pattern)
response = await llm.chat(
    message=result.to_text(),
    enable_rag=False,
)

wairu: Tool Discovery & Policy Injection

g = Grimoire("/path/to/grimoire")

# Discover available tools from rune contracts
catalog = g.catalog()
available_runes = catalog["runes"]

# Get tool schemas for all runes the session should have
tools = g.tool_schemas(rune_ids=[r["id"] for r in available_runes])

# Inject the appropriate policy spell based on risk level
runes = [g.get_rune(r["id"]) for r in available_runes]
has_high_risk = any(r.risk_level.value == "high" for r in runes)

policy_spell = (
    "agentic/tool_calling_policy_high_risk_requires_approval"
    if has_high_risk
    else "agentic/tool_calling_policy_low_risk"
)
policy = g.conjure(policy_spell)

CI/CD: Validation Gate

import sys
from grimoire import Grimoire

g = Grimoire(".")

# Step 1: Validate structure
val = g.validate()
if not val.ok:
    for d in val.diagnostics:
        print(f"[{d.severity.value}] {d.source}: {d.message}", file=sys.stderr)
    sys.exit(1)

# Step 2: Lint all spells
lint = g.lint()
errors = [d for d in lint.diagnostics if d.severity.value == "error"]
if errors:
    for d in errors:
        print(f"[LINT ERROR] {d.source}: {d.message}", file=sys.stderr)
    sys.exit(1)

# Step 3: Bind and export
for target in ("llmcore", "semantiscan", "wairu"):
    result = g.bind(target)
    result.write(f"exports/{target}/")
    print(f"✓ {target}: {len(result.files)} files, hash={result.compiled_hash}")

Agentic: Self-Selecting Prompts

An agent that uses the catalog to choose which spell to conjure:

g = Grimoire("/path/to/grimoire")

# Give the agent the catalog so it can self-select
catalog = g.catalog()
catalog_json = json.dumps(catalog, indent=2)

# Agent system prompt includes the catalog
agent_system = f"""You have access to a grimoire with these spells:

{catalog_json}

When given a task, select the most appropriate spell, determine the
required variables, and request conjuring.
"""

# After the agent selects a spell:
selected_id = "engineering/root_cause_analysis"

# Check what variables the agent needs to provide
missing = g.missing_vars(selected_id, provided=agent_context)

# Once variables are gathered, conjure
result = g.conjure(selected_id, variables=agent_variables)
messages = result.to_messages("openai")

Error Handling

All grimoire errors inherit from GrimoireError:

from grimoire import (
    GrimoireError,           # Base exception
    ArtifactNotFoundError,   # Spell/rune/ritual/bundle not found
    AmbiguousArtifactError,  # ID matches multiple types
    MissingVariableError,    # Required variable not provided (strict mode)
    CircularIncludeError,    # Include directives form a cycle
    IncludeError,            # Include target not found
    BundleAssemblyError,     # Bundle base_template not found or inject failure
    RitualError,             # Ritual evaluation failure
    SpellParseError,         # Malformed *.spell.md
    RuneParseError,          # Malformed *.rune.yaml
    RepoError,               # Repository loading failure
)

Recommended pattern:

try:
    result = g.conjure(spell_id, variables=user_vars)
except ArtifactNotFoundError:
    # Unknown spell/bundle/ritual ID
    logger.warning(f"Artifact not found: {spell_id}")
except MissingVariableError as e:
    # Required variable not provided and strict=True
    # e.args[0] contains the variable name and spell ID
    logger.error(f"Missing variable: {e}")
except GrimoireError as e:
    # Catch-all for any grimoire error
    logger.error(f"Grimoire error: {e}")

Architecture

┌─────────────────────────────────────────────────┐
│                  Grimoire Facade                │
│    conjure() · tool_schemas() · bind() · ...    │
├──────────┬───────────┬──────────┬───────────────┤
│  Conjure │  Bundle   │ Ritual   │    Binders    │
│  Engine  │ Assembler │Evaluator │ llm·sem·wairu │
├──────────┴───────────┴──────────┴───────────────┤
│                  GrimoireRepo                   │
│   spell discovery · rune indexing · promptlets  │
├─────────────────────────────────────────────────┤
│                    Parsers                      │
│  spell.md · rune.yaml · ritual.yaml · bundle    │
├─────────────────────────────────────────────────┤
│                   Validators                    │
│   structural · lint · drift detection · golden  │
└─────────────────────────────────────────────────┘

Data flow for a typical conjure() call:

conjure("bundles/engineering/rca_with_tools", variables={...}, context={...})
  │
  ├─ 1. Look up bundle in repo._bundles
  ├─ 2. BundleAssembler.assemble(bundle, context)
  │      ├─ Load base spell
  │      ├─ Select variant (first match on context)
  │      ├─ Inject promptlets at injection points
  │      └─ Return assembled Spell
  ├─ 3. ConjureEngine.conjure(assembled_spell, variables, defaults)
  │      ├─ Build effective variable map (precedence chain)
  │      ├─ For each message block:
  │      │    ├─ Resolve includes (recursive, cycle-detected)
  │      │    ├─ Resolve rune introspection
  │      │    └─ Resolve variables
  │      └─ Build provenance record
  └─ 4. Return ConjuredPrompt(blocks, provenance)

Thread Safety & Performance

A Grimoire instance is safe for concurrent reads: multiple threads can call conjure(), tool_schemas(), catalog(), etc. simultaneously without locking.

It is not safe for concurrent mutation. If the repository files change on disk, call g.reload() from a single thread before subsequent reads. catalog() and unfiltered tool_schemas() are memoized (invalidated by reload(), runtime rune registration, or invalidate_caches()), and conjure builtins that shell out to git are computed once per engine.

Performance characteristics:

  • Construction: ~20–50ms for a typical repository (hundreds of artifacts)
  • Conjure (spell): ~1–5ms (dominated by include resolution)
  • Conjure (bundle): ~2–10ms (assembly + conjuring)
  • tool_schemas(): <1ms (in-memory transformation)
  • bind(): ~10–50ms depending on artifact count and target

Type Reference

Core types

Type Module Description
Grimoire grimoire.api High-level facade (single root or Grimoire.layered(...))
LayeredGrimoire / GrimoireLayer / CompositeRepoView grimoire.layered Ordered overlay composition over GrimoireRepo
IntentMatch / ToolIntentMatch grimoire.procedural Results of find_by_intent / find_tools_by_intent
GrimoireRepo grimoire.store.repo Repository loader
ConjureEngine grimoire.conjure.engine Rendering engine
BundleAssembler grimoire.bundles.assembler Bundle materializer
RitualEvaluator grimoire.rituals.evaluator Ritual step evaluator

Model types

Type Description
Spell Parsed spell template
RuneSpec Parsed rune contract
Bundle Parsed bundle recipe
Ritual Parsed ritual flow
SkillDoc Parsed knowledge document
Promptlet Reusable prompt fragment
ConjuredPrompt Rendered output (blocks + provenance)
ConjuredRitualStep One evaluated ritual step
MessageBlock Single role+content pair
Provenance Audit trail for a conjured prompt
VariableSpec Variable schema (type, required, default, ask)
VariableType Enum: string, multiline, integer, float, boolean, choice, list, json, path

Bind types

Type Description
BindResult Aggregated output of a bind operation
BoundFile Single file with relative_path + content
BindTarget Enum: llmcore, semantiscan, wairu
BindFormat Enum: toml, legacy_tmpl, registry_bundle, tool_pack

Validation types

Type Description
ValidationResult Collection of diagnostics with .ok property
Diagnostic Single issue: severity, source, message
Severity Enum: error, warning, info
LintConfig Lint rule configuration

All types are importable from the top-level grimoire package:

from grimoire import (
    Grimoire, GrimoireRepo, ConjureEngine,
    Spell, RuneSpec, Bundle, Ritual,
    ConjuredPrompt, ConjuredRitualStep,
    VariableSpec, VariableType,
    BindResult, BindTarget,
    ArtifactNotFoundError, MissingVariableError,
)

Feature Notes by Version

0.4.0 — Layered control plane

See Layered Control Plane: Grimoire.layered(), CompositeRepoView over every artifact type, strict loading, validate(layer=...), deterministic discovery, memoized catalog/tool schemas, lazy llmcore import in grimoire.federation.

0.3.0 — Procedural discovery, MCP, federation, OWASP

See Intent Discovery, MCP Tool Manifest & Server and OWASP Metadata & Federation; rune command schema helpers live in grimoire.runes.schema (command_to_openai_tool_schema, command_parameters_schema).

0.2.0 — Attributes, Tag Search, Write API, Layered Overlays

Opaque spell attributes (WS-G4)

Spell.attributes is a declared, opaque dict[str, Any] that grimoire stores and serves verbatim but never interprets. It lets a downstream application attach arbitrary structured data to a spell without grimoire growing app-specific fields:

---
id: convergence/personas/skeptic
name: Skeptic
tags: [convergence, "pack:creative"]
attributes:
  color: "#cc3333"
  constraints: [challenge assumptions, cite evidence]
---

# SYSTEM
You are a relentless skeptic.

attributes is excluded from content_hash (it is metadata, not prompt body), so editing it does not register as prompt drift.

Tag vocabulary & search (WS-G3)

g = Grimoire("/path/to/repo")

# OR semantics (default is AND):
g.list_spells(tags=["analysis", "creative"], match="any")

# Distinct tag vocabulary with counts (ordered by count desc, then name):
g.list_tags()                 # {"convergence": 7, "pack:creative": 6, ...}
g.list_tags(prefix="pack:")   # only tags starting with "pack:"

# Case-insensitive substring search:
g.search_spells("skeptic")
g.search_spells("review", fields=("name", "description"))

match="all"|"any" is available on every list_* method (spells, runes, rituals, bundles, skilldocs).

Spell write API + serializer (WS-G1)

from grimoire import Grimoire, Spell, MessageBlock, MessageRole, serialize_spell

g = Grimoire("/path/to/repo")

spell = Spell(
    id="team/reviewer",
    name="Reviewer",
    tags=["code"],
    attributes={"color": "#0a0"},
    raw_blocks=[MessageBlock(role=MessageRole.SYSTEM, content="You are a reviewer.")],
)

path = g.write_spell(spell)            # atomic write + hot index; refuses clobber
g.update_spell(spell)                  # overwrite
g.delete_spell("team/reviewer")        # remove file + index entry

text = serialize_spell(spell)          # canonical .spell.md (round-trips with parse_spell)

GrimoireRepo.load(path, writable=False) produces a read-only repo whose write/delete operations raise RepoError.

Layered overlays (WS-G2)

LayeredGrimoire composes ordered repos so a read-only shipped library can be overridden by writable admin / user overlays without mutating shipped files:

from grimoire import LayeredGrimoire

lg = LayeredGrimoire.from_roots([
    ("shipped", "/opt/app/spells",          False),  # read-only
    ("admin",   "/var/lib/app/admin",        True),  # writable (scaffolded if missing)
    ("user",    "/var/lib/app/users/alice",  True),  # writable, highest precedence
])

lg.get_spell("convergence/personas/skeptic")   # highest-precedence layer wins
lg.resolve_layer("convergence/personas/skeptic")  # -> "shipped" / "admin" / "user"

# Writing an overlay shadows the shipped spell with the same id:
lg.write_spell(my_overlay_spell, layer="user")
# Deleting the overlay un-shadows the shipped definition again:
lg.delete_spell("convergence/personas/skeptic", layer="user")

lg.list_spells(match="any", tags=["custom"])    # merged, deduped-by-id view
lg.list_tags()                                   # vocabulary over resolved view

Layers are ordered lowest → highest precedence. Writes target a named writable layer or, by default, the highest-precedence writable layer.