personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Extract core prompt loading into think/prompts.py module

Split think/muse.py into two modules with clear separation:
- think/prompts.py: Core prompt loading (load_prompt, template substitution)
- think/muse.py: Muse agent/generator orchestration (configs, hooks, etc.)

Standalone prompt callers (observe/, think/*.py, apps/chat/) now import
from think.prompts directly. Muse orchestration code continues to use
think.muse. Updated docs/PROMPT_TEMPLATES.md to reflect new locations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+355 -322
+1 -1
apps/chat/routes.py
··· 113 113 114 114 def generate_chat_title(message: str) -> str: 115 115 """Generate a short title for a chat message using configured provider.""" 116 - from think.muse import load_prompt 116 + from think.prompts import load_prompt 117 117 118 118 prompt = load_prompt("title", base_dir=Path(__file__).parent) 119 119 try:
+6 -6
docs/PROMPT_TEMPLATES.md
··· 4 4 5 5 ## Overview 6 6 7 - 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: 7 + 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: 8 8 9 9 - Variables use `$name` or `${name}` syntax 10 10 - Undefined variables are left as-is (no errors) ··· 62 62 63 63 **References:** 64 64 - Identity configuration: [JOURNAL.md](JOURNAL.md) (identity section) 65 - - Flattening implementation: `think/muse.py` → `_flatten_identity_to_template_vars()` 65 + - Flattening implementation: `think/prompts.py` → `_flatten_identity_to_template_vars()` 66 66 67 67 ### Template Variables 68 68 ··· 159 159 160 160 Returns a `PromptContent` named tuple with `text` (substituted content), `path` (source file), and `metadata` (frontmatter dict). 161 161 162 - **Reference:** `think/muse.py` → `load_prompt()` 162 + **Reference:** `think/prompts.py` → `load_prompt()` 163 163 164 164 ## Adding New Variables 165 165 ··· 184 184 | Category | Authoritative Source | 185 185 |----------|---------------------| 186 186 | Identity config schema | [JOURNAL.md](JOURNAL.md) (identity section) | 187 - | Identity flattening | `think/muse.py` (`_flatten_identity_to_template_vars`) | 188 - | Template loading | `think/muse.py` (`_load_templates`) | 189 - | Core load function | `think/muse.py` (`load_prompt`) | 187 + | Identity flattening | `think/prompts.py` (`_flatten_identity_to_template_vars`) | 188 + | Template loading | `think/prompts.py` (`_load_templates`) | 189 + | Core load function | `think/prompts.py` (`load_prompt`) | 190 190 | Template files | `think/templates/*.md` | 191 191 | Test coverage | `tests/test_template_substitution.py` | 192 192 | Generator prompts | `muse/*.md` (files with `schedule` field but no `tools`) |
+2 -1
muse/anticipation.py
··· 19 19 write_events_jsonl, 20 20 ) 21 21 from think.models import generate 22 - from think.muse import get_output_topic, load_prompt 22 + from think.muse import get_output_topic 23 + from think.prompts import load_prompt 23 24 24 25 25 26 def post_process(result: str, context: dict) -> str | None:
+2 -1
muse/occurrence.py
··· 19 19 write_events_jsonl, 20 20 ) 21 21 from think.models import generate 22 - from think.muse import get_output_topic, load_prompt 22 + from think.muse import get_output_topic 23 + from think.prompts import load_prompt 23 24 24 25 25 26 def post_process(result: str, context: dict) -> str | None:
+1 -1
observe/describe.py
··· 35 35 from observe.extract import DEFAULT_MAX_EXTRACTIONS, select_frames_for_extraction 36 36 from observe.utils import get_segment_key 37 37 from think.callosum import callosum_send 38 - from think.muse import load_prompt 38 + from think.prompts import load_prompt 39 39 from think.utils import get_config, get_journal, setup_cli 40 40 41 41 logger = logging.getLogger(__name__)
+1 -1
observe/enrich.py
··· 23 23 24 24 from observe.utils import audio_to_flac_bytes 25 25 from think.models import generate 26 - from think.muse import load_prompt 26 + from think.prompts import load_prompt 27 27 28 28 logger = logging.getLogger(__name__) 29 29
+1 -1
observe/extract.py
··· 205 205 If AI selection fails (will trigger fallback in caller). 206 206 """ 207 207 from think.models import generate 208 - from think.muse import load_prompt 208 + from think.prompts import load_prompt 209 209 210 210 # Build extraction guidance with config overrides 211 211 extraction_guidance = _build_extraction_guidance(categories, config_overrides)
+1 -1
observe/transcribe/gemini.py
··· 32 32 33 33 from observe.utils import audio_to_flac_bytes 34 34 from think.models import generate 35 - from think.muse import load_prompt 35 + from think.prompts import load_prompt 36 36 37 37 logger = logging.getLogger(__name__) 38 38
+20 -21
tests/test_muse.py
··· 16 16 source_is_required, 17 17 ) 18 18 19 - 20 19 # ============================================================================= 21 20 # _merge_instructions_config tests 22 21 # ============================================================================= ··· 80 79 think_dir = tmp_path / "think" 81 80 think_dir.mkdir() 82 81 83 - import think.muse 82 + import think.prompts 84 83 85 - original_file = think.muse.__file__ 86 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 84 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 87 85 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 88 86 89 87 result = compose_instructions() 90 - 91 - # Restore 92 - monkeypatch.setattr(think.muse, "__file__", original_file) 93 88 94 89 assert "system_instruction" in result 95 90 assert result["system_instruction"] == "" ··· 102 97 custom_txt = think_dir / "custom.md" 103 98 custom_txt.write_text("Custom system instruction") 104 99 105 - import think.muse 100 + import think.prompts 106 101 107 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 102 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 108 103 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 109 104 110 105 result = compose_instructions( ··· 124 119 user_txt.write_text("User instruction content") 125 120 126 121 import think.muse 122 + import think.prompts 127 123 124 + # Monkeypatch both modules since compose_instructions uses muse.__file__ for 125 + # default user_prompt_dir, and load_prompt uses prompts.__file__ for defaults 126 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 128 127 monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 129 128 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 130 129 ··· 139 138 journal_txt = think_dir / "journal.md" 140 139 journal_txt.write_text("System instruction") 141 140 142 - import think.muse 141 + import think.prompts 143 142 144 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 143 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 145 144 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 146 145 147 146 result = compose_instructions() ··· 155 154 journal_txt = think_dir / "journal.md" 156 155 journal_txt.write_text("System instruction") 157 156 158 - import think.muse 157 + import think.prompts 159 158 160 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 159 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 161 160 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 162 161 163 162 result = compose_instructions( ··· 175 174 journal_txt = think_dir / "journal.md" 176 175 journal_txt.write_text("System instruction") 177 176 178 - import think.muse 177 + import think.prompts 179 178 180 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 179 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 181 180 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 182 181 183 182 result = compose_instructions( ··· 195 194 journal_txt = think_dir / "journal.md" 196 195 journal_txt.write_text("System instruction") 197 196 198 - import think.muse 197 + import think.prompts 199 198 200 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 199 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 201 200 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 202 201 203 202 result = compose_instructions( ··· 212 211 think_dir = tmp_path / "think" 213 212 think_dir.mkdir() 214 213 215 - import think.muse 214 + import think.prompts 216 215 217 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 216 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 218 217 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 219 218 220 219 result = compose_instructions() ··· 229 228 think_dir = tmp_path / "think" 230 229 think_dir.mkdir() 231 230 232 - import think.muse 231 + import think.prompts 233 232 234 - monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 233 + monkeypatch.setattr(think.prompts, "__file__", str(think_dir / "prompts.py")) 235 234 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 236 235 237 236 result = compose_instructions(
+1 -1
tests/test_template_substitution.py
··· 8 8 9 9 import pytest 10 10 11 - from think.muse import _flatten_identity_to_template_vars, load_prompt 11 + from think.prompts import _flatten_identity_to_template_vars, load_prompt 12 12 13 13 14 14 @pytest.fixture
+1 -1
think/detect_created.py
··· 13 13 from pathlib import Path 14 14 from typing import Optional 15 15 16 - from .muse import load_prompt 16 + from .prompts import load_prompt 17 17 18 18 19 19 def _load_system_prompt() -> str:
+1 -1
think/detect_transcript.py
··· 10 10 from pathlib import Path 11 11 from typing import List, Optional 12 12 13 - from .muse import load_prompt 13 + from .prompts import load_prompt 14 14 15 15 16 16 def _load_json_prompt() -> str:
+10 -284
think/muse.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Muse prompt loading and configuration utilities. 4 + """Muse agent and generator orchestration utilities. 5 5 6 - This module provides all functionality for loading, parsing, and configuring 7 - muse prompts (agents and generators) from muse/*.md and apps/*/muse/*.md. 6 + This module provides functionality for configuring and orchestrating muse agents 7 + and generators from muse/*.md and apps/*/muse/*.md. 8 8 9 9 Key functions: 10 - - load_prompt(): Load and parse .md prompt files with template substitution 11 10 - get_muse_configs(): Discover all muse configs with filtering 12 11 - get_agent(): Load complete agent configuration by name 13 12 - compose_instructions(): Build system/user prompts from instruction config 14 13 - Hook loading: load_pre_hook(), load_post_hook() 14 + 15 + For simple prompt loading without orchestration (observe/, think/*.md prompts), 16 + use think.prompts.load_prompt() directly. 15 17 """ 16 18 17 19 from __future__ import annotations 18 20 19 21 import importlib.util 20 - import logging 21 22 import os 22 23 from pathlib import Path 23 - from string import Template 24 - from typing import Any, Callable, NamedTuple 24 + from typing import Any, Callable 25 25 26 26 import frontmatter 27 27 28 + # Import core prompt utilities from think.prompts 29 + from think.prompts import _load_prompt_metadata, load_prompt 30 + 28 31 # --------------------------------------------------------------------------- 29 32 # Constants 30 33 # --------------------------------------------------------------------------- 31 34 32 35 MUSE_DIR = Path(__file__).parent.parent / "muse" 33 - TEMPLATES_DIR = Path(__file__).parent / "templates" 34 - 35 - # Cached raw template content loaded from think/templates/*.md 36 - _templates_cache: dict[str, str] | None = None 37 - 38 - 39 - # --------------------------------------------------------------------------- 40 - # Template Loading 41 - # --------------------------------------------------------------------------- 42 - 43 - 44 - def _load_raw_templates() -> dict[str, str]: 45 - """Load raw template files from think/templates/ directory. 46 - 47 - Templates are cached on first load. Each .md file becomes a template 48 - variable named after its stem (e.g., daily_preamble.md -> $daily_preamble). 49 - 50 - Returns 51 - ------- 52 - dict[str, str] 53 - Mapping of template variable names to their raw content (no substitution). 54 - """ 55 - global _templates_cache 56 - if _templates_cache is not None: 57 - return _templates_cache 58 - 59 - _templates_cache = {} 60 - if TEMPLATES_DIR.is_dir(): 61 - for md_path in TEMPLATES_DIR.glob("*.md"): 62 - var_name = md_path.stem 63 - try: 64 - post = frontmatter.load( 65 - md_path, 66 - ) 67 - _templates_cache[var_name] = post.content.strip() 68 - except Exception as exc: 69 - logging.debug("Failed to load template %s: %s", md_path, exc) 70 - 71 - return _templates_cache 72 - 73 - 74 - def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, str]: 75 - """Load and substitute template files from think/templates/ directory. 76 - 77 - Raw templates are cached, but substitution is performed on each call 78 - to support context-dependent variables like $date and $segment_start. 79 - 80 - Parameters 81 - ---------- 82 - template_vars: 83 - Optional variables to substitute into templates. Templates can use 84 - identity vars ($name, $preferred), context vars ($day, $date, 85 - $segment_start, $segment_end), and other template vars. 86 - 87 - Returns 88 - ------- 89 - dict[str, str] 90 - Mapping of template variable names to their substituted content. 91 - """ 92 - raw_templates = _load_raw_templates() 93 - 94 - if not template_vars: 95 - return dict(raw_templates) 96 - 97 - # Substitute variables into each template 98 - substituted = {} 99 - for var_name, content in raw_templates.items(): 100 - try: 101 - template = Template(content) 102 - substituted[var_name] = template.safe_substitute(template_vars) 103 - except Exception as exc: 104 - logging.debug("Template substitution failed for %s: %s", var_name, exc) 105 - substituted[var_name] = content 106 - 107 - return substituted 108 - 109 - 110 - # --------------------------------------------------------------------------- 111 - # Prompt Loading 112 - # --------------------------------------------------------------------------- 113 - 114 - 115 - class PromptContent(NamedTuple): 116 - """Container for prompt text, metadata, and its resolved path.""" 117 - 118 - text: str 119 - path: Path 120 - metadata: dict[str, Any] = {} 121 - 122 - 123 - class PromptNotFoundError(FileNotFoundError): 124 - """Raised when a prompt file cannot be located.""" 125 - 126 - def __init__(self, path: Path) -> None: 127 - self.path = path 128 - super().__init__(f"Prompt file not found: {path}") 129 - 130 - 131 - def _flatten_identity_to_template_vars(identity: dict[str, Any]) -> dict[str, str]: 132 - """Flatten identity config into template variables with uppercase-first versions. 133 - 134 - Parameters 135 - ---------- 136 - identity: 137 - Identity configuration dictionary from get_config()['identity']. 138 - 139 - Returns 140 - ------- 141 - dict[str, str] 142 - Template variables including flattened nested objects and uppercase-first versions. 143 - For example: 144 - - 'name' → identity['name'] 145 - - 'pronouns_possessive' → identity['pronouns']['possessive'] 146 - - 'Pronouns_possessive' → identity['pronouns']['possessive'].capitalize() 147 - - 'bio' → identity['bio'] 148 - """ 149 - template_vars: dict[str, str] = {} 150 - 151 - # Flatten top-level and nested values 152 - for key, value in identity.items(): 153 - if isinstance(value, dict): 154 - # Flatten nested dictionaries with underscore separator 155 - for subkey, subvalue in value.items(): 156 - var_name = f"{key}_{subkey}" 157 - template_vars[var_name] = str(subvalue) 158 - # Create uppercase-first version 159 - template_vars[var_name.capitalize()] = str(subvalue).capitalize() 160 - elif isinstance(value, (str, int, float, bool)): 161 - # Top-level scalar values 162 - template_vars[key] = str(value) 163 - # Create uppercase-first version 164 - template_vars[key.capitalize()] = str(value).capitalize() 165 - 166 - return template_vars 167 - 168 - 169 - def load_prompt( 170 - name: str, 171 - base_dir: str | Path | None = None, 172 - *, 173 - include_journal: bool = False, 174 - context: dict[str, Any] | None = None, 175 - ) -> PromptContent: 176 - """Return the text contents, metadata, and path for a ``.md`` prompt file. 177 - 178 - Prompt files use JSON frontmatter for metadata. Supports Python 179 - string.Template variable substitution using: 180 - - Identity config from get_config()['identity']: 181 - - Top-level fields: $name, $preferred, $bio, $timezone 182 - - Nested fields with underscores: $pronouns_possessive, $pronouns_subject 183 - - Uppercase-first versions: $Pronouns_possessive, $Name, $Bio 184 - - Templates from think/templates/*.md: 185 - - Each file becomes a variable named after its stem 186 - - Example: daily_preamble.md -> $daily_preamble 187 - - Templates are pre-processed with identity and context vars, so templates 188 - can use $date, $preferred, etc. before being substituted into prompts 189 - 190 - Callers can provide additional context variables via the ``context`` parameter. 191 - Context variables override identity and template variables if there's a collision. 192 - Uppercase-first versions are automatically created for context variables. 193 - 194 - Parameters 195 - ---------- 196 - name: 197 - Base filename of the prompt without the ``.md`` suffix. If the suffix is 198 - included, it will not be duplicated. 199 - base_dir: 200 - Optional directory containing the prompt file. Defaults to the directory 201 - of this module when not provided. 202 - include_journal: 203 - If True, prepends the content of ``think/journal.md`` to the requested 204 - prompt. Defaults to False. Context variables are passed through to the 205 - journal template as well. 206 - context: 207 - Optional dictionary of additional template variables. Values are converted 208 - to strings. For each key, an uppercase-first version is also created 209 - (e.g., ``{"day": "20250110"}`` adds both ``$day`` and ``$Day``). 210 - 211 - Returns 212 - ------- 213 - PromptContent 214 - The prompt text (with surrounding whitespace removed and template variables 215 - substituted), the resolved path to the ``.md`` file, and metadata from 216 - the JSON frontmatter. 217 - """ 218 - from think.utils import get_config 219 - 220 - if not name: 221 - raise ValueError("Prompt name must be provided") 222 - 223 - if name.endswith(".md"): 224 - filename = name 225 - else: 226 - filename = f"{name}.md" 227 - 228 - prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent 229 - prompt_path = prompt_dir / filename 230 - try: 231 - post = frontmatter.load( 232 - prompt_path, 233 - ) 234 - text = post.content.strip() 235 - metadata = dict(post.metadata) 236 - except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt 237 - raise PromptNotFoundError(prompt_path) from exc 238 - 239 - # Perform template substitution 240 - try: 241 - config = get_config() 242 - identity = config.get("identity", {}) 243 - template_vars = _flatten_identity_to_template_vars(identity) 244 - 245 - # Merge caller-provided context (overrides identity vars if collision) 246 - if context: 247 - for key, value in context.items(): 248 - str_value = str(value) 249 - template_vars[key] = str_value 250 - # Add uppercase-first version 251 - template_vars[key.capitalize()] = str_value.capitalize() 252 - 253 - # Load templates with identity and context vars so templates can use them 254 - templates = _load_templates(template_vars) 255 - template_vars.update(templates) 256 - 257 - # Use safe_substitute to avoid errors for undefined variables 258 - template = Template(text) 259 - text = template.safe_substitute(template_vars) 260 - except Exception as exc: 261 - # Log but don't fail - return original text if substitution fails 262 - logging.debug("Template substitution failed for %s: %s", prompt_path, exc) 263 - 264 - # Prepend journal content if requested 265 - if include_journal and name != "journal": 266 - journal_content = load_prompt("journal", context=context) 267 - text = f"{journal_content.text}\n\n{text}" 268 - 269 - return PromptContent(text=text, path=prompt_path, metadata=metadata) 270 - 271 - 272 - # --------------------------------------------------------------------------- 273 - # Prompt Metadata Loading 274 - # --------------------------------------------------------------------------- 275 - 276 - 277 - def _load_prompt_metadata(md_path: Path) -> dict[str, object]: 278 - """Load prompt metadata from .md file with JSON frontmatter. 279 - 280 - Parameters 281 - ---------- 282 - md_path: 283 - Path to the .md prompt file with JSON frontmatter. 284 - 285 - Returns 286 - ------- 287 - dict 288 - Metadata dict with path, mtime, color, and frontmatter fields. 289 - """ 290 - mtime = int(md_path.stat().st_mtime) 291 - info: dict[str, object] = { 292 - "path": str(md_path), 293 - "mtime": mtime, 294 - } 295 - 296 - try: 297 - post = frontmatter.load( 298 - md_path, 299 - ) 300 - if post.metadata: 301 - info.update(post.metadata) 302 - except Exception as exc: # pragma: no cover - metadata optional 303 - logging.debug("Error reading frontmatter from %s: %s", md_path, exc) 304 - 305 - # Apply default color if not specified 306 - if "color" not in info: 307 - info["color"] = "#6c757d" 308 - 309 - return info 310 36 311 37 312 38 # ---------------------------------------------------------------------------
+1 -1
think/planner.py
··· 8 8 import sys 9 9 from pathlib import Path 10 10 11 - from .muse import load_prompt 11 + from .prompts import load_prompt 12 12 from .utils import setup_cli 13 13 14 14
+306
think/prompts.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Core prompt loading utilities. 5 + 6 + This module provides the foundational prompt loading functionality used by both 7 + standalone prompts (observe/, think/*.md) and the full muse agent orchestration. 8 + 9 + Key functions: 10 + - load_prompt(): Load and parse .md prompt files with template substitution 11 + - PromptContent: Named tuple for prompt text, path, and metadata 12 + 13 + For full agent/generator orchestration (scheduling, hooks, instruction composition), 14 + use think.muse instead. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import logging 20 + from pathlib import Path 21 + from string import Template 22 + from typing import Any, NamedTuple 23 + 24 + import frontmatter 25 + 26 + # --------------------------------------------------------------------------- 27 + # Constants 28 + # --------------------------------------------------------------------------- 29 + 30 + TEMPLATES_DIR = Path(__file__).parent / "templates" 31 + 32 + # Cached raw template content loaded from think/templates/*.md 33 + _templates_cache: dict[str, str] | None = None 34 + 35 + 36 + # --------------------------------------------------------------------------- 37 + # Template Loading 38 + # --------------------------------------------------------------------------- 39 + 40 + 41 + def _load_raw_templates() -> dict[str, str]: 42 + """Load raw template files from think/templates/ directory. 43 + 44 + Templates are cached on first load. Each .md file becomes a template 45 + variable named after its stem (e.g., daily_preamble.md -> $daily_preamble). 46 + 47 + Returns 48 + ------- 49 + dict[str, str] 50 + Mapping of template variable names to their raw content (no substitution). 51 + """ 52 + global _templates_cache 53 + if _templates_cache is not None: 54 + return _templates_cache 55 + 56 + _templates_cache = {} 57 + if TEMPLATES_DIR.is_dir(): 58 + for md_path in TEMPLATES_DIR.glob("*.md"): 59 + var_name = md_path.stem 60 + try: 61 + post = frontmatter.load( 62 + md_path, 63 + ) 64 + _templates_cache[var_name] = post.content.strip() 65 + except Exception as exc: 66 + logging.debug("Failed to load template %s: %s", md_path, exc) 67 + 68 + return _templates_cache 69 + 70 + 71 + def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, str]: 72 + """Load and substitute template files from think/templates/ directory. 73 + 74 + Raw templates are cached, but substitution is performed on each call 75 + to support context-dependent variables like $date and $segment_start. 76 + 77 + Parameters 78 + ---------- 79 + template_vars: 80 + Optional variables to substitute into templates. Templates can use 81 + identity vars ($name, $preferred), context vars ($day, $date, 82 + $segment_start, $segment_end), and other template vars. 83 + 84 + Returns 85 + ------- 86 + dict[str, str] 87 + Mapping of template variable names to their substituted content. 88 + """ 89 + raw_templates = _load_raw_templates() 90 + 91 + if not template_vars: 92 + return dict(raw_templates) 93 + 94 + # Substitute variables into each template 95 + substituted = {} 96 + for var_name, content in raw_templates.items(): 97 + try: 98 + template = Template(content) 99 + substituted[var_name] = template.safe_substitute(template_vars) 100 + except Exception as exc: 101 + logging.debug("Template substitution failed for %s: %s", var_name, exc) 102 + substituted[var_name] = content 103 + 104 + return substituted 105 + 106 + 107 + # --------------------------------------------------------------------------- 108 + # Prompt Loading 109 + # --------------------------------------------------------------------------- 110 + 111 + 112 + class PromptContent(NamedTuple): 113 + """Container for prompt text, metadata, and its resolved path.""" 114 + 115 + text: str 116 + path: Path 117 + metadata: dict[str, Any] = {} 118 + 119 + 120 + class PromptNotFoundError(FileNotFoundError): 121 + """Raised when a prompt file cannot be located.""" 122 + 123 + def __init__(self, path: Path) -> None: 124 + self.path = path 125 + super().__init__(f"Prompt file not found: {path}") 126 + 127 + 128 + def _flatten_identity_to_template_vars(identity: dict[str, Any]) -> dict[str, str]: 129 + """Flatten identity config into template variables with uppercase-first versions. 130 + 131 + Parameters 132 + ---------- 133 + identity: 134 + Identity configuration dictionary from get_config()['identity']. 135 + 136 + Returns 137 + ------- 138 + dict[str, str] 139 + Template variables including flattened nested objects and uppercase-first versions. 140 + For example: 141 + - 'name' → identity['name'] 142 + - 'pronouns_possessive' → identity['pronouns']['possessive'] 143 + - 'Pronouns_possessive' → identity['pronouns']['possessive'].capitalize() 144 + - 'bio' → identity['bio'] 145 + """ 146 + template_vars: dict[str, str] = {} 147 + 148 + # Flatten top-level and nested values 149 + for key, value in identity.items(): 150 + if isinstance(value, dict): 151 + # Flatten nested dictionaries with underscore separator 152 + for subkey, subvalue in value.items(): 153 + var_name = f"{key}_{subkey}" 154 + template_vars[var_name] = str(subvalue) 155 + # Create uppercase-first version 156 + template_vars[var_name.capitalize()] = str(subvalue).capitalize() 157 + elif isinstance(value, (str, int, float, bool)): 158 + # Top-level scalar values 159 + template_vars[key] = str(value) 160 + # Create uppercase-first version 161 + template_vars[key.capitalize()] = str(value).capitalize() 162 + 163 + return template_vars 164 + 165 + 166 + def load_prompt( 167 + name: str, 168 + base_dir: str | Path | None = None, 169 + *, 170 + include_journal: bool = False, 171 + context: dict[str, Any] | None = None, 172 + ) -> PromptContent: 173 + """Return the text contents, metadata, and path for a ``.md`` prompt file. 174 + 175 + Prompt files use JSON frontmatter for metadata. Supports Python 176 + string.Template variable substitution using: 177 + - Identity config from get_config()['identity']: 178 + - Top-level fields: $name, $preferred, $bio, $timezone 179 + - Nested fields with underscores: $pronouns_possessive, $pronouns_subject 180 + - Uppercase-first versions: $Pronouns_possessive, $Name, $Bio 181 + - Templates from think/templates/*.md: 182 + - Each file becomes a variable named after its stem 183 + - Example: daily_preamble.md -> $daily_preamble 184 + - Templates are pre-processed with identity and context vars, so templates 185 + can use $date, $preferred, etc. before being substituted into prompts 186 + 187 + Callers can provide additional context variables via the ``context`` parameter. 188 + Context variables override identity and template variables if there's a collision. 189 + Uppercase-first versions are automatically created for context variables. 190 + 191 + Parameters 192 + ---------- 193 + name: 194 + Base filename of the prompt without the ``.md`` suffix. If the suffix is 195 + included, it will not be duplicated. 196 + base_dir: 197 + Optional directory containing the prompt file. Defaults to the directory 198 + of this module when not provided. 199 + include_journal: 200 + If True, prepends the content of ``think/journal.md`` to the requested 201 + prompt. Defaults to False. Context variables are passed through to the 202 + journal template as well. 203 + context: 204 + Optional dictionary of additional template variables. Values are converted 205 + to strings. For each key, an uppercase-first version is also created 206 + (e.g., ``{"day": "20250110"}`` adds both ``$day`` and ``$Day``). 207 + 208 + Returns 209 + ------- 210 + PromptContent 211 + The prompt text (with surrounding whitespace removed and template variables 212 + substituted), the resolved path to the ``.md`` file, and metadata from 213 + the JSON frontmatter. 214 + """ 215 + from think.utils import get_config 216 + 217 + if not name: 218 + raise ValueError("Prompt name must be provided") 219 + 220 + if name.endswith(".md"): 221 + filename = name 222 + else: 223 + filename = f"{name}.md" 224 + 225 + prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent 226 + prompt_path = prompt_dir / filename 227 + try: 228 + post = frontmatter.load( 229 + prompt_path, 230 + ) 231 + text = post.content.strip() 232 + metadata = dict(post.metadata) 233 + except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt 234 + raise PromptNotFoundError(prompt_path) from exc 235 + 236 + # Perform template substitution 237 + try: 238 + config = get_config() 239 + identity = config.get("identity", {}) 240 + template_vars = _flatten_identity_to_template_vars(identity) 241 + 242 + # Merge caller-provided context (overrides identity vars if collision) 243 + if context: 244 + for key, value in context.items(): 245 + str_value = str(value) 246 + template_vars[key] = str_value 247 + # Add uppercase-first version 248 + template_vars[key.capitalize()] = str_value.capitalize() 249 + 250 + # Load templates with identity and context vars so templates can use them 251 + templates = _load_templates(template_vars) 252 + template_vars.update(templates) 253 + 254 + # Use safe_substitute to avoid errors for undefined variables 255 + template = Template(text) 256 + text = template.safe_substitute(template_vars) 257 + except Exception as exc: 258 + # Log but don't fail - return original text if substitution fails 259 + logging.debug("Template substitution failed for %s: %s", prompt_path, exc) 260 + 261 + # Prepend journal content if requested 262 + if include_journal and name != "journal": 263 + journal_content = load_prompt("journal", context=context) 264 + text = f"{journal_content.text}\n\n{text}" 265 + 266 + return PromptContent(text=text, path=prompt_path, metadata=metadata) 267 + 268 + 269 + # --------------------------------------------------------------------------- 270 + # Prompt Metadata Loading 271 + # --------------------------------------------------------------------------- 272 + 273 + 274 + def _load_prompt_metadata(md_path: Path) -> dict[str, object]: 275 + """Load prompt metadata from .md file with JSON frontmatter. 276 + 277 + Parameters 278 + ---------- 279 + md_path: 280 + Path to the .md prompt file with JSON frontmatter. 281 + 282 + Returns 283 + ------- 284 + dict 285 + Metadata dict with path, mtime, color, and frontmatter fields. 286 + """ 287 + mtime = int(md_path.stat().st_mtime) 288 + info: dict[str, object] = { 289 + "path": str(md_path), 290 + "mtime": mtime, 291 + } 292 + 293 + try: 294 + post = frontmatter.load( 295 + md_path, 296 + ) 297 + if post.metadata: 298 + info.update(post.metadata) 299 + except Exception as exc: # pragma: no cover - metadata optional 300 + logging.debug("Error reading frontmatter from %s: %s", md_path, exc) 301 + 302 + # Apply default color if not specified 303 + if "color" not in info: 304 + info["color"] = "#6c757d" 305 + 306 + return info