personal memory agent
0
fork

Configure Feed

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

Replace CONTEXT_DEFAULTS with prompt file frontmatter discovery

Move context metadata (tier, label, group) from hardcoded CONTEXT_DEFAULTS
dict to YAML frontmatter in each prompt .md file. This establishes a single
source of truth for context configuration.

Changes:
- Add frontmatter to 8 prompt files with context/tier/label/group
- Create apps/chat/title.md (replaces inline TITLE_SYSTEM_INSTRUCTION)
- Add PROMPT_PATHS list and _discover_prompt_contexts() in models.py
- Remove CONTEXT_DEFAULTS dict and observe.summarize (unused)
- Update tests to validate prompt frontmatter instead of static dict
- Fix stale comments referencing "static defaults"

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

+201 -140
+5 -8
apps/chat/routes.py
··· 6 6 import logging 7 7 import os 8 8 import time 9 + from pathlib import Path 9 10 from typing import Any 10 11 11 12 from flask import Blueprint, jsonify, render_template, request ··· 93 94 return render_template("app.html") 94 95 95 96 96 - TITLE_SYSTEM_INSTRUCTION = ( 97 - "Take the user provided text and come up with a three word title that " 98 - "concisely but uniquely identifies the user's request for quick reference " 99 - "and recall. Output only the three word title, nothing else." 100 - ) 101 - 102 - 103 97 def _check_provider_api_key(provider: str) -> str | None: 104 98 """Check if provider API key is set and return error message if not. 105 99 ··· 119 113 120 114 def generate_chat_title(message: str) -> str: 121 115 """Generate a short title for a chat message using configured provider.""" 116 + from think.utils import load_prompt 117 + 118 + prompt = load_prompt("title", base_dir=Path(__file__).parent) 122 119 try: 123 120 title = generate( 124 121 contents=message, 125 122 context="app.chat.title", 126 - system_instruction=TITLE_SYSTEM_INSTRUCTION, 123 + system_instruction=prompt.text, 127 124 max_output_tokens=50, 128 125 timeout_s=10, 129 126 ).strip()
+7
apps/chat/title.md
··· 1 + --- 2 + context: app.chat.title 3 + tier: 3 4 + label: Chat Title Generation 5 + group: Apps 6 + --- 7 + Take the user provided text and come up with a three word title that concisely but uniquely identifies the user's request for quick reference and recall. Output only the three word title, nothing else.
+5 -6
docs/PROVIDERS.md
··· 249 249 - Other contexts: `{module}.{feature}[.{operation}]` 250 250 - Examples: `observe.describe.frame`, `app.chat.title` 251 251 252 - **Dynamic discovery:** Categories and muse configs express their own tier/label/group in their configs: 253 - - Categories: `observe/categories/*.json` - add `tier`, `label`, `group` fields 252 + **Dynamic discovery:** All context metadata (tier/label/group) is defined in prompt .md files via YAML frontmatter: 253 + - Prompt files: Listed in `PROMPT_PATHS` in `think/models.py` - add `context`, `tier`, `label`, `group` fields 254 + - Categories: `observe/categories/*.md` - add `tier`, `label`, `group` fields 254 255 - System muse: `muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 255 256 - App muse: `apps/*/muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 256 257 257 - These are discovered at runtime and merged with static defaults. Use `get_context_registry()` to get the complete context map including discovered entries. 258 - 259 - See `CONTEXT_DEFAULTS` in `think/models.py` for static context patterns (non-discoverable contexts like `observe.detect.*`). 258 + All contexts are discovered at runtime. Use `get_context_registry()` to get the complete context map. 260 259 261 260 **Resolution** (handled by `think/models.py` `resolve_provider()`): 262 261 1. Exact match in journal.json `providers.contexts` 263 262 2. Glob pattern match (fnmatch) with specificity ranking 264 - 3. Dynamic context registry (static defaults + discovered categories/agents) 263 + 3. Dynamic context registry (discovered prompts, categories, muse configs) 265 264 4. Default provider/tier from config 266 265 267 266 Providers don't implement routing - they receive the resolved model.
+6
observe/describe.md
··· 1 + --- 2 + context: observe.describe.frame 3 + tier: 3 4 + label: Screen Categorization 5 + group: Observe 6 + --- 1 7 You have one job: identify the primary foreground and (if present) secondary app categories in this desktop screenshot, and return ONLY this JSON: 2 8 3 9 {
+1 -1
observe/describe.py
··· 420 420 output_file.write(json.dumps(metadata) + "\n") 421 421 output_file.flush() 422 422 423 - # Resolve model for frame description (tier comes from CONTEXT_DEFAULTS) 423 + # Resolve model for frame description (tier from describe.md frontmatter) 424 424 _, frame_model = resolve_provider("observe.describe.frame") 425 425 426 426 # Create vision requests for all qualified frames
+6
observe/enrich.md
··· 1 + --- 2 + context: observe.enrich 3 + tier: 2 4 + label: Audio Enrichment 5 + group: Observe 6 + --- 1 7 You are correcting and enriching an audio transcript. You receive numbered statements with transcribed text and corresponding audio clips. 2 8 3 9 For each statement:
+1 -1
observe/enrich.py
··· 101 101 types.Part.from_bytes(data=audio_bytes, mime_type="audio/flac") 102 102 ) 103 103 104 - # Call LLM (tier defaults to LITE via CONTEXT_DEFAULTS) 104 + # Call LLM (tier from enrich.md frontmatter) 105 105 logger.info(f"Enriching {len(statements)} statements...") 106 106 t0 = time.perf_counter() 107 107
+6
observe/extract.md
··· 1 + --- 2 + context: observe.extract.selection 3 + tier: 2 4 + label: Frame Selection 5 + group: Observe 6 + --- 1 7 You are analyzing frame categorizations from a desktop screencast recording to select frames for detailed content extraction. 2 8 3 9 Given a time-series of frames with categories and visual descriptions, select the frames most valuable for text extraction and content analysis. Aim to select around $max_extractions frames total, fewer if the content is repetitive.
+6
observe/transcribe/gemini.md
··· 1 + --- 2 + context: observe.transcribe.gemini 3 + tier: 2 4 + label: Audio Transcription (Gemini) 5 + group: Observe 6 + --- 1 7 You are accurately transcribing audio and indentifying distinct. Return a JSON object with individual speech segments that represent separate statements or sentences. 2 8 3 9 ## Output Format
+59 -36
tests/test_models.py
··· 9 9 CLAUDE_HAIKU_4, 10 10 CLAUDE_OPUS_4, 11 11 CLAUDE_SONNET_4, 12 - CONTEXT_DEFAULTS, 13 12 DEFAULT_PROVIDER, 14 13 DEFAULT_TIER, 15 14 GEMINI_FLASH, ··· 18 17 GPT_5, 19 18 GPT_5_MINI, 20 19 GPT_5_NANO, 20 + PROMPT_PATHS, 21 21 PROVIDER_DEFAULTS, 22 22 TIER_FLASH, 23 23 TIER_LITE, ··· 196 196 assert DEFAULT_PROVIDER == "google" 197 197 198 198 199 - def test_context_defaults_structure(): 200 - """Test CONTEXT_DEFAULTS has required fields for each entry.""" 201 - required_keys = {"tier", "label", "group"} 199 + def test_prompt_paths_exist(): 200 + """Test all PROMPT_PATHS files exist and have valid frontmatter.""" 201 + from pathlib import Path 202 + 203 + import frontmatter 204 + 205 + base_dir = Path(__file__).parent.parent # Project root 206 + required_keys = {"context", "tier", "label", "group"} 207 + 208 + for rel_path in PROMPT_PATHS: 209 + path = base_dir / rel_path 210 + assert path.exists(), f"Prompt file not found: {rel_path}" 211 + 212 + post = frontmatter.load(path) 213 + meta = post.metadata or {} 202 214 203 - for context, config in CONTEXT_DEFAULTS.items(): 204 - assert isinstance(config, dict), f"{context} should be a dict" 205 215 assert required_keys <= set( 206 - config.keys() 207 - ), f"{context} missing keys: {required_keys - set(config.keys())}" 208 - assert config["tier"] in ( 216 + meta.keys() 217 + ), f"{rel_path} missing keys: {required_keys - set(meta.keys())}" 218 + assert meta["tier"] in ( 209 219 TIER_PRO, 210 220 TIER_FLASH, 211 221 TIER_LITE, 212 - ), f"{context} has invalid tier: {config['tier']}" 222 + ), f"{rel_path} has invalid tier: {meta['tier']}" 213 223 assert ( 214 - isinstance(config["label"], str) and config["label"] 215 - ), f"{context} has invalid label: {config['label']}" 224 + isinstance(meta["label"], str) and meta["label"] 225 + ), f"{rel_path} has invalid label: {meta['label']}" 216 226 assert ( 217 - isinstance(config["group"], str) and config["group"] 218 - ), f"{context} has invalid group: {config['group']}" 227 + isinstance(meta["group"], str) and meta["group"] 228 + ), f"{rel_path} has invalid group: {meta['group']}" 229 + 219 230 231 + def test_prompt_contexts_in_registry(): 232 + """Test prompt contexts are discovered and in registry.""" 233 + registry = get_context_registry() 220 234 221 - def test_context_defaults_known_entries(): 222 - """Test specific known CONTEXT_DEFAULTS entries.""" 223 - # Verify some known entries exist with correct values 224 - assert "observe.describe.frame" in CONTEXT_DEFAULTS 225 - assert CONTEXT_DEFAULTS["observe.describe.frame"]["tier"] == TIER_LITE 226 - assert CONTEXT_DEFAULTS["observe.describe.frame"]["group"] == "Observe" 235 + # Verify known prompt contexts exist with correct values 236 + assert "observe.describe.frame" in registry 237 + assert registry["observe.describe.frame"]["tier"] == TIER_LITE 238 + assert registry["observe.describe.frame"]["group"] == "Observe" 239 + 240 + assert "app.chat.title" in registry 241 + assert registry["app.chat.title"]["tier"] == TIER_LITE 242 + assert registry["app.chat.title"]["group"] == "Apps" 227 243 228 - # agent.* static defaults removed - now discovered from muse/*.md 229 - assert "agent.*" not in CONTEXT_DEFAULTS 244 + assert "observe.enrich" in registry 245 + assert registry["observe.enrich"]["tier"] == TIER_FLASH 230 246 231 - assert "app.chat.title" in CONTEXT_DEFAULTS 232 - assert CONTEXT_DEFAULTS["app.chat.title"]["tier"] == TIER_LITE 233 - assert CONTEXT_DEFAULTS["app.chat.title"]["group"] == "Apps" 247 + assert "detect.created" in registry 248 + assert registry["detect.created"]["tier"] == TIER_LITE 234 249 235 250 236 251 def test_provider_defaults_structure(): ··· 362 377 # --------------------------------------------------------------------------- 363 378 364 379 365 - def test_context_registry_includes_static_defaults(): 366 - """Test that registry includes all static CONTEXT_DEFAULTS entries.""" 380 + def test_context_registry_includes_prompt_contexts(): 381 + """Test that registry includes all contexts from PROMPT_PATHS.""" 382 + from pathlib import Path 383 + 384 + import frontmatter 385 + 367 386 registry = get_context_registry() 387 + base_dir = Path(__file__).parent.parent 368 388 369 - # All static defaults should be in registry 370 - for context in CONTEXT_DEFAULTS: 371 - assert context in registry, f"Static default {context} not in registry" 372 - assert registry[context]["tier"] == CONTEXT_DEFAULTS[context]["tier"] 389 + # All prompt contexts should be in registry with correct tier 390 + for rel_path in PROMPT_PATHS: 391 + path = base_dir / rel_path 392 + post = frontmatter.load(path) 393 + meta = post.metadata or {} 394 + context = meta.get("context") 395 + 396 + assert context in registry, f"Prompt context {context} not in registry" 397 + assert registry[context]["tier"] == meta["tier"] 373 398 374 399 375 400 def test_context_registry_includes_categories(): 376 401 """Test that registry includes discovered category contexts.""" 377 402 registry = get_context_registry() 378 403 379 - # Should have category entries (from observe/categories/*.json) 404 + # Should have category entries (from observe/categories/*.md) 380 405 category_contexts = [k for k in registry if k.startswith("observe.describe.")] 381 406 382 - # Should have more than just the static entries (frame and wildcard) 383 - assert len(category_contexts) > 2, "Should discover category contexts" 407 + # Should have frame + all categories (browsing, code, gaming, etc.) 408 + assert len(category_contexts) > 5, "Should discover category contexts" 384 409 385 410 # Each category context should have required fields 386 411 for context in category_contexts: 387 - if context == "observe.describe.*": 388 - continue # Skip wildcard 389 412 assert "tier" in registry[context] 390 413 assert "label" in registry[context] 391 414 assert "group" in registry[context]
+6
think/detect_created.md
··· 1 + --- 2 + context: detect.created 3 + tier: 3 4 + label: Date Detection 5 + group: Import 6 + --- 1 7 You are an expert at analyzing media file metadata to determine creation timestamps. Analyze the provided exiftool metadata output and extract the most accurate creation time. 2 8 3 9 Guidelines:
+6
think/detect_transcript_json.md
··· 1 + --- 2 + context: observe.detect.json 3 + tier: 2 4 + label: Normalization 5 + group: Import 6 + --- 1 7 You are a transcript processing assistant. Convert the provided transcript segment into structured JSON format. 2 8 3 9 ## Input Format:
+6
think/detect_transcript_segment.md
··· 1 + --- 2 + context: observe.detect.segment 3 + tier: 2 4 + label: Segmentation 5 + group: Import 6 + --- 1 7 You are a transcript analyzer that identifies 5-minute segment boundaries with absolute timestamps. 2 8 3 9 TASK: Find ~5-minute segment boundaries and return their line numbers with absolute time-of-day timestamps.
+75 -88
think/models.py
··· 92 92 93 93 94 94 # --------------------------------------------------------------------------- 95 - # Context defaults: context pattern -> {tier, label, group} 95 + # Prompt context discovery 96 96 # 97 - # These define the default tier for each context when not overridden in config. 98 - # Patterns support glob-style matching (fnmatch). 99 - # 100 - # Each entry contains: 101 - # - tier: Default tier (TIER_PRO, TIER_FLASH, TIER_LITE) 102 - # - label: Human-readable name for settings UI 103 - # - group: Category for grouping in settings UI 97 + # Context metadata (tier, label, group) is defined in prompt .md files via 98 + # YAML frontmatter. This eliminates duplication between code and config. 104 99 # 105 100 # NAMING CONVENTION: 106 101 # {module}.{feature}[.{operation}] ··· 112 107 # - muse.entities.observer -> muse module, entities app, observer config 113 108 # - app.chat.title -> apps module, chat app, title operation 114 109 # 115 - # DYNAMIC DISCOVERY: 116 - # Categories (observe/categories/*.json) and agents (muse/*.md, 117 - # apps/*/muse/*.md) can express tier/label/group in their frontmatter. 118 - # These are discovered at runtime and merged with the static defaults below. 110 + # DISCOVERY SOURCES: 111 + # 1. Prompt files listed in PROMPT_PATHS (with context in frontmatter) 112 + # 2. Categories from observe/categories/*.md (tier/label/group in frontmatter) 113 + # 3. Muse configs from muse/*.md and apps/*/muse/*.md 119 114 # 120 115 # When adding new contexts: 121 - # 1. Use module prefix matching the package (observe, think, app) 122 - # 2. Add specific operations as suffixes when granular control is needed 123 - # 3. Use wildcards sparingly - prefer explicit entries for clarity 124 - # 4. If not listed here, context falls back to DEFAULT_TIER (FLASH) 125 - # 5. For categories/agents, prefer adding tier/label/group to JSON configs 116 + # 1. Create a .md prompt file with YAML frontmatter containing: 117 + # context, tier, label, group 118 + # 2. Add the path to PROMPT_PATHS 119 + # 3. If not listed, context falls back to DEFAULT_TIER (FLASH) 126 120 # --------------------------------------------------------------------------- 127 121 128 - # Static context defaults - non-discoverable contexts only 129 - # Categories and agents express their own tier/label/group in JSON configs 130 - CONTEXT_DEFAULTS: Dict[str, Dict[str, Any]] = { 131 - # Observe pipeline - screen and audio capture processing 132 - "observe.describe.frame": { 133 - "tier": TIER_LITE, 134 - "label": "Screen Categorization", 135 - "group": "Observe", 136 - }, 137 - # Fallback for categories without explicit tier in their JSON 138 - "observe.describe.*": { 139 - "tier": TIER_FLASH, 140 - "label": "Screen Extraction", 141 - "group": "Observe", 142 - }, 143 - "observe.detect.segment": { 144 - "tier": TIER_FLASH, 145 - "label": "Segmentation", 146 - "group": "Import", 147 - }, 148 - "observe.detect.json": { 149 - "tier": TIER_FLASH, 150 - "label": "Normalization", 151 - "group": "Import", 152 - }, 153 - "observe.enrich": { 154 - "tier": TIER_FLASH, 155 - "label": "Audio Enrichment", 156 - "group": "Observe", 157 - }, 158 - "observe.transcribe.gemini": { 159 - "tier": TIER_FLASH, 160 - "label": "Audio Transcription (Gemini)", 161 - "group": "Observe", 162 - }, 163 - "observe.extract.selection": { 164 - "tier": TIER_FLASH, 165 - "label": "Frame Selection", 166 - "group": "Observe", 167 - }, 168 - "observe.summarize": { 169 - "tier": TIER_FLASH, 170 - "label": "Summarization", 171 - "group": "Import", 172 - }, 173 - # Utilities - miscellaneous processing tasks 174 - "detect.created": { 175 - "tier": TIER_LITE, 176 - "label": "Date Detection", 177 - "group": "Import", 178 - }, 179 - "planner.generate": { 180 - "tier": TIER_FLASH, 181 - "label": "Agent Prompt Generation", 182 - "group": "Think", 183 - }, 184 - # Apps - application-specific contexts 185 - "app.chat.title": { 186 - "tier": TIER_LITE, 187 - "label": "Chat Title Generation", 188 - "group": "Apps", 189 - }, 190 - } 122 + # Flat list of prompt files that define context metadata in frontmatter. 123 + # Each must have: context, tier, label, group in YAML frontmatter. 124 + PROMPT_PATHS: List[str] = [ 125 + "observe/describe.md", 126 + "observe/enrich.md", 127 + "observe/extract.md", 128 + "observe/transcribe/gemini.md", 129 + "think/detect_created.md", 130 + "think/detect_transcript_segment.md", 131 + "think/detect_transcript_json.md", 132 + "think/planner.md", 133 + "apps/chat/title.md", 134 + ] 191 135 192 136 193 137 # --------------------------------------------------------------------------- ··· 198 142 _context_registry: Optional[Dict[str, Dict[str, Any]]] = None 199 143 200 144 145 + def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]: 146 + """Load context metadata from prompt files listed in PROMPT_PATHS. 147 + 148 + Each file must have YAML frontmatter with: 149 + - context: The context string (e.g., "observe.enrich") 150 + - tier: Tier number (1=pro, 2=flash, 3=lite) 151 + - label: Human-readable name 152 + - group: Settings UI category 153 + 154 + Returns 155 + ------- 156 + Dict[str, Dict[str, Any]] 157 + Mapping of context patterns to {tier, label, group} dicts. 158 + """ 159 + contexts = {} 160 + base_dir = Path(__file__).parent.parent # Project root 161 + 162 + for rel_path in PROMPT_PATHS: 163 + path = base_dir / rel_path 164 + if not path.exists(): 165 + logging.getLogger(__name__).warning(f"Prompt file not found: {path}") 166 + continue 167 + 168 + try: 169 + post = frontmatter.load(path) 170 + meta = post.metadata or {} 171 + 172 + context = meta.get("context") 173 + if not context: 174 + logging.getLogger(__name__).warning(f"No context in {path}") 175 + continue 176 + 177 + contexts[context] = { 178 + "tier": meta.get("tier", TIER_FLASH), 179 + "label": meta.get("label", context), 180 + "group": meta.get("group", "Other"), 181 + } 182 + except Exception as e: 183 + logging.getLogger(__name__).warning(f"Failed to load {path}: {e}") 184 + 185 + return contexts 186 + 187 + 201 188 def _discover_muse_contexts() -> Dict[str, Dict[str, Any]]: 202 189 """Discover muse context defaults from muse/*.md config files. 203 190 ··· 265 252 266 253 267 254 def _build_context_registry() -> Dict[str, Dict[str, Any]]: 268 - """Build complete context registry from static defaults and discovered configs. 255 + """Build complete context registry from discovered configs. 269 256 270 257 Merges: 271 - 1. Static CONTEXT_DEFAULTS (non-discoverable contexts) 258 + 1. Prompt contexts from _discover_prompt_contexts() 272 259 2. Category contexts from observe/describe.py CATEGORIES 273 - 3. Agent contexts from _discover_agent_contexts() 260 + 3. Muse contexts from _discover_muse_contexts() 274 261 275 262 Returns 276 263 ------- 277 264 Dict[str, Dict[str, Any]] 278 265 Complete context registry mapping patterns to {tier, label, group}. 279 266 """ 280 - # Start with static defaults 281 - registry = dict(CONTEXT_DEFAULTS) 267 + # Start with prompt contexts (from PROMPT_PATHS) 268 + registry = _discover_prompt_contexts() 282 269 283 270 # Merge category contexts (lazy import to avoid circular dependency) 284 271 try: ··· 336 323 providers_config = journal_config.get("providers", {}) 337 324 contexts = providers_config.get("contexts", {}) 338 325 339 - # Get dynamic context registry (includes static defaults + discovered categories/agents) 326 + # Get dynamic context registry (discovered prompts, categories, muse configs) 340 327 registry = get_context_registry() 341 328 342 329 # Check journal config contexts first (exact match) ··· 496 483 497 484 # No context match - check dynamic context registry for this context 498 485 if match_config is None: 499 - # Get dynamic context registry (includes static defaults + discovered categories/agents) 486 + # Get dynamic context registry (discovered prompts, categories, muse configs) 500 487 registry = get_context_registry() 501 488 502 489 # Check for matching context default (exact match first, then glob) ··· 1163 1150 # Provider configuration 1164 1151 "DEFAULT_TIER", 1165 1152 "DEFAULT_PROVIDER", 1166 - "CONTEXT_DEFAULTS", 1153 + "PROMPT_PATHS", 1167 1154 "get_context_registry", 1168 1155 # Model constants (used by provider backends for defaults) 1169 1156 "GEMINI_FLASH",
+6
think/planner.md
··· 1 + --- 2 + context: planner.generate 3 + tier: 2 4 + label: Agent Prompt Generation 5 + group: Think 6 + --- 1 7 You are a strategic research planner for the solstone journal assistant, specialized in creating comprehensive plans to research and analyze personal journal data to answer user requests. 2 8 3 9 ## Core Role and Limitations