personal memory agent
0
fork

Configure Feed

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

Extract muse utilities into dedicated think/muse.py module

Consolidate all muse-related functionality from think/utils.py into a new
think/muse.py module for better organization and maintainability:

- Move prompt loading (load_prompt, PromptContent, PromptNotFoundError)
- Move config discovery (get_muse_configs, get_agent, _load_prompt_metadata)
- Move instruction composition (compose_instructions, _merge_instructions_config)
- Move source helpers (source_is_enabled, source_is_required, get_agent_filter)
- Move output path utilities (key_to_context, get_output_topic, get_output_path)
- Move hook loading (load_pre_hook, load_post_hook, _resolve_hook_path)

Update all consumers to import from think.muse instead of think.utils.
Create tests/test_muse.py with dedicated tests for the new module.
Update documentation references from think/utils.py to think/muse.py.

No legacy re-exports or backwards compatibility - all usages updated directly.

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

+1424 -1362
+2 -2
apps/agents/routes.py
··· 18 18 from convey.utils import DATE_RE, format_date 19 19 from think.facets import get_facets 20 20 from think.models import calc_agent_cost 21 - from think.utils import get_muse_configs 21 + from think.muse import get_muse_configs 22 22 23 23 agents_bp = Blueprint( 24 24 "app:agents", ··· 357 357 } 358 358 """ 359 359 try: 360 - from think.utils import get_agent 360 + from think.muse import get_agent 361 361 362 362 config = get_agent(name) 363 363
+1 -1
apps/calendar/routes.py
··· 55 55 return "", 404 56 56 57 57 from think.indexer.journal import get_events 58 - from think.utils import get_muse_configs 58 + from think.muse import get_muse_configs 59 59 60 60 generators = get_muse_configs(has_tools=False, has_output=True) 61 61
+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.utils import load_prompt 116 + from think.muse import load_prompt 117 117 118 118 prompt = load_prompt("title", base_dir=Path(__file__).parent) 119 119 try:
+3 -2
apps/insights/routes.py
··· 15 15 16 16 from convey.utils import DATE_RE, format_date 17 17 from think.models import get_usage_cost 18 - from think.utils import day_dirs, day_path, get_muse_configs, get_output_topic 18 + from think.muse import get_muse_configs, get_output_topic 19 + from think.utils import day_dirs, day_path 19 20 20 21 insights_bp = Blueprint( 21 22 "app:insights", ··· 85 86 meta = info["meta"] 86 87 87 88 # Get generation cost for this generator 88 - from think.utils import key_to_context 89 + from think.muse import key_to_context 89 90 90 91 cost_data = get_usage_cost(day, context=key_to_context(key)) 91 92 cost = cost_data["cost"] if cost_data["cost"] > 0 else None
+4 -4
apps/settings/routes.py
··· 260 260 DEFAULT_TIER, 261 261 get_context_registry, 262 262 ) 263 + from think.muse import get_muse_configs 263 264 from think.providers import get_provider_list 264 - from think.utils import get_muse_configs 265 265 266 266 config = get_journal_config() 267 267 providers_config = config.get("providers", {}) ··· 287 287 context_defaults[pattern]["has_tools"] = ctx_config["has_tools"] 288 288 289 289 # Enhance muse contexts with additional metadata from get_muse_configs 290 - from think.utils import key_to_context 290 + from think.muse import key_to_context 291 291 292 292 muse_configs = get_muse_configs(include_disabled=True) 293 293 for key, info in muse_configs.items(): ··· 543 543 - daily: List of daily-schedule generators 544 544 """ 545 545 try: 546 - from think.utils import get_muse_configs 546 + from think.muse import get_muse_configs 547 547 548 548 # Get all generators (has output but no tools) 549 549 all_generators = get_muse_configs( ··· 599 599 old_contexts = old_providers.get("contexts", {}) 600 600 changed_fields = {} 601 601 602 - from think.utils import key_to_context 602 + from think.muse import key_to_context 603 603 604 604 for key, updates in request_data.items(): 605 605 if not isinstance(updates, dict):
+1 -1
apps/stats/routes.py
··· 10 10 from flask import Blueprint, jsonify 11 11 12 12 from convey import state 13 - from think.utils import get_muse_configs 13 + from think.muse import get_muse_configs 14 14 15 15 stats_bp = Blueprint( 16 16 "app:stats",
+4 -4
docs/APPS.md
··· 333 333 **Reference implementations:** 334 334 - System generator templates: `muse/*.md` (files with `schedule` field but no `tools` field) 335 335 - Extraction hooks: `muse/occurrence.py`, `muse/anticipation.py` 336 - - Discovery logic: `think/utils.py` - `get_muse_configs(has_tools=False)`, `get_output_topic()` 337 - - Hook loading: `think/agents.py` - `load_pre_hook()`, `load_post_hook()` 336 + - Discovery logic: `think/muse.py` - `get_muse_configs(has_tools=False)`, `get_output_topic()` 337 + - Hook loading: `think/muse.py` - `load_pre_hook()`, `load_post_hook()` 338 338 339 339 --- 340 340 ··· 356 356 357 357 **Reference implementations:** 358 358 - System agent examples: `muse/*.md` (files with `tools` field) 359 - - Discovery logic: `think/utils.py` - `get_muse_configs(has_tools=True)`, `get_agent()` 359 + - Discovery logic: `think/muse.py` - `get_muse_configs(has_tools=True)`, `get_agent()` 360 360 361 361 #### Instructions Configuration 362 362 ··· 380 380 - `"required"` - load, and skip generation if no content found (useful for generators that only make sense with specific input types, e.g., `"audio": "required"` for speaker detection) 381 381 - For `agents` only: a dict for selective filtering, e.g., `{"entities": true, "meetings": "required", "flow": false}`. Keys are agent names (system) or `"app:topic"` (app-namespaced). An empty dict `{}` means no agents. 382 382 383 - **Authoritative source:** `think/utils.py` - `compose_instructions()`, `_DEFAULT_INSTRUCTIONS`, `source_is_enabled()`, `source_is_required()`, `get_agent_filter()` 383 + **Authoritative source:** `think/muse.py` - `compose_instructions()`, `_DEFAULT_INSTRUCTIONS`, `source_is_enabled()`, `source_is_required()`, `get_agent_filter()` 384 384 385 385 --- 386 386
+1 -1
docs/CORTEX.md
··· 264 264 When spawning an agent: 265 265 1. Cortex passes the raw request to `sol agents` via stdin (NDJSON format) 266 266 2. The agent process (`think/agents.py`) handles all config loading via `hydrate_config()`: 267 - - Loads agent configuration using `get_agent()` from `think/utils.py` 267 + - Loads agent configuration using `get_agent()` from `think/muse.py` 268 268 - Merges request parameters with agent defaults 269 269 - Resolves provider and model based on context 270 270 - Expands tool pack names to tool lists
+1 -1
docs/JOURNAL.md
··· 971 971 - `muse/*.md` – system generator templates (files with `schedule` field but no `tools` field) 972 972 - `apps/{app}/muse/*.md` – app-specific generator templates 973 973 974 - Each template is a `.md` file with JSON frontmatter containing metadata (title, description, schedule, output format). The `schedule` field is required and must be `"segment"` or `"daily"` - generators with missing or invalid schedule are skipped. Use `get_muse_configs(has_tools=False)` from `think/utils.py` to retrieve all available generators, or `get_muse_configs(has_tools=False, schedule="daily")` to get generators filtered by schedule. 974 + Each template is a `.md` file with JSON frontmatter containing metadata (title, description, schedule, output format). The `schedule` field is required and must be `"segment"` or `"daily"` - generators with missing or invalid schedule are skipped. Use `get_muse_configs(has_tools=False)` from `think/muse.py` to retrieve all available generators, or `get_muse_configs(has_tools=False, schedule="daily")` to get generators filtered by schedule. 975 975 976 976 **Output naming:** 977 977 - System outputs: `agents/{topic}.md` (e.g., `agents/flow.md`, `agents/meetings.md`)
+7 -7
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/utils.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/muse.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/utils.py` → `_flatten_identity_to_template_vars()` 65 + - Flattening implementation: `think/muse.py` → `_flatten_identity_to_template_vars()` 66 66 67 67 ### Template Variables 68 68 ··· 144 144 145 145 **Optional model configuration:** Add `max_output_tokens` (response length limit) and `thinking_budget` (model thinking token budget) to override provider defaults. Note: OpenAI uses fixed reasoning and ignores `thinking_budget`. 146 146 147 - **Reference:** `think/utils.py` → `get_agent()` for agent configuration loading 147 + **Reference:** `think/muse.py` → `get_agent()` for agent configuration loading 148 148 149 149 ### The load_prompt() Function 150 150 ··· 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/utils.py` → `load_prompt()` 162 + **Reference:** `think/muse.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/utils.py` (`_flatten_identity_to_template_vars`) | 188 - | Template loading | `think/utils.py` (`_load_templates`) | 189 - | Core load function | `think/utils.py` (`load_prompt`) | 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`) | 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`) |
+1 -1
docs/THINK.md
··· 179 179 180 180 ## Generator map keys 181 181 182 - `think.utils.get_muse_configs(has_tools=False)` reads the `.md` prompt files under `muse/` and 182 + `think.muse.get_muse_configs(has_tools=False)` reads the `.md` prompt files under `muse/` and 183 183 returns a dictionary keyed by generator name. Each entry contains: 184 184 185 185 - `path` – the prompt file path
+1 -1
muse/anticipation.py
··· 19 19 write_events_jsonl, 20 20 ) 21 21 from think.models import generate 22 - from think.utils import get_output_topic, load_prompt 22 + from think.muse import get_output_topic, load_prompt 23 23 24 24 25 25 def post_process(result: str, context: dict) -> str | None:
+1 -1
muse/occurrence.py
··· 19 19 write_events_jsonl, 20 20 ) 21 21 from think.models import generate 22 - from think.utils import get_output_topic, load_prompt 22 + from think.muse import get_output_topic, load_prompt 23 23 24 24 25 25 def post_process(result: str, context: dict) -> str | None:
+2 -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.utils import get_config, get_journal, load_prompt, setup_cli 38 + from think.muse import load_prompt 39 + from think.utils import get_config, get_journal, setup_cli 39 40 40 41 logger = logging.getLogger(__name__) 41 42
+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.utils import load_prompt 26 + from think.muse 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.utils import load_prompt 208 + from think.muse 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.utils import load_prompt 35 + from think.muse import load_prompt 36 36 37 37 logger = logging.getLogger(__name__) 38 38
+1 -1
tests/test_app_agents.py
··· 8 8 9 9 import pytest 10 10 11 - from think.utils import _resolve_agent_path, get_agent, get_muse_configs 11 + from think.muse import _resolve_agent_path, get_agent, get_muse_configs 12 12 13 13 14 14 @pytest.fixture
+1 -1
tests/test_dream_full.py
··· 106 106 107 107 def test_priority_validation_required(tmp_path, monkeypatch): 108 108 """Test that get_muse_configs raises error for scheduled prompts without priority.""" 109 - from think.utils import get_muse_configs 109 + from think.muse import get_muse_configs 110 110 111 111 # Create a test muse file without priority 112 112 muse_dir = Path(__file__).parent.parent / "muse"
+1 -1
tests/test_entity_agents.py
··· 7 7 8 8 import pytest 9 9 10 - from think.utils import get_agent 10 + from think.muse import get_agent 11 11 12 12 13 13 @pytest.fixture
+21 -21
tests/test_generators.py
··· 7 7 8 8 def test_get_muse_configs_generators(): 9 9 """Test that system generators are discovered with source field.""" 10 - utils = importlib.import_module("think.utils") 11 - generators = utils.get_muse_configs(has_tools=False, has_output=True) 10 + muse = importlib.import_module("think.muse") 11 + generators = muse.get_muse_configs(has_tools=False, has_output=True) 12 12 assert "flow" in generators 13 13 info = generators["flow"] 14 14 assert os.path.basename(info["path"]) == "flow.md" ··· 22 22 23 23 def test_get_output_topic(): 24 24 """Test generator key to filename conversion.""" 25 - utils = importlib.import_module("think.utils") 25 + muse = importlib.import_module("think.muse") 26 26 27 27 # System generators: key unchanged 28 - assert utils.get_output_topic("activity") == "activity" 29 - assert utils.get_output_topic("flow") == "flow" 28 + assert muse.get_output_topic("activity") == "activity" 29 + assert muse.get_output_topic("flow") == "flow" 30 30 31 31 # App generators: _app_topic format 32 - assert utils.get_output_topic("chat:sentiment") == "_chat_sentiment" 33 - assert utils.get_output_topic("my_app:weekly_summary") == "_my_app_weekly_summary" 32 + assert muse.get_output_topic("chat:sentiment") == "_chat_sentiment" 33 + assert muse.get_output_topic("my_app:weekly_summary") == "_my_app_weekly_summary" 34 34 35 35 36 36 def test_get_muse_configs_app_discovery(tmp_path, monkeypatch): 37 37 """Test that app generators are discovered from apps/*/muse/.""" 38 - utils = importlib.import_module("think.utils") 38 + muse = importlib.import_module("think.muse") 39 39 40 40 # Create a fake app with a generator 41 41 app_dir = tmp_path / "apps" / "test_app" / "muse" ··· 50 50 (tmp_path / "apps" / "test_app" / "workspace.html").write_text("<h1>Test</h1>") 51 51 52 52 # For now, just verify system generators have correct source 53 - generators = utils.get_muse_configs(has_tools=False, has_output=True) 53 + generators = muse.get_muse_configs(has_tools=False, has_output=True) 54 54 for key, info in generators.items(): 55 55 if ":" not in key: 56 56 assert info.get("source") == "system", f"{key} should have source=system" ··· 58 58 59 59 def test_get_muse_configs_by_schedule(): 60 60 """Test filtering generators by schedule.""" 61 - utils = importlib.import_module("think.utils") 61 + muse = importlib.import_module("think.muse") 62 62 63 63 # Get daily generators 64 - daily = utils.get_muse_configs(has_tools=False, has_output=True, schedule="daily") 64 + daily = muse.get_muse_configs(has_tools=False, has_output=True, schedule="daily") 65 65 assert len(daily) > 0 66 66 for key, meta in daily.items(): 67 67 assert meta.get("schedule") == "daily", f"{key} should have schedule=daily" 68 68 69 69 # Get segment generators 70 - segment = utils.get_muse_configs( 70 + segment = muse.get_muse_configs( 71 71 has_tools=False, has_output=True, schedule="segment" 72 72 ) 73 73 assert len(segment) > 0 ··· 81 81 82 82 # Unknown schedule returns empty dict 83 83 assert ( 84 - utils.get_muse_configs(has_tools=False, has_output=True, schedule="hourly") 84 + muse.get_muse_configs(has_tools=False, has_output=True, schedule="hourly") 85 85 == {} 86 86 ) 87 - assert utils.get_muse_configs(has_tools=False, has_output=True, schedule="") == {} 87 + assert muse.get_muse_configs(has_tools=False, has_output=True, schedule="") == {} 88 88 89 89 90 90 def test_get_muse_configs_include_disabled(monkeypatch): 91 91 """Test include_disabled parameter.""" 92 - utils = importlib.import_module("think.utils") 92 + muse = importlib.import_module("think.muse") 93 93 94 94 # Get generators without disabled (default) 95 - without_disabled = utils.get_muse_configs( 95 + without_disabled = muse.get_muse_configs( 96 96 has_tools=False, has_output=True, schedule="daily" 97 97 ) 98 98 99 99 # Get generators with disabled included 100 - with_disabled = utils.get_muse_configs( 100 + with_disabled = muse.get_muse_configs( 101 101 has_tools=False, has_output=True, schedule="daily", include_disabled=True 102 102 ) 103 103 ··· 113 113 Some generators (like importer) have output but no schedule - they're used 114 114 for ad-hoc processing, not scheduled runs. 115 115 """ 116 - utils = importlib.import_module("think.utils") 116 + muse = importlib.import_module("think.muse") 117 117 118 - generators = utils.get_muse_configs(has_tools=False, has_output=True) 118 + generators = muse.get_muse_configs(has_tools=False, has_output=True) 119 119 valid_schedules = ("segment", "daily") 120 120 121 121 for key, meta in generators.items(): ··· 128 128 129 129 def test_speakers_has_required_audio(): 130 130 """Test that speakers generator has audio as required source.""" 131 - utils = importlib.import_module("think.utils") 131 + muse = importlib.import_module("think.muse") 132 132 133 - generators = utils.get_muse_configs( 133 + generators = muse.get_muse_configs( 134 134 has_tools=False, has_output=True, schedule="segment" 135 135 ) 136 136 assert "speakers" in generators
+324
tests/test_muse.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for think.muse module. 5 + 6 + Tests for muse prompt loading, configuration, and instruction composition. 7 + """ 8 + 9 + import pytest 10 + 11 + from think.muse import ( 12 + _merge_instructions_config, 13 + compose_instructions, 14 + get_agent_filter, 15 + source_is_enabled, 16 + source_is_required, 17 + ) 18 + 19 + 20 + # ============================================================================= 21 + # _merge_instructions_config tests 22 + # ============================================================================= 23 + 24 + 25 + def test_merge_instructions_config_empty_overrides(): 26 + """Test that empty overrides returns defaults copy.""" 27 + defaults = {"system": "journal", "facets": True, "sources": {"audio": False}} 28 + result = _merge_instructions_config(defaults, None) 29 + assert result == defaults 30 + assert result is not defaults # Should be a copy 31 + 32 + 33 + def test_merge_instructions_config_with_overrides(): 34 + """Test that overrides are merged correctly.""" 35 + defaults = {"system": "journal", "facets": True, "sources": {"audio": False}} 36 + overrides = {"system": "custom", "facets": False} 37 + result = _merge_instructions_config(defaults, overrides) 38 + assert result["system"] == "custom" 39 + assert result["facets"] is False 40 + assert result["sources"] == {"audio": False} # Preserved 41 + 42 + 43 + def test_merge_instructions_config_sources_merge(): 44 + """Test that sources dict is merged, not replaced.""" 45 + defaults = {"system": None, "sources": {"audio": False, "screen": False}} 46 + overrides = {"sources": {"audio": True}} 47 + result = _merge_instructions_config(defaults, overrides) 48 + assert result["sources"]["audio"] is True # Overridden 49 + assert result["sources"]["screen"] is False # Preserved from defaults 50 + 51 + 52 + def test_merge_instructions_config_ignores_unknown_keys(): 53 + """Test that unknown keys in overrides are ignored.""" 54 + defaults = {"system": "journal", "facets": True} 55 + overrides = {"unknown_key": "value", "another": 123} 56 + result = _merge_instructions_config(defaults, overrides) 57 + assert "unknown_key" not in result 58 + assert "another" not in result 59 + 60 + 61 + def test_merge_instructions_config_facets_override(): 62 + """Test that facets key can be overridden with different values.""" 63 + defaults = {"system": "journal", "facets": True} 64 + overrides = {"facets": "full"} 65 + result = _merge_instructions_config(defaults, overrides) 66 + assert result["system"] == "journal" 67 + assert result["facets"] == "full" 68 + 69 + 70 + # ============================================================================= 71 + # compose_instructions tests 72 + # ============================================================================= 73 + 74 + 75 + class TestComposeInstructions: 76 + """Tests for compose_instructions function.""" 77 + 78 + def test_default_system_instruction_is_none(self, monkeypatch, tmp_path): 79 + """Test that default system instruction is empty (agents must opt-in).""" 80 + think_dir = tmp_path / "think" 81 + think_dir.mkdir() 82 + 83 + import think.muse 84 + 85 + original_file = think.muse.__file__ 86 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 87 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 88 + 89 + result = compose_instructions() 90 + 91 + # Restore 92 + monkeypatch.setattr(think.muse, "__file__", original_file) 93 + 94 + assert "system_instruction" in result 95 + assert result["system_instruction"] == "" 96 + assert result["system_prompt_name"] == "" 97 + 98 + def test_custom_system_instruction(self, monkeypatch, tmp_path): 99 + """Test that custom system prompt can be loaded.""" 100 + think_dir = tmp_path / "think" 101 + think_dir.mkdir() 102 + custom_txt = think_dir / "custom.md" 103 + custom_txt.write_text("Custom system instruction") 104 + 105 + import think.muse 106 + 107 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 108 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 109 + 110 + result = compose_instructions( 111 + config_overrides={"system": "custom"}, 112 + ) 113 + 114 + assert result["system_prompt_name"] == "custom" 115 + assert "Custom system instruction" in result["system_instruction"] 116 + 117 + def test_user_instruction_loaded_when_provided(self, monkeypatch, tmp_path): 118 + """Test that user instruction is loaded when user_prompt is provided.""" 119 + think_dir = tmp_path / "think" 120 + think_dir.mkdir() 121 + journal_txt = think_dir / "journal.md" 122 + journal_txt.write_text("System instruction") 123 + user_txt = think_dir / "default.md" 124 + user_txt.write_text("User instruction content") 125 + 126 + import think.muse 127 + 128 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 129 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 130 + 131 + result = compose_instructions(user_prompt="default") 132 + 133 + assert result["user_instruction"] == "User instruction content" 134 + 135 + def test_user_instruction_none_when_not_provided(self, monkeypatch, tmp_path): 136 + """Test that user instruction is None when user_prompt is not provided.""" 137 + think_dir = tmp_path / "think" 138 + think_dir.mkdir() 139 + journal_txt = think_dir / "journal.md" 140 + journal_txt.write_text("System instruction") 141 + 142 + import think.muse 143 + 144 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 145 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 146 + 147 + result = compose_instructions() 148 + 149 + assert result["user_instruction"] is None 150 + 151 + def test_facets_none_excludes_facets_from_context(self, monkeypatch, tmp_path): 152 + """Test that facets='none' excludes facet info from extra_context.""" 153 + think_dir = tmp_path / "think" 154 + think_dir.mkdir() 155 + journal_txt = think_dir / "journal.md" 156 + journal_txt.write_text("System instruction") 157 + 158 + import think.muse 159 + 160 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 161 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 162 + 163 + result = compose_instructions( 164 + include_datetime=False, 165 + config_overrides={"facets": False}, 166 + ) 167 + 168 + # With no datetime and no facets, extra_context should be empty/None 169 + assert result["extra_context"] is None or result["extra_context"] == "" 170 + 171 + def test_include_datetime_false_excludes_time(self, monkeypatch, tmp_path): 172 + """Test that include_datetime=False excludes time from context.""" 173 + think_dir = tmp_path / "think" 174 + think_dir.mkdir() 175 + journal_txt = think_dir / "journal.md" 176 + journal_txt.write_text("System instruction") 177 + 178 + import think.muse 179 + 180 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 181 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 182 + 183 + result = compose_instructions( 184 + include_datetime=False, 185 + config_overrides={"facets": False}, 186 + ) 187 + 188 + extra = result.get("extra_context") or "" 189 + assert "Current Date and Time" not in extra 190 + 191 + def test_include_datetime_true_includes_time(self, monkeypatch, tmp_path): 192 + """Test that include_datetime=True includes time in context.""" 193 + think_dir = tmp_path / "think" 194 + think_dir.mkdir() 195 + journal_txt = think_dir / "journal.md" 196 + journal_txt.write_text("System instruction") 197 + 198 + import think.muse 199 + 200 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 201 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 202 + 203 + result = compose_instructions( 204 + include_datetime=True, 205 + config_overrides={"facets": False}, 206 + ) 207 + 208 + assert "Current Date and Time" in result["extra_context"] 209 + 210 + def test_sources_returned_from_defaults(self, monkeypatch, tmp_path): 211 + """Test that sources config is returned with defaults (all false).""" 212 + think_dir = tmp_path / "think" 213 + think_dir.mkdir() 214 + 215 + import think.muse 216 + 217 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 218 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 219 + 220 + result = compose_instructions() 221 + 222 + assert "sources" in result 223 + assert result["sources"]["audio"] is False 224 + assert result["sources"]["screen"] is False 225 + assert result["sources"]["agents"] is False 226 + 227 + def test_sources_can_be_overridden(self, monkeypatch, tmp_path): 228 + """Test that sources config can be overridden.""" 229 + think_dir = tmp_path / "think" 230 + think_dir.mkdir() 231 + 232 + import think.muse 233 + 234 + monkeypatch.setattr(think.muse, "__file__", str(think_dir / "muse.py")) 235 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 236 + 237 + result = compose_instructions( 238 + config_overrides={ 239 + "sources": {"audio": True, "agents": True}, 240 + }, 241 + ) 242 + 243 + assert result["sources"]["audio"] is True # Overridden 244 + assert result["sources"]["screen"] is False # Default preserved 245 + assert result["sources"]["agents"] is True # Overridden 246 + 247 + 248 + # ============================================================================= 249 + # source_is_enabled / source_is_required / get_agent_filter tests 250 + # ============================================================================= 251 + 252 + 253 + def test_source_is_enabled_bool(): 254 + """Test source_is_enabled with bool values.""" 255 + assert source_is_enabled(True) is True 256 + assert source_is_enabled(False) is False 257 + 258 + 259 + def test_source_is_enabled_required_string(): 260 + """Test source_is_enabled with 'required' string.""" 261 + assert source_is_enabled("required") is True 262 + 263 + 264 + def test_source_is_enabled_dict(): 265 + """Test source_is_enabled with dict values for agents source.""" 266 + # Dict with at least one True value -> enabled 267 + assert source_is_enabled({"entities": True, "meetings": False}) is True 268 + 269 + # Dict with at least one "required" value -> enabled 270 + assert source_is_enabled({"entities": "required", "meetings": False}) is True 271 + 272 + # Dict with all False values -> disabled 273 + assert source_is_enabled({"entities": False, "meetings": False}) is False 274 + 275 + # Empty dict -> disabled 276 + assert source_is_enabled({}) is False 277 + 278 + 279 + def test_source_is_required_bool(): 280 + """Test source_is_required with bool values.""" 281 + assert source_is_required(True) is False 282 + assert source_is_required(False) is False 283 + 284 + 285 + def test_source_is_required_string(): 286 + """Test source_is_required with 'required' string.""" 287 + assert source_is_required("required") is True 288 + 289 + 290 + def test_source_is_required_dict(): 291 + """Test source_is_required with dict values.""" 292 + # Dict with at least one "required" value -> required 293 + assert source_is_required({"entities": "required", "meetings": False}) is True 294 + 295 + # Dict with no "required" values -> not required 296 + assert source_is_required({"entities": True, "meetings": False}) is False 297 + 298 + # Empty dict -> not required 299 + assert source_is_required({}) is False 300 + 301 + 302 + def test_get_agent_filter_bool(): 303 + """Test get_agent_filter with bool values.""" 304 + # True -> None (all agents) 305 + assert get_agent_filter(True) is None 306 + 307 + # False -> empty dict (no agents) 308 + assert get_agent_filter(False) == {} 309 + 310 + 311 + def test_get_agent_filter_required_string(): 312 + """Test get_agent_filter with 'required' string.""" 313 + # "required" -> None (all agents, required) 314 + assert get_agent_filter("required") is None 315 + 316 + 317 + def test_get_agent_filter_dict(): 318 + """Test get_agent_filter with dict values.""" 319 + # Dict -> returned as-is for filtering 320 + filter_dict = {"entities": True, "meetings": "required", "flow": False} 321 + assert get_agent_filter(filter_dict) == filter_dict 322 + 323 + # Empty dict -> empty dict (no agents) 324 + assert get_agent_filter({}) == {}
+2 -2
tests/test_output_hooks.py
··· 150 150 151 151 def test_prompt_metadata_no_hook_path(tmp_path): 152 152 """Test that _load_prompt_metadata no longer sets hook_path.""" 153 - utils = importlib.import_module("think.utils") 153 + muse = importlib.import_module("think.muse") 154 154 155 155 md_file = tmp_path / "test_generator.md" 156 156 md_file.write_text( ··· 161 161 hook_file = tmp_path / "test_generator.py" 162 162 hook_file.write_text("def post_process(r, c): return r") 163 163 164 - meta = utils._load_prompt_metadata(md_file) 164 + meta = muse._load_prompt_metadata(md_file) 165 165 166 166 # hook_path should no longer be set (hooks are loaded via load_post_hook) 167 167 assert "hook_path" not in meta
+1 -1
tests/test_output_path.py
··· 5 5 6 6 from pathlib import Path 7 7 8 - from think.utils import get_output_path, get_output_topic 8 + from think.muse import get_output_path, get_output_topic 9 9 10 10 11 11 class TestGetOutputTopic:
+1 -1
tests/test_template_substitution.py
··· 8 8 9 9 import pytest 10 10 11 - from think.utils import _flatten_identity_to_template_vars, load_prompt 11 + from think.muse import _flatten_identity_to_template_vars, load_prompt 12 12 13 13 14 14 @pytest.fixture
+1 -330
tests/test_think_utils.py
··· 13 13 import pytest 14 14 15 15 from think.entities import load_entity_names 16 - from think.utils import ( 17 - _merge_instructions_config, 18 - compose_instructions, 19 - segment_key, 20 - setup_cli, 21 - ) 16 + from think.utils import segment_key, setup_cli 22 17 23 18 24 19 def setup_entities_new_structure( ··· 658 653 assert os.environ.get("BOOL_VAR") == "True" 659 654 660 655 661 - class TestMergeInstructionsConfig: 662 - """Tests for _merge_instructions_config helper.""" 663 - 664 - def test_returns_defaults_when_no_overrides(self): 665 - """Test that defaults are returned when overrides is None.""" 666 - defaults = {"system": "journal", "facets": True} 667 - result = _merge_instructions_config(defaults, None) 668 - assert result == defaults 669 - # Should be a copy, not the same object 670 - assert result is not defaults 671 - 672 - def test_returns_defaults_when_empty_overrides(self): 673 - """Test that defaults are returned when overrides is empty dict.""" 674 - defaults = {"system": "journal", "facets": True} 675 - result = _merge_instructions_config(defaults, {}) 676 - assert result == defaults 677 - 678 - def test_overrides_system_key(self): 679 - """Test that system key can be overridden.""" 680 - defaults = {"system": "journal", "facets": True} 681 - overrides = {"system": "custom_prompt"} 682 - result = _merge_instructions_config(defaults, overrides) 683 - assert result["system"] == "custom_prompt" 684 - assert result["facets"] is True 685 - 686 - def test_overrides_facets_key(self): 687 - """Test that facets key can be overridden.""" 688 - defaults = {"system": "journal", "facets": True} 689 - overrides = {"facets": "full"} 690 - result = _merge_instructions_config(defaults, overrides) 691 - assert result["system"] == "journal" 692 - assert result["facets"] == "full" 693 - 694 - def test_merges_sources_dict(self): 695 - """Test that sources dict is merged, not replaced.""" 696 - defaults = { 697 - "system": "journal", 698 - "sources": {"audio": True, "screen": True, "agents": False}, 699 - } 700 - overrides = {"sources": {"screen": False}} 701 - result = _merge_instructions_config(defaults, overrides) 702 - assert result["sources"]["audio"] is True # Preserved from defaults 703 - assert result["sources"]["screen"] is False # Overridden 704 - assert result["sources"]["agents"] is False # Preserved from defaults 705 - 706 - def test_ignores_unknown_keys(self): 707 - """Test that unknown keys in overrides are ignored.""" 708 - defaults = {"system": "journal", "facets": True} 709 - overrides = {"unknown_key": "value", "another": 123} 710 - result = _merge_instructions_config(defaults, overrides) 711 - assert "unknown_key" not in result 712 - assert "another" not in result 713 - 714 - 715 - class TestComposeInstructions: 716 - """Tests for compose_instructions function.""" 717 - 718 - def test_default_system_instruction_is_none(self, monkeypatch, tmp_path): 719 - """Test that default system instruction is empty (agents must opt-in).""" 720 - think_dir = tmp_path / "think" 721 - think_dir.mkdir() 722 - 723 - import think.utils 724 - 725 - original_file = think.utils.__file__ 726 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 727 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 728 - 729 - result = compose_instructions() 730 - 731 - # Restore 732 - monkeypatch.setattr(think.utils, "__file__", original_file) 733 - 734 - assert "system_instruction" in result 735 - assert result["system_instruction"] == "" 736 - assert result["system_prompt_name"] == "" 737 - 738 - def test_custom_system_instruction(self, monkeypatch, tmp_path): 739 - """Test that custom system prompt can be loaded.""" 740 - think_dir = tmp_path / "think" 741 - think_dir.mkdir() 742 - custom_txt = think_dir / "custom.md" 743 - custom_txt.write_text("Custom system instruction") 744 - 745 - import think.utils 746 - 747 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 748 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 749 - 750 - result = compose_instructions( 751 - config_overrides={"system": "custom"}, 752 - ) 753 - 754 - assert result["system_prompt_name"] == "custom" 755 - assert "Custom system instruction" in result["system_instruction"] 756 - 757 - def test_user_instruction_loaded_when_provided(self, monkeypatch, tmp_path): 758 - """Test that user instruction is loaded when user_prompt is provided.""" 759 - think_dir = tmp_path / "think" 760 - think_dir.mkdir() 761 - journal_txt = think_dir / "journal.md" 762 - journal_txt.write_text("System instruction") 763 - user_txt = think_dir / "default.md" 764 - user_txt.write_text("User instruction content") 765 - 766 - import think.utils 767 - 768 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 769 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 770 - 771 - result = compose_instructions(user_prompt="default") 772 - 773 - assert result["user_instruction"] == "User instruction content" 774 - 775 - def test_user_instruction_none_when_not_provided(self, monkeypatch, tmp_path): 776 - """Test that user instruction is None when user_prompt is not provided.""" 777 - think_dir = tmp_path / "think" 778 - think_dir.mkdir() 779 - journal_txt = think_dir / "journal.md" 780 - journal_txt.write_text("System instruction") 781 - 782 - import think.utils 783 - 784 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 785 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 786 - 787 - result = compose_instructions() 788 - 789 - assert result["user_instruction"] is None 790 - 791 - def test_facets_none_excludes_facets_from_context(self, monkeypatch, tmp_path): 792 - """Test that facets='none' excludes facet info from extra_context.""" 793 - think_dir = tmp_path / "think" 794 - think_dir.mkdir() 795 - journal_txt = think_dir / "journal.md" 796 - journal_txt.write_text("System instruction") 797 - 798 - import think.utils 799 - 800 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 801 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 802 - 803 - result = compose_instructions( 804 - include_datetime=False, 805 - config_overrides={"facets": False}, 806 - ) 807 - 808 - # With no datetime and no facets, extra_context should be empty/None 809 - assert result["extra_context"] is None or result["extra_context"] == "" 810 - 811 - def test_include_datetime_false_excludes_time(self, monkeypatch, tmp_path): 812 - """Test that include_datetime=False excludes time from context.""" 813 - think_dir = tmp_path / "think" 814 - think_dir.mkdir() 815 - journal_txt = think_dir / "journal.md" 816 - journal_txt.write_text("System instruction") 817 - 818 - import think.utils 819 - 820 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 821 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 822 - 823 - result = compose_instructions( 824 - include_datetime=False, 825 - config_overrides={"facets": False}, 826 - ) 827 - 828 - extra = result.get("extra_context") or "" 829 - assert "Current Date and Time" not in extra 830 - 831 - def test_include_datetime_true_includes_time(self, monkeypatch, tmp_path): 832 - """Test that include_datetime=True includes time in context.""" 833 - think_dir = tmp_path / "think" 834 - think_dir.mkdir() 835 - journal_txt = think_dir / "journal.md" 836 - journal_txt.write_text("System instruction") 837 - 838 - import think.utils 839 - 840 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 841 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 842 - 843 - result = compose_instructions( 844 - include_datetime=True, 845 - config_overrides={"facets": False}, 846 - ) 847 - 848 - assert "Current Date and Time" in result["extra_context"] 849 - 850 - def test_sources_returned_from_defaults(self, monkeypatch, tmp_path): 851 - """Test that sources config is returned with defaults (all false).""" 852 - think_dir = tmp_path / "think" 853 - think_dir.mkdir() 854 - 855 - import think.utils 856 - 857 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 858 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 859 - 860 - result = compose_instructions() 861 - 862 - assert "sources" in result 863 - assert result["sources"]["audio"] is False 864 - assert result["sources"]["screen"] is False 865 - assert result["sources"]["agents"] is False 866 - 867 - def test_sources_can_be_overridden(self, monkeypatch, tmp_path): 868 - """Test that sources config can be overridden.""" 869 - think_dir = tmp_path / "think" 870 - think_dir.mkdir() 871 - 872 - import think.utils 873 - 874 - monkeypatch.setattr(think.utils, "__file__", str(think_dir / "utils.py")) 875 - monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 876 - 877 - result = compose_instructions( 878 - config_overrides={ 879 - "sources": {"audio": True, "agents": True}, 880 - }, 881 - ) 882 - 883 - assert result["sources"]["audio"] is True # Overridden 884 - assert result["sources"]["screen"] is False # Default preserved 885 - assert result["sources"]["agents"] is True # Overridden 886 - 887 - 888 656 class TestPortDiscovery: 889 657 """Tests for service port discovery utilities.""" 890 658 ··· 963 731 # Now it should exist 964 732 assert health_dir.exists() 965 733 assert (health_dir / "new_service.port").read_text() == "9999" 966 - 967 - 968 - # ============================================================================= 969 - # source_is_enabled / source_is_required / get_agent_filter tests 970 - # ============================================================================= 971 - 972 - 973 - def test_source_is_enabled_bool(): 974 - """Test source_is_enabled with bool values.""" 975 - from think.utils import source_is_enabled 976 - 977 - assert source_is_enabled(True) is True 978 - assert source_is_enabled(False) is False 979 - 980 - 981 - def test_source_is_enabled_required_string(): 982 - """Test source_is_enabled with 'required' string.""" 983 - from think.utils import source_is_enabled 984 - 985 - assert source_is_enabled("required") is True 986 - 987 - 988 - def test_source_is_enabled_dict(): 989 - """Test source_is_enabled with dict values for agents source.""" 990 - from think.utils import source_is_enabled 991 - 992 - # Dict with at least one True value -> enabled 993 - assert source_is_enabled({"entities": True, "meetings": False}) is True 994 - 995 - # Dict with at least one "required" value -> enabled 996 - assert source_is_enabled({"entities": "required", "meetings": False}) is True 997 - 998 - # Dict with all False values -> disabled 999 - assert source_is_enabled({"entities": False, "meetings": False}) is False 1000 - 1001 - # Empty dict -> disabled 1002 - assert source_is_enabled({}) is False 1003 - 1004 - 1005 - def test_source_is_required_bool(): 1006 - """Test source_is_required with bool values.""" 1007 - from think.utils import source_is_required 1008 - 1009 - assert source_is_required(True) is False 1010 - assert source_is_required(False) is False 1011 - 1012 - 1013 - def test_source_is_required_string(): 1014 - """Test source_is_required with 'required' string.""" 1015 - from think.utils import source_is_required 1016 - 1017 - assert source_is_required("required") is True 1018 - 1019 - 1020 - def test_source_is_required_dict(): 1021 - """Test source_is_required with dict values.""" 1022 - from think.utils import source_is_required 1023 - 1024 - # Dict with at least one "required" value -> required 1025 - assert source_is_required({"entities": "required", "meetings": False}) is True 1026 - 1027 - # Dict with no "required" values -> not required 1028 - assert source_is_required({"entities": True, "meetings": False}) is False 1029 - 1030 - # Empty dict -> not required 1031 - assert source_is_required({}) is False 1032 - 1033 - 1034 - def test_get_agent_filter_bool(): 1035 - """Test get_agent_filter with bool values.""" 1036 - from think.utils import get_agent_filter 1037 - 1038 - # True -> None (all agents) 1039 - assert get_agent_filter(True) is None 1040 - 1041 - # False -> empty dict (no agents) 1042 - assert get_agent_filter(False) == {} 1043 - 1044 - 1045 - def test_get_agent_filter_required_string(): 1046 - """Test get_agent_filter with 'required' string.""" 1047 - from think.utils import get_agent_filter 1048 - 1049 - # "required" -> None (all agents, required) 1050 - assert get_agent_filter("required") is None 1051 - 1052 - 1053 - def test_get_agent_filter_dict(): 1054 - """Test get_agent_filter with dict values.""" 1055 - from think.utils import get_agent_filter 1056 - 1057 - # Dict -> returned as-is for filtering 1058 - filter_dict = {"entities": True, "meetings": "required", "flow": False} 1059 - assert get_agent_filter(filter_dict) == filter_dict 1060 - 1061 - # Empty dict -> empty dict (no agents) 1062 - assert get_agent_filter({}) == {}
+13 -95
think/agents.py
··· 29 29 30 30 from think.cluster import cluster, cluster_period, cluster_span 31 31 from think.providers.shared import Event, GenerateResult 32 - from think.utils import ( 32 + from think.muse import ( 33 33 compose_instructions, 34 - day_log, 35 - day_path, 36 - format_day, 37 - format_segment_times, 38 34 get_agent_filter, 39 35 get_muse_configs, 40 36 get_output_path, 37 + load_post_hook, 38 + load_pre_hook, 41 39 load_prompt, 40 + source_is_enabled, 41 + source_is_required, 42 + ) 43 + from think.utils import ( 44 + day_log, 45 + day_path, 46 + format_day, 47 + format_segment_times, 42 48 now_ms, 43 49 segment_parse, 44 50 setup_cli, 45 - source_is_enabled, 46 - source_is_required, 47 51 ) 48 52 49 53 LOG = logging.getLogger("think.agents") ··· 248 252 meta: dict # Full frontmatter/config 249 253 250 254 251 - # MUSE_DIR for hook resolution 252 - _MUSE_DIR = Path(__file__).parent.parent / "muse" 253 - 254 - 255 - def _resolve_hook_path(hook_name: str) -> Path: 256 - """Resolve hook name to file path. 257 - 258 - Resolution: 259 - - Named: "name" -> muse/{name}.py 260 - - App-qualified: "app:name" -> apps/{app}/muse/{name}.py 261 - - Explicit path: "path/to/hook.py" -> direct path 262 - """ 263 - if "/" in hook_name or hook_name.endswith(".py"): 264 - return Path(hook_name) 265 - elif ":" in hook_name: 266 - app, name = hook_name.split(":", 1) 267 - return Path(__file__).parent.parent / "apps" / app / "muse" / f"{name}.py" 268 - else: 269 - return _MUSE_DIR / f"{hook_name}.py" 270 - 271 - 272 - def _load_hook_function(config: dict, key: str, func_name: str) -> Callable | None: 273 - """Load a hook function from config. 274 - 275 - Args: 276 - config: Agent/generator config dict 277 - key: Hook key in config ("pre" or "post") 278 - func_name: Function name to load ("pre_process" or "post_process") 279 - 280 - Returns: 281 - The hook function, or None if no hook configured. 282 - 283 - Raises: 284 - ValueError: If hook file doesn't define the required function. 285 - ImportError: If hook file cannot be loaded. 286 - """ 287 - import importlib.util 288 - 289 - hook_config = config.get("hook") 290 - if not hook_config or not isinstance(hook_config, dict): 291 - return None 292 - 293 - hook_name = hook_config.get(key) 294 - if not hook_name: 295 - return None 296 - 297 - hook_path = _resolve_hook_path(hook_name) 298 - 299 - if not hook_path.exists(): 300 - raise ImportError(f"Hook file not found: {hook_path}") 301 - 302 - spec = importlib.util.spec_from_file_location( 303 - f"{key}_hook_{hook_path.stem}", hook_path 304 - ) 305 - if spec is None or spec.loader is None: 306 - raise ImportError(f"Cannot load hook from {hook_path}") 307 - 308 - module = importlib.util.module_from_spec(spec) 309 - spec.loader.exec_module(module) 310 - 311 - if not hasattr(module, func_name): 312 - raise ValueError(f"Hook {hook_path} must define a '{func_name}' function") 313 - 314 - process_func = getattr(module, func_name) 315 - if not callable(process_func): 316 - raise ValueError(f"Hook {hook_path} '{func_name}' must be callable") 317 - 318 - return process_func 319 - 320 - 321 - def load_post_hook(config: dict) -> Callable[[str, HookContext], str | None] | None: 322 - """Load post-processing hook from config if defined. 323 - 324 - Hook config format: {"hook": {"post": "name"}} 325 - """ 326 - return _load_hook_function(config, "post", "post_process") 327 - 328 - 329 - def load_pre_hook(config: dict) -> Callable[[PreHookContext], dict | None] | None: 330 - """Load pre-processing hook from config if defined. 331 - 332 - Hook config format: {"hook": {"pre": "name"}} 333 - """ 334 - return _load_hook_function(config, "pre", "pre_process") 335 - 336 - 337 255 def _build_base_context(config: dict) -> dict: 338 256 """Build common context fields shared by pre and post hooks.""" 339 257 context = { ··· 474 392 Fully hydrated config dict ready for routing 475 393 """ 476 394 from think.models import resolve_model_for_provider, resolve_provider 477 - from think.utils import get_agent, key_to_context 395 + from think.muse import get_agent, key_to_context 478 396 479 397 name = request.get("name", "default") 480 398 facet = request.get("facet") ··· 1235 1153 max_output_tokens = 8192 * 6 1236 1154 1237 1155 # Build context for provider routing and token logging 1238 - from think.utils import key_to_context 1156 + from think.muse import key_to_context 1239 1157 1240 1158 context = key_to_context(name) if name else "muse.system.unknown" 1241 1159
+3 -2
think/cortex.py
··· 483 483 if usage_data and original_request: 484 484 try: 485 485 from think.models import log_token_usage 486 - from think.utils import key_to_context 486 + from think.muse import key_to_context 487 487 488 488 model = original_request.get("model", "unknown") 489 489 name = original_request.get("name", "unknown") ··· 659 659 - Multi-facet: {name}_{facet}.{ext} instead of {name}.{ext} 660 660 """ 661 661 try: 662 - from think.utils import day_path, get_output_path 662 + from think.muse import get_output_path 663 + from think.utils import day_path 663 664 664 665 # Check for explicit output_path override first 665 666 if config.get("output_path"):
+1 -1
think/detect_created.py
··· 13 13 from pathlib import Path 14 14 from typing import Optional 15 15 16 - from .utils import load_prompt 16 + from .muse 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 .utils import load_prompt 13 + from .muse import load_prompt 14 14 15 15 16 16 def _load_json_prompt() -> str:
+7 -4
think/dream.py
··· 20 20 from think.cortex_client import cortex_request, get_agent_end_state, wait_for_agents 21 21 from think.facets import get_active_facets, get_enabled_facets, get_facets 22 22 from think.runner import run_task 23 + from think.muse import get_muse_configs, get_output_path 23 24 from think.utils import ( 24 25 day_input_summary, 25 26 day_log, 26 27 day_path, 27 28 get_journal, 28 - get_muse_configs, 29 - get_output_path, 30 29 iso_date, 31 30 setup_cli, 32 31 ) ··· 319 318 320 319 if spawned: 321 320 agent_ids = [agent_id for agent_id, _, _ in spawned] 322 - logging.info(f"Waiting for {len(agent_ids)} prompts in priority {priority}...") 321 + logging.info( 322 + f"Waiting for {len(agent_ids)} prompts in priority {priority}..." 323 + ) 323 324 324 325 completed, timed_out = wait_for_agents(agent_ids, timeout=600) 325 326 ··· 675 676 day_log(day, msg) 676 677 677 678 duration_ms = int((time.time() - start_time) * 1000) 678 - logging.info(f"Dream completed in {duration_ms}ms: {success_count} succeeded, {fail_count} failed") 679 + logging.info( 680 + f"Dream completed in {duration_ms}ms: {success_count} succeeded, {fail_count} failed" 681 + ) 679 682 680 683 if fail_count > 0: 681 684 logging.error(f"{fail_count} prompt(s) failed, exiting with error")
+2 -1
think/hooks.py
··· 131 131 Returns: 132 132 Relative path like "20240101/agents/meetings.md". 133 133 """ 134 - from think.utils import get_journal, get_output_topic 134 + from think.muse import get_output_topic 135 + from think.utils import get_journal 135 136 136 137 day = context.get("day", "") 137 138 output_path = context.get("output_path", "")
+14 -49
think/models.py
··· 188 188 def _discover_muse_contexts() -> Dict[str, Dict[str, Any]]: 189 189 """Discover muse context defaults from muse/*.md config files. 190 190 191 - Scans system muse configs (muse/*.md) and app muse configs (apps/*/muse/*.md) 192 - for tier/label/group metadata. Includes both tool-using agents and generators. 191 + Uses get_muse_configs() from think.muse to load all muse configurations 192 + and converts them to context patterns with tier/label/group metadata. 193 193 194 194 Returns 195 195 ------- ··· 197 197 Mapping of context patterns to {tier, label, group, has_tools} dicts. 198 198 Context patterns are: muse.system.{name} or muse.{app}.{name} 199 199 """ 200 + from think.muse import get_muse_configs, key_to_context 201 + 200 202 contexts = {} 201 203 202 - # System muse configs from muse/ 203 - muse_dir = Path(__file__).parent.parent / "muse" 204 - if muse_dir.exists(): 205 - for md_path in muse_dir.glob("*.md"): 206 - config_name = md_path.stem 207 - try: 208 - post = frontmatter.load( 209 - md_path, 210 - ) 211 - config = post.metadata if post.metadata else {} 204 + # Load all muse configs (including disabled for completeness) 205 + all_configs = get_muse_configs(include_disabled=True) 212 206 213 - context = f"muse.system.{config_name}" 214 - contexts[context] = { 215 - "tier": config.get("tier", TIER_FLASH), 216 - "label": config.get("label", config.get("title", config_name)), 217 - "group": config.get("group", "Think"), 218 - "has_tools": "tools" in config, 219 - } 220 - except Exception: 221 - pass # Skip configs that can't be loaded 222 - 223 - # App muse configs from apps/*/muse/ 224 - apps_dir = Path(__file__).parent.parent / "apps" 225 - if apps_dir.is_dir(): 226 - for app_path in apps_dir.iterdir(): 227 - if not app_path.is_dir() or app_path.name.startswith("_"): 228 - continue 229 - muse_subdir = app_path / "muse" 230 - if not muse_subdir.is_dir(): 231 - continue 232 - app_name = app_path.name 233 - for md_path in muse_subdir.glob("*.md"): 234 - config_name = md_path.stem 235 - try: 236 - post = frontmatter.load( 237 - md_path, 238 - ) 239 - config = post.metadata if post.metadata else {} 240 - 241 - context = f"muse.{app_name}.{config_name}" 242 - contexts[context] = { 243 - "tier": config.get("tier", TIER_FLASH), 244 - "label": config.get("label", config.get("title", config_name)), 245 - "group": config.get("group", "Think"), 246 - "has_tools": "tools" in config, 247 - } 248 - except Exception: 249 - pass # Skip configs that can't be loaded 207 + for key, config in all_configs.items(): 208 + context = key_to_context(key) 209 + contexts[context] = { 210 + "tier": config.get("tier", TIER_FLASH), 211 + "label": config.get("label", config.get("title", key)), 212 + "group": config.get("group", "Think"), 213 + "has_tools": "tools" in config, 214 + } 250 215 251 216 return contexts 252 217
+984
think/muse.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Muse prompt loading and configuration utilities. 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. 8 + 9 + Key functions: 10 + - load_prompt(): Load and parse .md prompt files with template substitution 11 + - get_muse_configs(): Discover all muse configs with filtering 12 + - get_agent(): Load complete agent configuration by name 13 + - compose_instructions(): Build system/user prompts from instruction config 14 + - Hook loading: load_pre_hook(), load_post_hook() 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import importlib.util 20 + import logging 21 + import os 22 + from pathlib import Path 23 + from string import Template 24 + from typing import Any, Callable, NamedTuple 25 + 26 + import frontmatter 27 + 28 + # --------------------------------------------------------------------------- 29 + # Constants 30 + # --------------------------------------------------------------------------- 31 + 32 + 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 + 311 + 312 + # --------------------------------------------------------------------------- 313 + # Muse Config Discovery 314 + # --------------------------------------------------------------------------- 315 + 316 + 317 + def key_to_context(key: str) -> str: 318 + """Convert muse config key to context pattern. 319 + 320 + Parameters 321 + ---------- 322 + key: 323 + Muse config key in format "name" (system) or "app:name" (app). 324 + 325 + Returns 326 + ------- 327 + str 328 + Context pattern: "muse.system.{name}" or "muse.{app}.{name}". 329 + 330 + Examples 331 + -------- 332 + >>> key_to_context("meetings") 333 + 'muse.system.meetings' 334 + >>> key_to_context("entities:observer") 335 + 'muse.entities.observer' 336 + """ 337 + if ":" in key: 338 + app, name = key.split(":", 1) 339 + return f"muse.{app}.{name}" 340 + return f"muse.system.{key}" 341 + 342 + 343 + def get_output_topic(key: str) -> str: 344 + """Convert agent/generator key to filesystem-safe basename (no extension). 345 + 346 + Parameters 347 + ---------- 348 + key: 349 + Generator key in format "topic" (system) or "app:topic" (app). 350 + 351 + Returns 352 + ------- 353 + str 354 + Filesystem-safe name: "topic" or "_app_topic". 355 + 356 + Examples 357 + -------- 358 + >>> get_output_topic("activity") 359 + 'activity' 360 + >>> get_output_topic("chat:sentiment") 361 + '_chat_sentiment' 362 + """ 363 + if ":" in key: 364 + app, topic = key.split(":", 1) 365 + return f"_{app}_{topic}" 366 + return key 367 + 368 + 369 + def get_output_path( 370 + day_dir: "os.PathLike[str]", 371 + key: str, 372 + segment: str | None = None, 373 + output_format: str | None = None, 374 + facet: str | None = None, 375 + ) -> Path: 376 + """Return output path for generator agent output. 377 + 378 + Shared utility for determining where to write generator results. 379 + Used by think/agents.py and think/cortex.py. 380 + 381 + Parameters 382 + ---------- 383 + day_dir: 384 + Day directory path (YYYYMMDD). 385 + key: 386 + Generator key or agent name (e.g., "activity", "chat:sentiment", 387 + "decisionalizer", "entities:observer"). 388 + segment: 389 + Optional segment key (HHMMSS_LEN) for segment-level output. 390 + output_format: 391 + Output format - "json" for JSON, anything else for markdown. 392 + facet: 393 + Optional facet name for multi-facet agents. When provided, the facet 394 + is appended to the filename (e.g., "newsletter_work.md"). 395 + 396 + Returns 397 + ------- 398 + Path 399 + Output file path: 400 + - With segment: YYYYMMDD/{segment}/{topic}.{ext} 401 + - Without segment: YYYYMMDD/agents/{topic}.{ext} 402 + - With facet: {topic}_{facet}.{ext} instead of {topic}.{ext} 403 + Where topic is derived from key and ext is "json" or "md". 404 + """ 405 + day = Path(day_dir) 406 + topic = get_output_topic(key) 407 + ext = "json" if output_format == "json" else "md" 408 + 409 + # Append facet suffix for multi-facet agent outputs 410 + if facet: 411 + filename = f"{topic}_{facet}.{ext}" 412 + else: 413 + filename = f"{topic}.{ext}" 414 + 415 + if segment: 416 + # Segment output goes directly in segment directory 417 + return day / segment / filename 418 + else: 419 + # Daily output goes in agents/ subdirectory 420 + return day / "agents" / filename 421 + 422 + 423 + def get_muse_configs( 424 + *, 425 + has_tools: bool | None = None, 426 + has_output: bool | None = None, 427 + schedule: str | None = None, 428 + include_disabled: bool = False, 429 + ) -> dict[str, dict[str, Any]]: 430 + """Load muse configs from system and app directories. 431 + 432 + Unified function for loading both tool-using agents and generators from 433 + muse/*.md files. Filters based on presence of tools/output fields. 434 + 435 + Args: 436 + has_tools: If True, only configs with "tools" field (agents). 437 + If False, only configs without "tools" field. 438 + If None, no filtering on tools presence. 439 + has_output: If True, only configs with "output" field (generators). 440 + If False, only configs without "output" field. 441 + If None, no filtering on output presence. 442 + schedule: If provided, only configs where schedule matches this value 443 + (e.g., "segment", "daily"). 444 + include_disabled: If True, include configs with disabled=True. 445 + Default False (for processing pipelines). 446 + 447 + Returns: 448 + Dictionary mapping config keys to their metadata including: 449 + - path: Path to the .md file 450 + - source: "system" or "app" 451 + - app: App name (only for app configs) 452 + - All fields from frontmatter 453 + """ 454 + from think.utils import get_config 455 + 456 + configs: dict[str, dict[str, Any]] = {} 457 + 458 + def matches_filter(info: dict) -> bool: 459 + """Check if config matches the filter criteria.""" 460 + # Check has_tools filter 461 + if has_tools is True and "tools" not in info: 462 + return False 463 + if has_tools is False and "tools" in info: 464 + return False 465 + 466 + # Check has_output filter 467 + if has_output is True and "output" not in info: 468 + return False 469 + if has_output is False and "output" in info: 470 + return False 471 + 472 + # Check specific schedule value 473 + if schedule is not None and info.get("schedule") != schedule: 474 + return False 475 + 476 + # Check disabled status 477 + if not include_disabled and info.get("disabled", False): 478 + return False 479 + 480 + return True 481 + 482 + # System configs from muse/ 483 + if MUSE_DIR.is_dir(): 484 + for md_path in sorted(MUSE_DIR.glob("*.md")): 485 + name = md_path.stem 486 + info = _load_prompt_metadata(md_path) 487 + 488 + if not matches_filter(info): 489 + continue 490 + 491 + info["source"] = "system" 492 + configs[name] = info 493 + 494 + # App configs from apps/*/muse/ 495 + apps_dir = Path(__file__).parent.parent / "apps" 496 + if apps_dir.is_dir(): 497 + for app_path in sorted(apps_dir.iterdir()): 498 + if not app_path.is_dir() or app_path.name.startswith("_"): 499 + continue 500 + app_muse_dir = app_path / "muse" 501 + if not app_muse_dir.is_dir(): 502 + continue 503 + app_name = app_path.name 504 + for md_path in sorted(app_muse_dir.glob("*.md")): 505 + item_name = md_path.stem 506 + info = _load_prompt_metadata(md_path) 507 + 508 + if not matches_filter(info): 509 + continue 510 + 511 + key = f"{app_name}:{item_name}" 512 + info["source"] = "app" 513 + info["app"] = app_name 514 + configs[key] = info 515 + 516 + # Merge journal config overrides from providers.contexts 517 + providers_config = get_config().get("providers", {}) 518 + contexts = providers_config.get("contexts", {}) 519 + 520 + for key, info in configs.items(): 521 + context_key = key_to_context(key) 522 + 523 + # Check for exact match in contexts 524 + override = contexts.get(context_key) 525 + if override and isinstance(override, dict): 526 + # Merge supported override fields 527 + if "disabled" in override: 528 + info["disabled"] = override["disabled"] 529 + if "extract" in override: 530 + info["extract"] = override["extract"] 531 + if "tier" in override: 532 + info["tier"] = override["tier"] 533 + if "provider" in override: 534 + info["provider"] = override["provider"] 535 + 536 + # Validate: scheduled prompts must have explicit priority 537 + for key, info in configs.items(): 538 + if info.get("schedule") and "priority" not in info: 539 + raise ValueError( 540 + f"Scheduled prompt '{key}' is missing required 'priority' field. " 541 + f"All prompts with 'schedule' must declare an explicit priority." 542 + ) 543 + 544 + return configs 545 + 546 + 547 + # --------------------------------------------------------------------------- 548 + # Agent Resolution 549 + # --------------------------------------------------------------------------- 550 + 551 + 552 + def _resolve_agent_path(name: str) -> tuple[Path, str]: 553 + """Resolve agent name to directory path and agent filename. 554 + 555 + Parameters 556 + ---------- 557 + name: 558 + Agent name - either system agent (e.g., "default") or 559 + app-namespaced agent (e.g., "chat:helper"). 560 + 561 + Returns 562 + ------- 563 + tuple[Path, str] 564 + (agent_directory, agent_name) tuple. 565 + """ 566 + if ":" in name: 567 + # App agent: "chat:helper" -> apps/chat/muse/helper 568 + app, agent_name = name.split(":", 1) 569 + agent_dir = Path(__file__).parent.parent / "apps" / app / "muse" 570 + else: 571 + # System agent: "default" -> muse/default 572 + agent_dir = MUSE_DIR 573 + agent_name = name 574 + return agent_dir, agent_name 575 + 576 + 577 + # --------------------------------------------------------------------------- 578 + # Instructions Composition 579 + # --------------------------------------------------------------------------- 580 + 581 + # Default instruction configuration - all false, agents must explicitly opt-in 582 + _DEFAULT_INSTRUCTIONS = { 583 + "system": None, 584 + "facets": False, 585 + "sources": { 586 + "audio": False, 587 + "screen": False, 588 + "agents": False, 589 + }, 590 + } 591 + 592 + 593 + def _merge_instructions_config(defaults: dict, overrides: dict | None) -> dict: 594 + """Merge instruction config overrides into defaults. 595 + 596 + Handles nested "sources" dict specially. 597 + 598 + Parameters 599 + ---------- 600 + defaults: 601 + Default instruction configuration. 602 + overrides: 603 + Optional overrides from .json "instructions" key. 604 + 605 + Returns 606 + ------- 607 + dict 608 + Merged configuration. 609 + """ 610 + if not overrides: 611 + return defaults.copy() 612 + 613 + result = defaults.copy() 614 + 615 + # Merge top-level keys 616 + for key in ("system", "facets"): 617 + if key in overrides: 618 + result[key] = overrides[key] 619 + 620 + # Merge sources dict if present 621 + if "sources" in overrides and isinstance(overrides["sources"], dict): 622 + result["sources"] = {**defaults.get("sources", {}), **overrides["sources"]} 623 + 624 + return result 625 + 626 + 627 + def compose_instructions( 628 + *, 629 + user_prompt: str | None = None, 630 + user_prompt_dir: Path | None = None, 631 + facet: str | None = None, 632 + include_datetime: bool = True, 633 + config_overrides: dict | None = None, 634 + ) -> dict: 635 + """Compose instruction components for agents or generators. 636 + 637 + This is the shared function for building system_instruction, user_instruction, 638 + extra_context, and sources configuration. Both agents and generators use this 639 + to ensure consistent prompt composition. 640 + 641 + Parameters 642 + ---------- 643 + user_prompt: 644 + Name of the user instruction prompt to load (e.g., "default" for agents). 645 + If None, no user_instruction is included (typical for generators). 646 + user_prompt_dir: 647 + Directory to load user_prompt from. If None, uses think/ directory. 648 + facet: 649 + Optional facet name to focus on. When provided, extra_context includes 650 + only this facet's info (detail level controlled by "facets" setting). 651 + include_datetime: 652 + Whether to include current date/time in extra_context. Default True 653 + for agents (real-time chat), typically False for generators (past analysis). 654 + config_overrides: 655 + Optional dict from .json "instructions" key. Supported keys: 656 + - "system": prompt name for system instruction (default: "journal") 657 + - "facets": false | true | "full" (default: true) 658 + false = skip facet context 659 + true = include facet context with names only 660 + "full" = include facet context with full descriptions 661 + For faceted generators, shows focused facet; for unfaceted, shows all facets. 662 + - "sources": {"audio": bool, "screen": bool, "agents": bool|dict} 663 + The "agents" source can be: 664 + - bool: True (all agents), False (no agents) 665 + - "required": all agents, fail if none found 666 + - dict: selective filtering, e.g., {"entities": true, "meetings": "required"} 667 + 668 + Returns 669 + ------- 670 + dict 671 + Composed instruction configuration: 672 + - system_instruction: str - loaded from "system" prompt 673 + - system_prompt_name: str - name of system prompt (for cache keys) 674 + - user_instruction: str | None - loaded from user_prompt if provided 675 + - extra_context: str | None - facets + datetime 676 + - sources: dict - {"audio": bool, "screen": bool, "agents": bool|dict} 677 + """ 678 + from datetime import datetime 679 + 680 + # Merge defaults with overrides 681 + cfg = _merge_instructions_config(_DEFAULT_INSTRUCTIONS, config_overrides) 682 + 683 + result: dict = {} 684 + 685 + # Load system instruction (None means no system prompt) 686 + system_name = cfg.get("system") 687 + if system_name: 688 + system_prompt = load_prompt(system_name) 689 + result["system_instruction"] = system_prompt.text 690 + result["system_prompt_name"] = system_name 691 + else: 692 + result["system_instruction"] = "" 693 + result["system_prompt_name"] = "" 694 + 695 + # Load user instruction if specified 696 + if user_prompt: 697 + base_dir = user_prompt_dir if user_prompt_dir else Path(__file__).parent 698 + user_prompt_obj = load_prompt(user_prompt, base_dir=base_dir) 699 + result["user_instruction"] = user_prompt_obj.text 700 + else: 701 + result["user_instruction"] = None 702 + 703 + # Build extra_context based on facets setting 704 + # Values: false (skip), true (names only), "full" (with descriptions) 705 + extra_parts = [] 706 + facets_setting = cfg.get("facets", False) 707 + facets_full = facets_setting == "full" 708 + 709 + if facets_setting: 710 + if facet: 711 + # Focused facet mode: include only this facet's context 712 + try: 713 + from think.facets import facet_summary 714 + 715 + summary = facet_summary(facet, detailed=facets_full) 716 + extra_parts.append(f"## Facet Focus\n{summary}") 717 + except Exception: 718 + pass # Ignore if facet can't be loaded 719 + else: 720 + # General mode: all facets 721 + try: 722 + from think.facets import facet_summaries 723 + 724 + summary = facet_summaries(detailed=facets_full) 725 + if summary and summary != "No facets found.": 726 + extra_parts.append(summary) 727 + except Exception: 728 + pass # Ignore if facets can't be loaded 729 + 730 + # Add current date/time if requested 731 + if include_datetime: 732 + now = datetime.now() 733 + try: 734 + import tzlocal 735 + 736 + local_tz = tzlocal.get_localzone() 737 + now_local = now.astimezone(local_tz) 738 + time_str = now_local.strftime("%A, %B %d, %Y at %I:%M %p %Z") 739 + except Exception: 740 + time_str = now.strftime("%A, %B %d, %Y at %I:%M %p") 741 + extra_parts.append(f"## Current Date and Time\nToday is {time_str}") 742 + 743 + result["extra_context"] = "\n\n".join(extra_parts).strip() if extra_parts else None 744 + 745 + # Include sources config 746 + result["sources"] = cfg.get("sources", _DEFAULT_INSTRUCTIONS["sources"]) 747 + 748 + return result 749 + 750 + 751 + # --------------------------------------------------------------------------- 752 + # Source Configuration Helpers 753 + # --------------------------------------------------------------------------- 754 + 755 + 756 + def source_is_enabled(value: bool | str | dict) -> bool: 757 + """Check if a source should be loaded based on its config value. 758 + 759 + Sources can be configured as: 760 + - False: don't load 761 + - True: load if available 762 + - "required": load (and generation will fail if none found) 763 + - dict: for agents source, selective loading (e.g., {"entities": true}) 764 + 765 + Both True and "required" mean the source should be loaded. 766 + A non-empty dict means the source should be loaded (with filtering). 767 + 768 + Args: 769 + value: The source config value (bool, "required" string, or dict for agents) 770 + 771 + Returns: 772 + True if the source should be loaded, False otherwise. 773 + """ 774 + if isinstance(value, dict): 775 + # Dict means selective loading - enabled if any agent is enabled 776 + return any(v is True or v == "required" for v in value.values()) 777 + return value is True or value == "required" 778 + 779 + 780 + def source_is_required(value: bool | str | dict) -> bool: 781 + """Check if a source must have content for generation to proceed. 782 + 783 + Args: 784 + value: The source config value (bool, "required" string, or dict for agents) 785 + 786 + Returns: 787 + True if the source is required (generation should skip if no content). 788 + For dict values, returns True if any agent is marked "required". 789 + """ 790 + if isinstance(value, dict): 791 + return any(v == "required" for v in value.values()) 792 + return value == "required" 793 + 794 + 795 + def get_agent_filter(value: bool | str | dict) -> dict[str, bool | str] | None: 796 + """Extract agent filter from sources config. 797 + 798 + When agents source is a dict, returns it as filter mapping agent names 799 + to their enabled/required status. When agents source is bool or "required", 800 + returns None to indicate all agents should be loaded. 801 + 802 + Args: 803 + value: The agents source config value 804 + 805 + Returns: 806 + Dict mapping agent names to bool/"required", or None for all agents. 807 + Returns empty dict if value is False (no agents). 808 + 809 + Examples: 810 + >>> get_agent_filter(True) 811 + None # All agents 812 + >>> get_agent_filter(False) 813 + {} # No agents 814 + >>> get_agent_filter({"entities": True, "meetings": "required"}) 815 + {"entities": True, "meetings": "required"} 816 + """ 817 + if isinstance(value, dict): 818 + return value 819 + if value is False: 820 + return {} # No agents 821 + return None # All agents (True or "required") 822 + 823 + 824 + # --------------------------------------------------------------------------- 825 + # Agent Loading 826 + # --------------------------------------------------------------------------- 827 + 828 + 829 + def get_agent(name: str = "default", facet: str | None = None) -> dict: 830 + """Return complete agent configuration by name. 831 + 832 + Loads configuration from .md file with JSON frontmatter and instruction text, 833 + merges with runtime context. 834 + 835 + Parameters 836 + ---------- 837 + name: 838 + Agent name to load. Can be a system agent (e.g., "default") 839 + or an app-namespaced agent (e.g., "chat:helper" for apps/chat/muse/helper). 840 + facet: 841 + Optional facet name to focus on. When provided, includes detailed 842 + information for just this facet (with full entity details) instead 843 + of summaries of all facets. 844 + 845 + Returns 846 + ------- 847 + dict 848 + Complete agent configuration including system_instruction, user_instruction, 849 + extra_context, model, backend, etc. 850 + """ 851 + # Resolve agent path based on namespace 852 + agent_dir, agent_name = _resolve_agent_path(name) 853 + 854 + # Verify agent prompt file exists 855 + md_path = agent_dir / f"{agent_name}.md" 856 + if not md_path.exists(): 857 + raise FileNotFoundError(f"Agent not found: {name}") 858 + 859 + # Load config from frontmatter 860 + post = frontmatter.load( 861 + md_path, 862 + ) 863 + config = dict(post.metadata) if post.metadata else {} 864 + 865 + # Extract instructions config if present 866 + instructions_config = config.pop("instructions", None) 867 + 868 + # Use compose_instructions for consistent prompt composition 869 + instructions = compose_instructions( 870 + user_prompt=agent_name, 871 + user_prompt_dir=agent_dir, 872 + facet=facet, 873 + include_datetime=True, 874 + config_overrides=instructions_config, 875 + ) 876 + 877 + # Merge instruction results into config 878 + config["system_instruction"] = instructions["system_instruction"] 879 + config["user_instruction"] = instructions["user_instruction"] 880 + if instructions["extra_context"]: 881 + config["extra_context"] = instructions["extra_context"] 882 + 883 + # Set agent name 884 + config["name"] = name 885 + 886 + return config 887 + 888 + 889 + # --------------------------------------------------------------------------- 890 + # Hook Loading 891 + # --------------------------------------------------------------------------- 892 + 893 + 894 + def _resolve_hook_path(hook_name: str) -> Path: 895 + """Resolve hook name to file path. 896 + 897 + Resolution: 898 + - Named: "name" -> muse/{name}.py 899 + - App-qualified: "app:name" -> apps/{app}/muse/{name}.py 900 + - Explicit path: "path/to/hook.py" -> direct path 901 + """ 902 + if "/" in hook_name or hook_name.endswith(".py"): 903 + return Path(hook_name) 904 + elif ":" in hook_name: 905 + app, name = hook_name.split(":", 1) 906 + return Path(__file__).parent.parent / "apps" / app / "muse" / f"{name}.py" 907 + else: 908 + return MUSE_DIR / f"{hook_name}.py" 909 + 910 + 911 + def _load_hook_function(config: dict, key: str, func_name: str) -> Callable | None: 912 + """Load a hook function from config. 913 + 914 + Args: 915 + config: Agent/generator config dict 916 + key: Hook key in config ("pre" or "post") 917 + func_name: Function name to load ("pre_process" or "post_process") 918 + 919 + Returns: 920 + The hook function, or None if no hook configured. 921 + 922 + Raises: 923 + ValueError: If hook file doesn't define the required function. 924 + ImportError: If hook file cannot be loaded. 925 + """ 926 + hook_config = config.get("hook") 927 + if not hook_config or not isinstance(hook_config, dict): 928 + return None 929 + 930 + hook_name = hook_config.get(key) 931 + if not hook_name: 932 + return None 933 + 934 + hook_path = _resolve_hook_path(hook_name) 935 + 936 + if not hook_path.exists(): 937 + raise ImportError(f"Hook file not found: {hook_path}") 938 + 939 + spec = importlib.util.spec_from_file_location( 940 + f"{key}_hook_{hook_path.stem}", hook_path 941 + ) 942 + if spec is None or spec.loader is None: 943 + raise ImportError(f"Cannot load hook from {hook_path}") 944 + 945 + module = importlib.util.module_from_spec(spec) 946 + spec.loader.exec_module(module) 947 + 948 + if not hasattr(module, func_name): 949 + raise ValueError(f"Hook {hook_path} must define a '{func_name}' function") 950 + 951 + process_func = getattr(module, func_name) 952 + if not callable(process_func): 953 + raise ValueError(f"Hook {hook_path} '{func_name}' must be callable") 954 + 955 + return process_func 956 + 957 + 958 + def load_post_hook(config: dict) -> Callable[[str, "HookContext"], str | None] | None: 959 + """Load post-processing hook from config if defined. 960 + 961 + Hook config format: {"hook": {"post": "name"}} 962 + 963 + Returns: 964 + Post-processing function or None if no hook configured. 965 + Function signature: (result: str, context: HookContext) -> str | None 966 + """ 967 + return _load_hook_function(config, "post", "post_process") 968 + 969 + 970 + def load_pre_hook(config: dict) -> Callable[["PreHookContext"], dict | None] | None: 971 + """Load pre-processing hook from config if defined. 972 + 973 + Hook config format: {"hook": {"pre": "name"}} 974 + 975 + Returns: 976 + Pre-processing function or None if no hook configured. 977 + Function signature: (context: PreHookContext) -> dict | None 978 + """ 979 + return _load_hook_function(config, "pre", "pre_process") 980 + 981 + 982 + # Type aliases for hook context (actual TypedDicts defined in agents.py) 983 + HookContext = dict 984 + PreHookContext = dict
+3 -3
think/muse_cli.py
··· 28 28 29 29 import frontmatter 30 30 31 - from think.utils import ( 31 + from think.muse import ( 32 32 MUSE_DIR, 33 33 _load_prompt_metadata, 34 34 get_muse_configs, 35 35 get_output_topic, 36 - setup_cli, 37 36 ) 37 + from think.utils import setup_cli 38 38 39 39 # Project root for computing relative paths 40 40 _PROJECT_ROOT = Path(__file__).parent.parent ··· 460 460 config["facet"] = facet 461 461 else: 462 462 # Tool agent - use get_agent() to build full config with instructions 463 - from think.utils import get_agent 463 + from think.muse import get_agent 464 464 465 465 try: 466 466 agent_config = get_agent(name, facet=facet)
+2 -1
think/planner.py
··· 8 8 import sys 9 9 from pathlib import Path 10 10 11 - from .utils import load_prompt, setup_cli 11 + from .muse import load_prompt 12 + from .utils import setup_cli 12 13 13 14 14 15 async def _get_mcp_tools() -> str:
+8 -816
think/utils.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + """General utilities for solstone. 5 + 6 + This module provides core utilities for journal access, date/segment handling, 7 + configuration loading, and CLI setup. Muse-related utilities (prompt loading, 8 + agent configs, etc.) have been moved to think/muse.py. 9 + """ 10 + 4 11 from __future__ import annotations 5 12 6 13 import argparse ··· 11 18 import time 12 19 from datetime import datetime 13 20 from pathlib import Path 14 - from string import Template 15 - from typing import Any, NamedTuple, Optional 21 + from typing import Any, Optional 16 22 17 - import frontmatter 18 23 import platformdirs 19 24 from dotenv import load_dotenv 20 25 from timefhuman import timefhuman ··· 26 31 def now_ms() -> int: 27 32 """Return current time as Unix epoch milliseconds.""" 28 33 return int(time.time() * 1000) 29 - 30 - 31 - MUSE_DIR = Path(__file__).parent.parent / "muse" 32 - TEMPLATES_DIR = Path(__file__).parent / "templates" 33 - 34 - # Cached raw template content loaded from think/templates/*.md 35 - _templates_cache: dict[str, str] | None = None 36 - 37 - 38 - def _load_raw_templates() -> dict[str, str]: 39 - """Load raw template files from think/templates/ directory. 40 - 41 - Templates are cached on first load. Each .md file becomes a template 42 - variable named after its stem (e.g., daily_preamble.md -> $daily_preamble). 43 - 44 - Returns 45 - ------- 46 - dict[str, str] 47 - Mapping of template variable names to their raw content (no substitution). 48 - """ 49 - global _templates_cache 50 - if _templates_cache is not None: 51 - return _templates_cache 52 - 53 - _templates_cache = {} 54 - if TEMPLATES_DIR.is_dir(): 55 - for md_path in TEMPLATES_DIR.glob("*.md"): 56 - var_name = md_path.stem 57 - try: 58 - post = frontmatter.load( 59 - md_path, 60 - ) 61 - _templates_cache[var_name] = post.content.strip() 62 - except Exception as exc: 63 - logging.debug("Failed to load template %s: %s", md_path, exc) 64 - 65 - return _templates_cache 66 - 67 - 68 - def _load_templates(template_vars: dict[str, str] | None = None) -> dict[str, str]: 69 - """Load and substitute template files from think/templates/ directory. 70 - 71 - Raw templates are cached, but substitution is performed on each call 72 - to support context-dependent variables like $date and $segment_start. 73 - 74 - Parameters 75 - ---------- 76 - template_vars: 77 - Optional variables to substitute into templates. Templates can use 78 - identity vars ($name, $preferred), context vars ($day, $date, 79 - $segment_start, $segment_end), and other template vars. 80 - 81 - Returns 82 - ------- 83 - dict[str, str] 84 - Mapping of template variable names to their substituted content. 85 - """ 86 - raw_templates = _load_raw_templates() 87 - 88 - if not template_vars: 89 - return dict(raw_templates) 90 - 91 - # Substitute variables into each template 92 - substituted = {} 93 - for var_name, content in raw_templates.items(): 94 - try: 95 - template = Template(content) 96 - substituted[var_name] = template.safe_substitute(template_vars) 97 - except Exception as exc: 98 - logging.debug("Template substitution failed for %s: %s", var_name, exc) 99 - substituted[var_name] = content 100 - 101 - return substituted 102 - 103 - 104 - class PromptContent(NamedTuple): 105 - """Container for prompt text, metadata, and its resolved path.""" 106 - 107 - text: str 108 - path: Path 109 - metadata: dict[str, Any] = {} 110 - 111 - 112 - class PromptNotFoundError(FileNotFoundError): 113 - """Raised when a prompt file cannot be located.""" 114 - 115 - def __init__(self, path: Path) -> None: 116 - self.path = path 117 - super().__init__(f"Prompt file not found: {path}") 118 - 119 - 120 - def _flatten_identity_to_template_vars(identity: dict[str, Any]) -> dict[str, str]: 121 - """Flatten identity config into template variables with uppercase-first versions. 122 - 123 - Parameters 124 - ---------- 125 - identity: 126 - Identity configuration dictionary from get_config()['identity']. 127 - 128 - Returns 129 - ------- 130 - dict[str, str] 131 - Template variables including flattened nested objects and uppercase-first versions. 132 - For example: 133 - - 'name' → identity['name'] 134 - - 'pronouns_possessive' → identity['pronouns']['possessive'] 135 - - 'Pronouns_possessive' → identity['pronouns']['possessive'].capitalize() 136 - - 'bio' → identity['bio'] 137 - """ 138 - template_vars: dict[str, str] = {} 139 - 140 - # Flatten top-level and nested values 141 - for key, value in identity.items(): 142 - if isinstance(value, dict): 143 - # Flatten nested dictionaries with underscore separator 144 - for subkey, subvalue in value.items(): 145 - var_name = f"{key}_{subkey}" 146 - template_vars[var_name] = str(subvalue) 147 - # Create uppercase-first version 148 - template_vars[var_name.capitalize()] = str(subvalue).capitalize() 149 - elif isinstance(value, (str, int, float, bool)): 150 - # Top-level scalar values 151 - template_vars[key] = str(value) 152 - # Create uppercase-first version 153 - template_vars[key.capitalize()] = str(value).capitalize() 154 - 155 - return template_vars 156 - 157 - 158 - def load_prompt( 159 - name: str, 160 - base_dir: str | Path | None = None, 161 - *, 162 - include_journal: bool = False, 163 - context: dict[str, Any] | None = None, 164 - ) -> PromptContent: 165 - """Return the text contents, metadata, and path for a ``.md`` prompt file. 166 - 167 - Prompt files use JSON frontmatter for metadata. Supports Python 168 - string.Template variable substitution using: 169 - - Identity config from get_config()['identity']: 170 - - Top-level fields: $name, $preferred, $bio, $timezone 171 - - Nested fields with underscores: $pronouns_possessive, $pronouns_subject 172 - - Uppercase-first versions: $Pronouns_possessive, $Name, $Bio 173 - - Templates from think/templates/*.md: 174 - - Each file becomes a variable named after its stem 175 - - Example: daily_preamble.md -> $daily_preamble 176 - - Templates are pre-processed with identity and context vars, so templates 177 - can use $date, $preferred, etc. before being substituted into prompts 178 - 179 - Callers can provide additional context variables via the ``context`` parameter. 180 - Context variables override identity and template variables if there's a collision. 181 - Uppercase-first versions are automatically created for context variables. 182 - 183 - Parameters 184 - ---------- 185 - name: 186 - Base filename of the prompt without the ``.md`` suffix. If the suffix is 187 - included, it will not be duplicated. 188 - base_dir: 189 - Optional directory containing the prompt file. Defaults to the directory 190 - of this module when not provided. 191 - include_journal: 192 - If True, prepends the content of ``think/journal.md`` to the requested 193 - prompt. Defaults to False. Context variables are passed through to the 194 - journal template as well. 195 - context: 196 - Optional dictionary of additional template variables. Values are converted 197 - to strings. For each key, an uppercase-first version is also created 198 - (e.g., ``{"day": "20250110"}`` adds both ``$day`` and ``$Day``). 199 - 200 - Returns 201 - ------- 202 - PromptContent 203 - The prompt text (with surrounding whitespace removed and template variables 204 - substituted), the resolved path to the ``.md`` file, and metadata from 205 - the JSON frontmatter. 206 - """ 207 - 208 - if not name: 209 - raise ValueError("Prompt name must be provided") 210 - 211 - if name.endswith(".md"): 212 - filename = name 213 - else: 214 - filename = f"{name}.md" 215 - 216 - prompt_dir = Path(base_dir) if base_dir is not None else Path(__file__).parent 217 - prompt_path = prompt_dir / filename 218 - try: 219 - post = frontmatter.load( 220 - prompt_path, 221 - ) 222 - text = post.content.strip() 223 - metadata = dict(post.metadata) 224 - except FileNotFoundError as exc: # pragma: no cover - caller handles missing prompt 225 - raise PromptNotFoundError(prompt_path) from exc 226 - 227 - # Perform template substitution 228 - try: 229 - config = get_config() 230 - identity = config.get("identity", {}) 231 - template_vars = _flatten_identity_to_template_vars(identity) 232 - 233 - # Merge caller-provided context (overrides identity vars if collision) 234 - if context: 235 - for key, value in context.items(): 236 - str_value = str(value) 237 - template_vars[key] = str_value 238 - # Add uppercase-first version 239 - template_vars[key.capitalize()] = str_value.capitalize() 240 - 241 - # Load templates with identity and context vars so templates can use them 242 - templates = _load_templates(template_vars) 243 - template_vars.update(templates) 244 - 245 - # Use safe_substitute to avoid errors for undefined variables 246 - template = Template(text) 247 - text = template.safe_substitute(template_vars) 248 - except Exception as exc: 249 - # Log but don't fail - return original text if substitution fails 250 - logging.debug("Template substitution failed for %s: %s", prompt_path, exc) 251 - 252 - # Prepend journal content if requested 253 - if include_journal and name != "journal": 254 - journal_content = load_prompt("journal", context=context) 255 - text = f"{journal_content.text}\n\n{text}" 256 - 257 - return PromptContent(text=text, path=prompt_path, metadata=metadata) 258 34 259 35 260 36 def get_journal_info() -> tuple[str, str]: ··· 757 533 os.environ[key] = str(value) 758 534 759 535 return (args, extra) if parse_known else args 760 - 761 - 762 - def get_output_topic(key: str) -> str: 763 - """Convert agent/generator key to filesystem-safe basename (no extension). 764 - 765 - Parameters 766 - ---------- 767 - key: 768 - Generator key in format "topic" (system) or "app:topic" (app). 769 - 770 - Returns 771 - ------- 772 - str 773 - Filesystem-safe name: "topic" or "_app_topic". 774 - 775 - Examples 776 - -------- 777 - >>> get_output_topic("activity") 778 - 'activity' 779 - >>> get_output_topic("chat:sentiment") 780 - '_chat_sentiment' 781 - """ 782 - if ":" in key: 783 - app, topic = key.split(":", 1) 784 - return f"_{app}_{topic}" 785 - return key 786 - 787 - 788 - def key_to_context(key: str) -> str: 789 - """Convert muse config key to context pattern. 790 - 791 - Parameters 792 - ---------- 793 - key: 794 - Muse config key in format "name" (system) or "app:name" (app). 795 - 796 - Returns 797 - ------- 798 - str 799 - Context pattern: "muse.system.{name}" or "muse.{app}.{name}". 800 - 801 - Examples 802 - -------- 803 - >>> key_to_context("meetings") 804 - 'muse.system.meetings' 805 - >>> key_to_context("entities:observer") 806 - 'muse.entities.observer' 807 - """ 808 - if ":" in key: 809 - app, name = key.split(":", 1) 810 - return f"muse.{app}.{name}" 811 - return f"muse.system.{key}" 812 - 813 - 814 - def get_output_path( 815 - day_dir: os.PathLike[str], 816 - key: str, 817 - segment: str | None = None, 818 - output_format: str | None = None, 819 - facet: str | None = None, 820 - ) -> Path: 821 - """Return output path for generator agent output. 822 - 823 - Shared utility for determining where to write generator results. 824 - Used by think/agents.py and think/cortex.py. 825 - 826 - Parameters 827 - ---------- 828 - day_dir: 829 - Day directory path (YYYYMMDD). 830 - key: 831 - Generator key or agent name (e.g., "activity", "chat:sentiment", 832 - "decisionalizer", "entities:observer"). 833 - segment: 834 - Optional segment key (HHMMSS_LEN) for segment-level output. 835 - output_format: 836 - Output format - "json" for JSON, anything else for markdown. 837 - facet: 838 - Optional facet name for multi-facet agents. When provided, the facet 839 - is appended to the filename (e.g., "newsletter_work.md"). 840 - 841 - Returns 842 - ------- 843 - Path 844 - Output file path: 845 - - With segment: YYYYMMDD/{segment}/{topic}.{ext} 846 - - Without segment: YYYYMMDD/agents/{topic}.{ext} 847 - - With facet: {topic}_{facet}.{ext} instead of {topic}.{ext} 848 - Where topic is derived from key and ext is "json" or "md". 849 - """ 850 - day = Path(day_dir) 851 - topic = get_output_topic(key) 852 - ext = "json" if output_format == "json" else "md" 853 - 854 - # Append facet suffix for multi-facet agent outputs 855 - if facet: 856 - filename = f"{topic}_{facet}.{ext}" 857 - else: 858 - filename = f"{topic}.{ext}" 859 - 860 - if segment: 861 - # Segment output goes directly in segment directory 862 - return day / segment / filename 863 - else: 864 - # Daily output goes in agents/ subdirectory 865 - return day / "agents" / filename 866 - 867 - 868 - def _load_prompt_metadata(md_path: Path) -> dict[str, object]: 869 - """Load prompt metadata from .md file with JSON frontmatter. 870 - 871 - Parameters 872 - ---------- 873 - md_path: 874 - Path to the .md prompt file with JSON frontmatter. 875 - 876 - Returns 877 - ------- 878 - dict 879 - Metadata dict with path, mtime, color, and frontmatter fields. 880 - """ 881 - mtime = int(md_path.stat().st_mtime) 882 - info: dict[str, object] = { 883 - "path": str(md_path), 884 - "mtime": mtime, 885 - } 886 - 887 - try: 888 - post = frontmatter.load( 889 - md_path, 890 - ) 891 - if post.metadata: 892 - info.update(post.metadata) 893 - except Exception as exc: # pragma: no cover - metadata optional 894 - logging.debug("Error reading frontmatter from %s: %s", md_path, exc) 895 - 896 - # Apply default color if not specified 897 - if "color" not in info: 898 - info["color"] = "#6c757d" 899 - 900 - return info 901 - 902 - 903 - def get_muse_configs( 904 - *, 905 - has_tools: bool | None = None, 906 - has_output: bool | None = None, 907 - schedule: str | None = None, 908 - include_disabled: bool = False, 909 - ) -> dict[str, dict[str, Any]]: 910 - """Load muse configs from system and app directories. 911 - 912 - Unified function for loading both tool-using agents and generators from 913 - muse/*.md files. Filters based on presence of tools/output fields. 914 - 915 - Args: 916 - has_tools: If True, only configs with "tools" field (agents). 917 - If False, only configs without "tools" field. 918 - If None, no filtering on tools presence. 919 - has_output: If True, only configs with "output" field (generators). 920 - If False, only configs without "output" field. 921 - If None, no filtering on output presence. 922 - schedule: If provided, only configs where schedule matches this value 923 - (e.g., "segment", "daily"). 924 - include_disabled: If True, include configs with disabled=True. 925 - Default False (for processing pipelines). 926 - 927 - Returns: 928 - Dictionary mapping config keys to their metadata including: 929 - - path: Path to the .md file 930 - - source: "system" or "app" 931 - - app: App name (only for app configs) 932 - - All fields from frontmatter 933 - """ 934 - configs: dict[str, dict[str, Any]] = {} 935 - 936 - def matches_filter(info: dict) -> bool: 937 - """Check if config matches the filter criteria.""" 938 - # Check has_tools filter 939 - if has_tools is True and "tools" not in info: 940 - return False 941 - if has_tools is False and "tools" in info: 942 - return False 943 - 944 - # Check has_output filter 945 - if has_output is True and "output" not in info: 946 - return False 947 - if has_output is False and "output" in info: 948 - return False 949 - 950 - # Check specific schedule value 951 - if schedule is not None and info.get("schedule") != schedule: 952 - return False 953 - 954 - # Check disabled status 955 - if not include_disabled and info.get("disabled", False): 956 - return False 957 - 958 - return True 959 - 960 - # System configs from muse/ 961 - if MUSE_DIR.is_dir(): 962 - for md_path in sorted(MUSE_DIR.glob("*.md")): 963 - name = md_path.stem 964 - info = _load_prompt_metadata(md_path) 965 - 966 - if not matches_filter(info): 967 - continue 968 - 969 - info["source"] = "system" 970 - configs[name] = info 971 - 972 - # App configs from apps/*/muse/ 973 - apps_dir = Path(__file__).parent.parent / "apps" 974 - if apps_dir.is_dir(): 975 - for app_path in sorted(apps_dir.iterdir()): 976 - if not app_path.is_dir() or app_path.name.startswith("_"): 977 - continue 978 - app_muse_dir = app_path / "muse" 979 - if not app_muse_dir.is_dir(): 980 - continue 981 - app_name = app_path.name 982 - for md_path in sorted(app_muse_dir.glob("*.md")): 983 - item_name = md_path.stem 984 - info = _load_prompt_metadata(md_path) 985 - 986 - if not matches_filter(info): 987 - continue 988 - 989 - key = f"{app_name}:{item_name}" 990 - info["source"] = "app" 991 - info["app"] = app_name 992 - configs[key] = info 993 - 994 - # Merge journal config overrides from providers.contexts 995 - providers_config = get_config().get("providers", {}) 996 - contexts = providers_config.get("contexts", {}) 997 - 998 - for key, info in configs.items(): 999 - context_key = key_to_context(key) 1000 - 1001 - # Check for exact match in contexts 1002 - override = contexts.get(context_key) 1003 - if override and isinstance(override, dict): 1004 - # Merge supported override fields 1005 - if "disabled" in override: 1006 - info["disabled"] = override["disabled"] 1007 - if "extract" in override: 1008 - info["extract"] = override["extract"] 1009 - if "tier" in override: 1010 - info["tier"] = override["tier"] 1011 - if "provider" in override: 1012 - info["provider"] = override["provider"] 1013 - 1014 - # Validate: scheduled prompts must have explicit priority 1015 - for key, info in configs.items(): 1016 - if info.get("schedule") and "priority" not in info: 1017 - raise ValueError( 1018 - f"Scheduled prompt '{key}' is missing required 'priority' field. " 1019 - f"All prompts with 'schedule' must declare an explicit priority." 1020 - ) 1021 - 1022 - return configs 1023 - 1024 - 1025 - def _resolve_agent_path(name: str) -> tuple[Path, str]: 1026 - """Resolve agent name to directory path and agent filename. 1027 - 1028 - Parameters 1029 - ---------- 1030 - name: 1031 - Agent name - either system agent (e.g., "default") or 1032 - app-namespaced agent (e.g., "chat:helper"). 1033 - 1034 - Returns 1035 - ------- 1036 - tuple[Path, str] 1037 - (agent_directory, agent_name) tuple. 1038 - """ 1039 - if ":" in name: 1040 - # App agent: "chat:helper" -> apps/chat/muse/helper 1041 - app, agent_name = name.split(":", 1) 1042 - agent_dir = Path(__file__).parent.parent / "apps" / app / "muse" 1043 - else: 1044 - # System agent: "default" -> muse/default 1045 - agent_dir = MUSE_DIR 1046 - agent_name = name 1047 - return agent_dir, agent_name 1048 - 1049 - 1050 - # Default instruction configuration - all false, agents must explicitly opt-in 1051 - _DEFAULT_INSTRUCTIONS = { 1052 - "system": None, 1053 - "facets": False, 1054 - "sources": { 1055 - "audio": False, 1056 - "screen": False, 1057 - "agents": False, 1058 - }, 1059 - } 1060 - 1061 - 1062 - def _merge_instructions_config(defaults: dict, overrides: dict | None) -> dict: 1063 - """Merge instruction config overrides into defaults. 1064 - 1065 - Handles nested "sources" dict specially. 1066 - 1067 - Parameters 1068 - ---------- 1069 - defaults: 1070 - Default instruction configuration. 1071 - overrides: 1072 - Optional overrides from .json "instructions" key. 1073 - 1074 - Returns 1075 - ------- 1076 - dict 1077 - Merged configuration. 1078 - """ 1079 - if not overrides: 1080 - return defaults.copy() 1081 - 1082 - result = defaults.copy() 1083 - 1084 - # Merge top-level keys 1085 - for key in ("system", "facets"): 1086 - if key in overrides: 1087 - result[key] = overrides[key] 1088 - 1089 - # Merge sources dict if present 1090 - if "sources" in overrides and isinstance(overrides["sources"], dict): 1091 - result["sources"] = {**defaults.get("sources", {}), **overrides["sources"]} 1092 - 1093 - return result 1094 - 1095 - 1096 - def compose_instructions( 1097 - *, 1098 - user_prompt: str | None = None, 1099 - user_prompt_dir: Path | None = None, 1100 - facet: str | None = None, 1101 - include_datetime: bool = True, 1102 - config_overrides: dict | None = None, 1103 - ) -> dict: 1104 - """Compose instruction components for agents or generators. 1105 - 1106 - This is the shared function for building system_instruction, user_instruction, 1107 - extra_context, and sources configuration. Both agents and generators use this 1108 - to ensure consistent prompt composition. 1109 - 1110 - Parameters 1111 - ---------- 1112 - user_prompt: 1113 - Name of the user instruction prompt to load (e.g., "default" for agents). 1114 - If None, no user_instruction is included (typical for generators). 1115 - user_prompt_dir: 1116 - Directory to load user_prompt from. If None, uses think/ directory. 1117 - facet: 1118 - Optional facet name to focus on. When provided, extra_context includes 1119 - only this facet's info (detail level controlled by "facets" setting). 1120 - include_datetime: 1121 - Whether to include current date/time in extra_context. Default True 1122 - for agents (real-time chat), typically False for generators (past analysis). 1123 - config_overrides: 1124 - Optional dict from .json "instructions" key. Supported keys: 1125 - - "system": prompt name for system instruction (default: "journal") 1126 - - "facets": false | true | "full" (default: true) 1127 - false = skip facet context 1128 - true = include facet context with names only 1129 - "full" = include facet context with full descriptions 1130 - For faceted generators, shows focused facet; for unfaceted, shows all facets. 1131 - - "sources": {"audio": bool, "screen": bool, "agents": bool|dict} 1132 - The "agents" source can be: 1133 - - bool: True (all agents), False (no agents) 1134 - - "required": all agents, fail if none found 1135 - - dict: selective filtering, e.g., {"entities": true, "meetings": "required"} 1136 - 1137 - Returns 1138 - ------- 1139 - dict 1140 - Composed instruction configuration: 1141 - - system_instruction: str - loaded from "system" prompt 1142 - - system_prompt_name: str - name of system prompt (for cache keys) 1143 - - user_instruction: str | None - loaded from user_prompt if provided 1144 - - extra_context: str | None - facets + datetime 1145 - - sources: dict - {"audio": bool, "screen": bool, "agents": bool|dict} 1146 - """ 1147 - # Merge defaults with overrides 1148 - cfg = _merge_instructions_config(_DEFAULT_INSTRUCTIONS, config_overrides) 1149 - 1150 - result: dict = {} 1151 - 1152 - # Load system instruction (None means no system prompt) 1153 - system_name = cfg.get("system") 1154 - if system_name: 1155 - system_prompt = load_prompt(system_name) 1156 - result["system_instruction"] = system_prompt.text 1157 - result["system_prompt_name"] = system_name 1158 - else: 1159 - result["system_instruction"] = "" 1160 - result["system_prompt_name"] = "" 1161 - 1162 - # Load user instruction if specified 1163 - if user_prompt: 1164 - base_dir = user_prompt_dir if user_prompt_dir else Path(__file__).parent 1165 - user_prompt_obj = load_prompt(user_prompt, base_dir=base_dir) 1166 - result["user_instruction"] = user_prompt_obj.text 1167 - else: 1168 - result["user_instruction"] = None 1169 - 1170 - # Build extra_context based on facets setting 1171 - # Values: false (skip), true (names only), "full" (with descriptions) 1172 - extra_parts = [] 1173 - facets_setting = cfg.get("facets", False) 1174 - facets_full = facets_setting == "full" 1175 - 1176 - if facets_setting: 1177 - if facet: 1178 - # Focused facet mode: include only this facet's context 1179 - try: 1180 - from think.facets import facet_summary 1181 - 1182 - summary = facet_summary(facet, detailed=facets_full) 1183 - extra_parts.append(f"## Facet Focus\n{summary}") 1184 - except Exception: 1185 - pass # Ignore if facet can't be loaded 1186 - else: 1187 - # General mode: all facets 1188 - try: 1189 - from think.facets import facet_summaries 1190 - 1191 - summary = facet_summaries(detailed=facets_full) 1192 - if summary and summary != "No facets found.": 1193 - extra_parts.append(summary) 1194 - except Exception: 1195 - pass # Ignore if facets can't be loaded 1196 - 1197 - # Add current date/time if requested 1198 - if include_datetime: 1199 - now = datetime.now() 1200 - try: 1201 - import tzlocal 1202 - 1203 - local_tz = tzlocal.get_localzone() 1204 - now_local = now.astimezone(local_tz) 1205 - time_str = now_local.strftime("%A, %B %d, %Y at %I:%M %p %Z") 1206 - except Exception: 1207 - time_str = now.strftime("%A, %B %d, %Y at %I:%M %p") 1208 - extra_parts.append(f"## Current Date and Time\nToday is {time_str}") 1209 - 1210 - result["extra_context"] = "\n\n".join(extra_parts).strip() if extra_parts else None 1211 - 1212 - # Include sources config 1213 - result["sources"] = cfg.get("sources", _DEFAULT_INSTRUCTIONS["sources"]) 1214 - 1215 - return result 1216 - 1217 - 1218 - def source_is_enabled(value: bool | str | dict) -> bool: 1219 - """Check if a source should be loaded based on its config value. 1220 - 1221 - Sources can be configured as: 1222 - - False: don't load 1223 - - True: load if available 1224 - - "required": load (and generation will fail if none found) 1225 - - dict: for agents source, selective loading (e.g., {"entities": true}) 1226 - 1227 - Both True and "required" mean the source should be loaded. 1228 - A non-empty dict means the source should be loaded (with filtering). 1229 - 1230 - Args: 1231 - value: The source config value (bool, "required" string, or dict for agents) 1232 - 1233 - Returns: 1234 - True if the source should be loaded, False otherwise. 1235 - """ 1236 - if isinstance(value, dict): 1237 - # Dict means selective loading - enabled if any agent is enabled 1238 - return any(v is True or v == "required" for v in value.values()) 1239 - return value is True or value == "required" 1240 - 1241 - 1242 - def source_is_required(value: bool | str | dict) -> bool: 1243 - """Check if a source must have content for generation to proceed. 1244 - 1245 - Args: 1246 - value: The source config value (bool, "required" string, or dict for agents) 1247 - 1248 - Returns: 1249 - True if the source is required (generation should skip if no content). 1250 - For dict values, returns True if any agent is marked "required". 1251 - """ 1252 - if isinstance(value, dict): 1253 - return any(v == "required" for v in value.values()) 1254 - return value == "required" 1255 - 1256 - 1257 - def get_agent_filter(value: bool | str | dict) -> dict[str, bool | str] | None: 1258 - """Extract agent filter from sources config. 1259 - 1260 - When agents source is a dict, returns it as filter mapping agent names 1261 - to their enabled/required status. When agents source is bool or "required", 1262 - returns None to indicate all agents should be loaded. 1263 - 1264 - Args: 1265 - value: The agents source config value 1266 - 1267 - Returns: 1268 - Dict mapping agent names to bool/"required", or None for all agents. 1269 - Returns empty dict if value is False (no agents). 1270 - 1271 - Examples: 1272 - >>> get_agent_filter(True) 1273 - None # All agents 1274 - >>> get_agent_filter(False) 1275 - {} # No agents 1276 - >>> get_agent_filter({"entities": True, "meetings": "required"}) 1277 - {"entities": True, "meetings": "required"} 1278 - """ 1279 - if isinstance(value, dict): 1280 - return value 1281 - if value is False: 1282 - return {} # No agents 1283 - return None # All agents (True or "required") 1284 - 1285 - 1286 - def get_agent(name: str = "default", facet: str | None = None) -> dict: 1287 - """Return complete agent configuration by name. 1288 - 1289 - Loads configuration from .md file with JSON frontmatter and instruction text, 1290 - merges with runtime context. 1291 - 1292 - Parameters 1293 - ---------- 1294 - name: 1295 - Agent name to load. Can be a system agent (e.g., "default") 1296 - or an app-namespaced agent (e.g., "chat:helper" for apps/chat/muse/helper). 1297 - facet: 1298 - Optional facet name to focus on. When provided, includes detailed 1299 - information for just this facet (with full entity details) instead 1300 - of summaries of all facets. 1301 - 1302 - Returns 1303 - ------- 1304 - dict 1305 - Complete agent configuration including system_instruction, user_instruction, 1306 - extra_context, model, backend, etc. 1307 - """ 1308 - # Resolve agent path based on namespace 1309 - agent_dir, agent_name = _resolve_agent_path(name) 1310 - 1311 - # Verify agent prompt file exists 1312 - md_path = agent_dir / f"{agent_name}.md" 1313 - if not md_path.exists(): 1314 - raise FileNotFoundError(f"Agent not found: {name}") 1315 - 1316 - # Load config from frontmatter 1317 - post = frontmatter.load( 1318 - md_path, 1319 - ) 1320 - config = dict(post.metadata) if post.metadata else {} 1321 - 1322 - # Extract instructions config if present 1323 - instructions_config = config.pop("instructions", None) 1324 - 1325 - # Use compose_instructions for consistent prompt composition 1326 - instructions = compose_instructions( 1327 - user_prompt=agent_name, 1328 - user_prompt_dir=agent_dir, 1329 - facet=facet, 1330 - include_datetime=True, 1331 - config_overrides=instructions_config, 1332 - ) 1333 - 1334 - # Merge instruction results into config 1335 - config["system_instruction"] = instructions["system_instruction"] 1336 - config["user_instruction"] = instructions["user_instruction"] 1337 - if instructions["extra_context"]: 1338 - config["extra_context"] = instructions["extra_context"] 1339 - 1340 - # Set agent name 1341 - config["name"] = name 1342 - 1343 - return config 1344 536 1345 537 1346 538 def create_mcp_client(http_uri: str) -> Any: