diff --git a/apps/chat/routes.py b/apps/chat/routes.py index 5b14f008e..8953c9eda 100644 --- a/apps/chat/routes.py +++ b/apps/chat/routes.py @@ -113,7 +113,7 @@ def _check_provider_api_key(provider: str) -> str | None: def generate_chat_title(message: str) -> str: """Generate a short title for a chat message using configured provider.""" - from think.muse import load_prompt + from think.prompts import load_prompt prompt = load_prompt("title", base_dir=Path(__file__).parent) try: diff --git a/docs/PROMPT_TEMPLATES.md b/docs/PROMPT_TEMPLATES.md index 7cda5231b..7aafe19aa 100644 --- a/docs/PROMPT_TEMPLATES.md +++ b/docs/PROMPT_TEMPLATES.md @@ -4,7 +4,7 @@ This document describes solstone's template variable system for personalizing pr ## Overview -Prompts are stored as `.md` files with optional JSON frontmatter for metadata. The prompt content is loaded via `load_prompt()` from `think/muse.py`, which uses Python's `string.Template` with `safe_substitute`. This means: +Prompts are stored as `.md` files with optional JSON frontmatter for metadata. The prompt content is loaded via `load_prompt()` from `think/prompts.py`, which uses Python's `string.Template` with `safe_substitute`. This means: - Variables use `$name` or `${name}` syntax - Undefined variables are left as-is (no errors) @@ -62,7 +62,7 @@ The flattening logic converts nested objects using underscore separators. For ex **References:** - Identity configuration: [JOURNAL.md](JOURNAL.md) (identity section) -- Flattening implementation: `think/muse.py` → `_flatten_identity_to_template_vars()` +- Flattening implementation: `think/prompts.py` → `_flatten_identity_to_template_vars()` ### Template Variables @@ -159,7 +159,7 @@ load_prompt( Returns a `PromptContent` named tuple with `text` (substituted content), `path` (source file), and `metadata` (frontmatter dict). -**Reference:** `think/muse.py` → `load_prompt()` +**Reference:** `think/prompts.py` → `load_prompt()` ## Adding New Variables @@ -184,9 +184,9 @@ load_prompt("myprompt", context={"custom_var": "value"}) | Category | Authoritative Source | |----------|---------------------| | Identity config schema | [JOURNAL.md](JOURNAL.md) (identity section) | -| Identity flattening | `think/muse.py` (`_flatten_identity_to_template_vars`) | -| Template loading | `think/muse.py` (`_load_templates`) | -| Core load function | `think/muse.py` (`load_prompt`) | +| Identity flattening | `think/prompts.py` (`_flatten_identity_to_template_vars`) | +| Template loading | `think/prompts.py` (`_load_templates`) | +| Core load function | `think/prompts.py` (`load_prompt`) | | Template files | `think/templates/*.md` | | Test coverage | `tests/test_template_substitution.py` | | Generator prompts | `muse/*.md` (files with `schedule` field but no `tools`) | diff --git a/muse/anticipation.py b/muse/anticipation.py index 3053ff773..3fa74b195 100644 --- a/muse/anticipation.py +++ b/muse/anticipation.py @@ -19,7 +19,8 @@ from think.hooks import ( write_events_jsonl, ) from think.models import generate -from think.muse import get_output_topic, load_prompt +from think.muse import get_output_topic +from think.prompts import load_prompt def post_process(result: str, context: dict) -> str | None: diff --git a/muse/occurrence.py b/muse/occurrence.py index 045b28767..cf422c919 100644 --- a/muse/occurrence.py +++ b/muse/occurrence.py @@ -19,7 +19,8 @@ from think.hooks import ( write_events_jsonl, ) from think.models import generate -from think.muse import get_output_topic, load_prompt +from think.muse import get_output_topic +from think.prompts import load_prompt def post_process(result: str, context: dict) -> str | None: diff --git a/observe/describe.py b/observe/describe.py index a6e39c97b..135bc5749 100644 --- a/observe/describe.py +++ b/observe/describe.py @@ -35,7 +35,7 @@ from observe.aruco import detect_markers, mask_convey_region, polygon_area from observe.extract import DEFAULT_MAX_EXTRACTIONS, select_frames_for_extraction from observe.utils import get_segment_key from think.callosum import callosum_send -from think.muse import load_prompt +from think.prompts import load_prompt from think.utils import get_config, get_journal, setup_cli logger = logging.getLogger(__name__) diff --git a/observe/enrich.py b/observe/enrich.py index ea03f82fb..720d5d8b2 100644 --- a/observe/enrich.py +++ b/observe/enrich.py @@ -23,7 +23,7 @@ from google.genai import types from observe.utils import audio_to_flac_bytes from think.models import generate -from think.muse import load_prompt +from think.prompts import load_prompt logger = logging.getLogger(__name__) diff --git a/observe/extract.py b/observe/extract.py index b9551853c..ffd27a2d6 100644 --- a/observe/extract.py +++ b/observe/extract.py @@ -205,7 +205,7 @@ def _ai_select_frames( If AI selection fails (will trigger fallback in caller). """ from think.models import generate - from think.muse import load_prompt + from think.prompts import load_prompt # Build extraction guidance with config overrides extraction_guidance = _build_extraction_guidance(categories, config_overrides) diff --git a/observe/transcribe/gemini.py b/observe/transcribe/gemini.py index 1be9299af..1c1096d65 100644 --- a/observe/transcribe/gemini.py +++ b/observe/transcribe/gemini.py @@ -32,7 +32,7 @@ from google.genai import types from observe.utils import audio_to_flac_bytes from think.models import generate -from think.muse import load_prompt +from think.prompts import load_prompt logger = logging.getLogger(__name__) diff --git a/tests/test_muse.py b/tests/test_muse.py index cead75a85..2cadf7bb4 100644 --- a/tests/test_muse.py +++ b/tests/test_muse.py @@ -16,7 +16,6 @@ from think.muse import ( source_is_required, ) - # ============================================================================= # _merge_instructions_config tests # ============================================================================= @@ -80,17 +79,13 @@ class TestComposeInstructions: think_dir = tmp_path / "think" think_dir.mkdir() - import think.muse + import think.prompts - original_file = think.muse.__file__ - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions() - # Restore - monkeypatch.setattr(think.muse, "__file__", original_file) - assert "system_instruction" in result assert result["system_instruction"] == "" assert result["system_prompt_name"] == "" @@ -102,9 +97,9 @@ class TestComposeInstructions: custom_txt = think_dir / "custom.md" custom_txt.write_text("Custom system instruction") - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions( @@ -124,7 +119,11 @@ class TestComposeInstructions: user_txt.write_text("User instruction content") import think.muse + import think.prompts + # Monkeypatch both modules since compose_instructions uses muse.__file__ for + # default user_prompt_dir, and load_prompt uses prompts.__file__ for defaults + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) @@ -139,9 +138,9 @@ class TestComposeInstructions: journal_txt = think_dir / "journal.md" journal_txt.write_text("System instruction") - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions() @@ -155,9 +154,9 @@ class TestComposeInstructions: journal_txt = think_dir / "journal.md" journal_txt.write_text("System instruction") - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions( @@ -175,9 +174,9 @@ class TestComposeInstructions: journal_txt = think_dir / "journal.md" journal_txt.write_text("System instruction") - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions( @@ -195,9 +194,9 @@ class TestComposeInstructions: journal_txt = think_dir / "journal.md" journal_txt.write_text("System instruction") - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions( @@ -212,9 +211,9 @@ class TestComposeInstructions: think_dir = tmp_path / "think" think_dir.mkdir() - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions() @@ -229,9 +228,9 @@ class TestComposeInstructions: think_dir = tmp_path / "think" think_dir.mkdir() - import think.muse + import think.prompts - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) result = compose_instructions( diff --git a/tests/test_template_substitution.py b/tests/test_template_substitution.py index 97038a02c..79c4236bc 100644 --- a/tests/test_template_substitution.py +++ b/tests/test_template_substitution.py @@ -8,7 +8,7 @@ import os import pytest -from think.muse import _flatten_identity_to_template_vars, load_prompt +from think.prompts import _flatten_identity_to_template_vars, load_prompt @pytest.fixture diff --git a/think/detect_created.py b/think/detect_created.py index a1f271527..571358b7a 100644 --- a/think/detect_created.py +++ b/think/detect_created.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from .muse import load_prompt +from .prompts import load_prompt def _load_system_prompt() -> str: diff --git a/think/detect_transcript.py b/think/detect_transcript.py index c4d74d70e..ac0a5b011 100644 --- a/think/detect_transcript.py +++ b/think/detect_transcript.py @@ -10,7 +10,7 @@ import logging from pathlib import Path from typing import List, Optional -from .muse import load_prompt +from .prompts import load_prompt def _load_json_prompt() -> str: diff --git a/think/muse.py b/think/muse.py index d1fdfddbc..67c325e4f 100644 --- a/think/muse.py +++ b/think/muse.py @@ -1,312 +1,38 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Muse prompt loading and configuration utilities. +"""Muse agent and generator orchestration utilities. -This module provides all functionality for loading, parsing, and configuring -muse prompts (agents and generators) from muse/*.md and apps/*/muse/*.md. +This module provides functionality for configuring and orchestrating muse agents +and generators from muse/*.md and apps/*/muse/*.md. Key functions: -- load_prompt(): Load and parse .md prompt files with template substitution - get_muse_configs(): Discover all muse configs with filtering - get_agent(): Load complete agent configuration by name - compose_instructions(): Build system/user prompts from instruction config - Hook loading: load_pre_hook(), load_post_hook() + +For simple prompt loading without orchestration (observe/, think/*.md prompts), +use think.prompts.load_prompt() directly. """ from __future__ import annotations import importlib.util -import logging import os from pathlib import Path -from string import Template -from typing import Any, Callable, NamedTuple +from typing import Any, Callable import frontmatter +# Import core prompt utilities from think.prompts +from think.prompts import _load_prompt_metadata, load_prompt + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- MUSE_DIR = Path(__file__).parent.parent / "muse" -TEMPLATES_DIR = Path(__file__).parent / "templates" - -# Cached raw template content loaded from think/templates/*.md -_templates_cache: dict[str, str] | None = None - - -# --------------------------------------------------------------------------- -# Template Loading -# --------------------------------------------------------------------------- - - -def _load_raw_templates() -> dict[str, str]: - """Load raw template files from think/templates/ directory. - - Templates are cached on first load. Each .md file becomes a template - variable named after its stem (e.g., daily_preamble.md -> $daily_preamble). - - Returns - ------- - dict[str, str] - Mapping of template variable names to their raw content (no substitution). - """ - global _templates_cache - if _templates_cache is not None: - return _templates_cache - - _templates_cache = {} - if TEMPLATES_DIR.is_dir(): - for md_path in TEMPLATES_DIR.glob("*.md"): - var_name = md_path.stem - try: - post = frontmatter.load( - md_path, - ) - _templates_cache[var_name] = post.content.strip() - except Exception as exc: - logging.debug("Failed to load template %s: %s", md_path, exc) - - return _templates_cache - - -def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, str]: - """Load and substitute template files from think/templates/ directory. - - Raw templates are cached, but substitution is performed on each call - to support context-dependent variables like $date and $segment_start. - - Parameters - ---------- - template_vars: - Optional variables to substitute into templates. Templates can use - identity vars ($name, $preferred), context vars ($day, $date, - $segment_start, $segment_end), and other template vars. - - Returns - ------- - dict[str, str] - Mapping of template variable names to their substituted content. - """ - raw_templates = _load_raw_templates() - - if not template_vars: - return dict(raw_templates) - - # Substitute variables into each template - substituted = {} - for var_name, content in raw_templates.items(): - try: - template = Template(content) - substituted[var_name] = template.safe_substitute(template_vars) - except Exception as exc: - logging.debug("Template substitution failed for %s: %s", var_name, exc) - substituted[var_name] = content - - return substituted - - -# --------------------------------------------------------------------------- -# Prompt Loading -# --------------------------------------------------------------------------- - - -class PromptContent(NamedTuple): - """Container for prompt text, metadata, and its resolved path.""" - - text: str - path: Path - metadata: dict[str, Any] = {} - - -class PromptNotFoundError(FileNotFoundError): - """Raised when a prompt file cannot be located.""" - - def __init__(self, path: Path) -> None: - self.path = path - super().__init__(f"Prompt file not found: {path}") - - -def _flatten_identity_to_template_vars(identity: dict[str, Any]) -> dict[str, str]: - """Flatten identity config into template variables with uppercase-first versions. - - Parameters - ---------- - identity: - Identity configuration dictionary from get_config()['identity']. - - Returns - ------- - dict[str, str] - Template variables including flattened nested objects and uppercase-first versions. - For example: - - 'name' → identity['name'] - - 'pronouns_possessive' → identity['pronouns']['possessive'] - - 'Pronouns_possessive' → identity['pronouns']['possessive'].capitalize() - - 'bio' → identity['bio'] - """ - template_vars: dict[str, str] = {} - - # Flatten top-level and nested values - for key, value in identity.items(): - if isinstance(value, dict): - # Flatten nested dictionaries with underscore separator - for subkey, subvalue in value.items(): - var_name = f"{key}_{subkey}" - template_vars[var_name] = str(subvalue) - # Create uppercase-first version - template_vars[var_name.capitalize()] = str(subvalue).capitalize() - elif isinstance(value, (str, int, float, bool)): - # Top-level scalar values - template_vars[key] = str(value) - # Create uppercase-first version - template_vars[key.capitalize()] = str(value).capitalize() - - return template_vars - - -def load_prompt( - name: str, - base_dir: str | Path | None = None, - *, - include_journal: bool = False, - context: dict[str, Any] | None = None, -) -> PromptContent: - """Return the text contents, metadata, and path for a ``.md`` prompt file. - - Prompt files use JSON frontmatter for metadata. Supports Python - string.Template variable substitution using: - - Identity config from get_config()['identity']: - - Top-level fields: $name, $preferred, $bio, $timezone - - Nested fields with underscores: $pronouns_possessive, $pronouns_subject - - Uppercase-first versions: $Pronouns_possessive, $Name, $Bio - - Templates from think/templates/*.md: - - Each file becomes a variable named after its stem - - Example: daily_preamble.md -> $daily_preamble - - Templates are pre-processed with identity and context vars, so templates - can use $date, $preferred, etc. before being substituted into prompts - - Callers can provide additional context variables via the ``context`` parameter. - Context variables override identity and template variables if there's a collision. - Uppercase-first versions are automatically created for context variables. - - Parameters - ---------- - name: - Base filename of the prompt without the ``.md`` suffix. If the suffix is - included, it will not be duplicated. - base_dir: - Optional directory containing the prompt file. Defaults to the directory - of this module when not provided. - include_journal: - If True, prepends the content of ``think/journal.md`` to the requested - prompt. Defaults to False. Context variables are passed through to the - journal template as well. - context: - Optional dictionary of additional template variables. Values are converted - to strings. For each key, an uppercase-first version is also created - (e.g., ``{"day": "20250110"}`` adds both ``$day`` and ``$Day``). - - Returns - ------- - PromptContent - The prompt text (with surrounding whitespace removed and template variables - substituted), the resolved path to the ``.md`` file, and metadata from - the JSON frontmatter. - """ - from think.utils import get_config - - if not name: - raise ValueError("Prompt name must be provided") - - if name.endswith(".md"): - filename = name - else: - filename = f"{name}.md" - - prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent - prompt_path = prompt_dir / filename - try: - post = frontmatter.load( - prompt_path, - ) - text = post.content.strip() - metadata = dict(post.metadata) - except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt - raise PromptNotFoundError(prompt_path) from exc - - # Perform template substitution - try: - config = get_config() - identity = config.get("identity", {}) - template_vars = _flatten_identity_to_template_vars(identity) - - # Merge caller-provided context (overrides identity vars if collision) - if context: - for key, value in context.items(): - str_value = str(value) - template_vars[key] = str_value - # Add uppercase-first version - template_vars[key.capitalize()] = str_value.capitalize() - - # Load templates with identity and context vars so templates can use them - templates = _load_templates(template_vars) - template_vars.update(templates) - - # Use safe_substitute to avoid errors for undefined variables - template = Template(text) - text = template.safe_substitute(template_vars) - except Exception as exc: - # Log but don't fail - return original text if substitution fails - logging.debug("Template substitution failed for %s: %s", prompt_path, exc) - - # Prepend journal content if requested - if include_journal and name != "journal": - journal_content = load_prompt("journal", context=context) - text = f"{journal_content.text}\n\n{text}" - - return PromptContent(text=text, path=prompt_path, metadata=metadata) - - -# --------------------------------------------------------------------------- -# Prompt Metadata Loading -# --------------------------------------------------------------------------- - - -def _load_prompt_metadata(md_path: Path) -> dict[str, object]: - """Load prompt metadata from .md file with JSON frontmatter. - - Parameters - ---------- - md_path: - Path to the .md prompt file with JSON frontmatter. - - Returns - ------- - dict - Metadata dict with path, mtime, color, and frontmatter fields. - """ - mtime = int(md_path.stat().st_mtime) - info: dict[str, object] = { - "path": str(md_path), - "mtime": mtime, - } - - try: - post = frontmatter.load( - md_path, - ) - if post.metadata: - info.update(post.metadata) - except Exception as exc: # pragma: no cover - metadata optional - logging.debug("Error reading frontmatter from %s: %s", md_path, exc) - - # Apply default color if not specified - if "color" not in info: - info["color"] = "#6c757d" - - return info # --------------------------------------------------------------------------- diff --git a/think/planner.py b/think/planner.py index 523327b5e..f17535d83 100644 --- a/think/planner.py +++ b/think/planner.py @@ -8,7 +8,7 @@ import os import sys from pathlib import Path -from .muse import load_prompt +from .prompts import load_prompt from .utils import setup_cli diff --git a/think/prompts.py b/think/prompts.py new file mode 100644 index 000000000..93b23a97a --- /dev/null +++ b/think/prompts.py @@ -0,0 +1,306 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Core prompt loading utilities. + +This module provides the foundational prompt loading functionality used by both +standalone prompts (observe/, think/*.md) and the full muse agent orchestration. + +Key functions: +- load_prompt(): Load and parse .md prompt files with template substitution +- PromptContent: Named tuple for prompt text, path, and metadata + +For full agent/generator orchestration (scheduling, hooks, instruction composition), +use think.muse instead. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from string import Template +from typing import Any, NamedTuple + +import frontmatter + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +# Cached raw template content loaded from think/templates/*.md +_templates_cache: dict[str, str] | None = None + + +# --------------------------------------------------------------------------- +# Template Loading +# --------------------------------------------------------------------------- + + +def _load_raw_templates() -> dict[str, str]: + """Load raw template files from think/templates/ directory. + + Templates are cached on first load. Each .md file becomes a template + variable named after its stem (e.g., daily_preamble.md -> $daily_preamble). + + Returns + ------- + dict[str, str] + Mapping of template variable names to their raw content (no substitution). + """ + global _templates_cache + if _templates_cache is not None: + return _templates_cache + + _templates_cache = {} + if TEMPLATES_DIR.is_dir(): + for md_path in TEMPLATES_DIR.glob("*.md"): + var_name = md_path.stem + try: + post = frontmatter.load( + md_path, + ) + _templates_cache[var_name] = post.content.strip() + except Exception as exc: + logging.debug("Failed to load template %s: %s", md_path, exc) + + return _templates_cache + + +def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, str]: + """Load and substitute template files from think/templates/ directory. + + Raw templates are cached, but substitution is performed on each call + to support context-dependent variables like $date and $segment_start. + + Parameters + ---------- + template_vars: + Optional variables to substitute into templates. Templates can use + identity vars ($name, $preferred), context vars ($day, $date, + $segment_start, $segment_end), and other template vars. + + Returns + ------- + dict[str, str] + Mapping of template variable names to their substituted content. + """ + raw_templates = _load_raw_templates() + + if not template_vars: + return dict(raw_templates) + + # Substitute variables into each template + substituted = {} + for var_name, content in raw_templates.items(): + try: + template = Template(content) + substituted[var_name] = template.safe_substitute(template_vars) + except Exception as exc: + logging.debug("Template substitution failed for %s: %s", var_name, exc) + substituted[var_name] = content + + return substituted + + +# --------------------------------------------------------------------------- +# Prompt Loading +# --------------------------------------------------------------------------- + + +class PromptContent(NamedTuple): + """Container for prompt text, metadata, and its resolved path.""" + + text: str + path: Path + metadata: dict[str, Any] = {} + + +class PromptNotFoundError(FileNotFoundError): + """Raised when a prompt file cannot be located.""" + + def __init__(self, path: Path) -> None: + self.path = path + super().__init__(f"Prompt file not found: {path}") + + +def _flatten_identity_to_template_vars(identity: dict[str, Any]) -> dict[str, str]: + """Flatten identity config into template variables with uppercase-first versions. + + Parameters + ---------- + identity: + Identity configuration dictionary from get_config()['identity']. + + Returns + ------- + dict[str, str] + Template variables including flattened nested objects and uppercase-first versions. + For example: + - 'name' → identity['name'] + - 'pronouns_possessive' → identity['pronouns']['possessive'] + - 'Pronouns_possessive' → identity['pronouns']['possessive'].capitalize() + - 'bio' → identity['bio'] + """ + template_vars: dict[str, str] = {} + + # Flatten top-level and nested values + for key, value in identity.items(): + if isinstance(value, dict): + # Flatten nested dictionaries with underscore separator + for subkey, subvalue in value.items(): + var_name = f"{key}_{subkey}" + template_vars[var_name] = str(subvalue) + # Create uppercase-first version + template_vars[var_name.capitalize()] = str(subvalue).capitalize() + elif isinstance(value, (str, int, float, bool)): + # Top-level scalar values + template_vars[key] = str(value) + # Create uppercase-first version + template_vars[key.capitalize()] = str(value).capitalize() + + return template_vars + + +def load_prompt( + name: str, + base_dir: str | Path | None = None, + *, + include_journal: bool = False, + context: dict[str, Any] | None = None, +) -> PromptContent: + """Return the text contents, metadata, and path for a ``.md`` prompt file. + + Prompt files use JSON frontmatter for metadata. Supports Python + string.Template variable substitution using: + - Identity config from get_config()['identity']: + - Top-level fields: $name, $preferred, $bio, $timezone + - Nested fields with underscores: $pronouns_possessive, $pronouns_subject + - Uppercase-first versions: $Pronouns_possessive, $Name, $Bio + - Templates from think/templates/*.md: + - Each file becomes a variable named after its stem + - Example: daily_preamble.md -> $daily_preamble + - Templates are pre-processed with identity and context vars, so templates + can use $date, $preferred, etc. before being substituted into prompts + + Callers can provide additional context variables via the ``context`` parameter. + Context variables override identity and template variables if there's a collision. + Uppercase-first versions are automatically created for context variables. + + Parameters + ---------- + name: + Base filename of the prompt without the ``.md`` suffix. If the suffix is + included, it will not be duplicated. + base_dir: + Optional directory containing the prompt file. Defaults to the directory + of this module when not provided. + include_journal: + If True, prepends the content of ``think/journal.md`` to the requested + prompt. Defaults to False. Context variables are passed through to the + journal template as well. + context: + Optional dictionary of additional template variables. Values are converted + to strings. For each key, an uppercase-first version is also created + (e.g., ``{"day": "20250110"}`` adds both ``$day`` and ``$Day``). + + Returns + ------- + PromptContent + The prompt text (with surrounding whitespace removed and template variables + substituted), the resolved path to the ``.md`` file, and metadata from + the JSON frontmatter. + """ + from think.utils import get_config + + if not name: + raise ValueError("Prompt name must be provided") + + if name.endswith(".md"): + filename = name + else: + filename = f"{name}.md" + + prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent + prompt_path = prompt_dir / filename + try: + post = frontmatter.load( + prompt_path, + ) + text = post.content.strip() + metadata = dict(post.metadata) + except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt + raise PromptNotFoundError(prompt_path) from exc + + # Perform template substitution + try: + config = get_config() + identity = config.get("identity", {}) + template_vars = _flatten_identity_to_template_vars(identity) + + # Merge caller-provided context (overrides identity vars if collision) + if context: + for key, value in context.items(): + str_value = str(value) + template_vars[key] = str_value + # Add uppercase-first version + template_vars[key.capitalize()] = str_value.capitalize() + + # Load templates with identity and context vars so templates can use them + templates = _load_templates(template_vars) + template_vars.update(templates) + + # Use safe_substitute to avoid errors for undefined variables + template = Template(text) + text = template.safe_substitute(template_vars) + except Exception as exc: + # Log but don't fail - return original text if substitution fails + logging.debug("Template substitution failed for %s: %s", prompt_path, exc) + + # Prepend journal content if requested + if include_journal and name != "journal": + journal_content = load_prompt("journal", context=context) + text = f"{journal_content.text}\n\n{text}" + + return PromptContent(text=text, path=prompt_path, metadata=metadata) + + +# --------------------------------------------------------------------------- +# Prompt Metadata Loading +# --------------------------------------------------------------------------- + + +def _load_prompt_metadata(md_path: Path) -> dict[str, object]: + """Load prompt metadata from .md file with JSON frontmatter. + + Parameters + ---------- + md_path: + Path to the .md prompt file with JSON frontmatter. + + Returns + ------- + dict + Metadata dict with path, mtime, color, and frontmatter fields. + """ + mtime = int(md_path.stat().st_mtime) + info: dict[str, object] = { + "path": str(md_path), + "mtime": mtime, + } + + try: + post = frontmatter.load( + md_path, + ) + if post.metadata: + info.update(post.metadata) + except Exception as exc: # pragma: no cover - metadata optional + logging.debug("Error reading frontmatter from %s: %s", md_path, exc) + + # Apply default color if not specified + if "color" not in info: + info["color"] = "#6c757d" + + return info