personal memory agent
0
fork

Configure Feed

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

Add --dry-run to sol agents and --prompt to sol muse

- Add --dry-run flag to sol agents that emits full LLM context as JSONL
without calling the provider (works for both generators and tool agents)
- Add --prompt mode to sol muse that pipes to agents --dry-run and formats
the output with section headers for system instruction, prompt, transcript
- Support --day, --segment, --facet, --query, --full arguments for context
- Show pre-hook before/after when hooks modify inputs
- Improve sol muse list with 5-column layout (name, title, output, tools, tags)
- Add day format validation (YYYYMMDD)
- Add system_instruction_source to tool agent dry-run events

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

+634 -40
+5
tests/test_agents_ndjson.py
··· 88 88 89 89 mock_args = MagicMock() 90 90 mock_args.verbose = False 91 + mock_args.dry_run = False 91 92 92 93 mock_all_providers(monkeypatch) 93 94 ··· 145 146 146 147 mock_args = MagicMock() 147 148 mock_args.verbose = False 149 + mock_args.dry_run = False 148 150 149 151 mock_all_providers(monkeypatch) 150 152 ··· 180 182 181 183 mock_args = MagicMock() 182 184 mock_args.verbose = False 185 + mock_args.dry_run = False 183 186 184 187 mock_all_providers(monkeypatch) 185 188 ··· 216 219 217 220 mock_args = MagicMock() 218 221 mock_args.verbose = False 222 + mock_args.dry_run = False 219 223 220 224 mock_all_providers(monkeypatch) 221 225 ··· 246 250 247 251 mock_args = MagicMock() 248 252 mock_args.verbose = False 253 + mock_args.dry_run = False 249 254 250 255 mock_all_providers(monkeypatch) 251 256
+148 -19
tests/test_muse_cli.py
··· 9 9 10 10 from think.muse_cli import ( 11 11 _collect_configs, 12 - _property_tags, 12 + _format_output_path, 13 + _format_tags, 14 + _format_tools, 13 15 _scan_variables, 14 16 json_output, 15 17 list_prompts, ··· 61 63 assert info.get("source") == "app", f"{key} should be app" 62 64 63 65 64 - def test_property_tags_output(): 65 - """Property tags show output, tools, hook.""" 66 - assert _property_tags({"output": "md"}) == "output:md" 67 - assert _property_tags({"tools": "journal, todo"}) == "tools:journal, todo" 68 - 69 - # New dict-based hook format 70 - assert _property_tags({"hook": {"post": "occurrence"}}) == "hook:post=occurrence" 71 - assert _property_tags({"hook": {"pre": "prep"}}) == "hook:pre=prep" 66 + def test_format_tags_hook(): 67 + """Format tags shows hook and disabled status.""" 68 + # Dict-based hook format 69 + assert _format_tags({"hook": {"post": "occurrence"}}) == "hook:post=occurrence" 70 + assert _format_tags({"hook": {"pre": "prep"}}) == "hook:pre=prep" 72 71 assert ( 73 - _property_tags({"hook": {"pre": "prep", "post": "process"}}) 72 + _format_tags({"hook": {"pre": "prep", "post": "process"}}) 74 73 == "hook:pre=prep,post=process" 75 74 ) 76 75 77 - tags = _property_tags({"output": "md", "hook": {"post": "occurrence"}}) 78 - assert "output:md" in tags 76 + assert _format_tags({}) == "" 77 + assert "disabled" in _format_tags({"disabled": True}) 78 + 79 + # Hook + disabled combined 80 + tags = _format_tags({"hook": {"post": "occurrence"}, "disabled": True}) 79 81 assert "hook:post=occurrence" in tags 82 + assert "disabled" in tags 80 83 81 - assert _property_tags({}) == "" 82 - assert "disabled" in _property_tags({"disabled": True}) 83 84 85 + def test_format_output_path_segment(): 86 + """Output path for segment-scheduled prompts.""" 87 + assert _format_output_path("activity", {"schedule": "segment", "output": "md"}) == ( 88 + "<segment>/activity.md" 89 + ) 90 + assert _format_output_path( 91 + "speakers", {"schedule": "segment", "output": "json"} 92 + ) == ("<segment>/speakers.json") 84 93 85 - def test_property_tags_tools_list(): 86 - """Property tags handle tools as a list.""" 87 - tags = _property_tags({"tools": ["journal", "todo"]}) 88 - assert tags == "tools:journal,todo" 94 + 95 + def test_format_output_path_daily(): 96 + """Output path for daily-scheduled prompts.""" 97 + assert _format_output_path("flow", {"schedule": "daily", "output": "md"}) == ( 98 + "<day>/agents/flow.md" 99 + ) 100 + assert _format_output_path( 101 + "schedule", {"schedule": "daily", "output": "json"} 102 + ) == ("<day>/agents/schedule.json") 103 + 104 + 105 + def test_format_output_path_unscheduled(): 106 + """Unscheduled prompts with output go to agents/.""" 107 + assert _format_output_path("importer", {"output": "md"}) == ( 108 + "<day>/agents/importer.md" 109 + ) 110 + 111 + 112 + def test_format_output_path_no_output(): 113 + """Prompts without output field return dash.""" 114 + assert _format_output_path("default", {"tools": "journal"}) == "-" 115 + assert _format_output_path("joke_bot", {}) == "-" 116 + 117 + 118 + def test_format_output_path_app_namespaced(): 119 + """App-namespaced prompts use underscore prefix in filename.""" 120 + # This tests the get_output_topic integration 121 + assert _format_output_path( 122 + "entities:entities", {"schedule": "daily", "output": "md"} 123 + ) == ("<day>/agents/_entities_entities.md") 124 + 125 + 126 + def test_format_tools(): 127 + """Format tools extracts tools field or returns dash.""" 128 + assert _format_tools({"tools": "journal, todo"}) == "journal, todo" 129 + assert _format_tools({"tools": ["journal", "todo"]}) == "journal, todo" 130 + assert _format_tools({}) == "-" 131 + assert _format_tools({"output": "md"}) == "-" 89 132 90 133 91 134 def test_scan_variables(): ··· 99 142 100 143 101 144 def test_list_prompts_output(capsys): 102 - """List view outputs expected groups and prompts.""" 145 + """List view outputs expected groups and prompts with column layout.""" 103 146 list_prompts() 104 147 output = capsys.readouterr().out 105 148 149 + # Column header 150 + assert "NAME" in output 151 + assert "TITLE" in output 152 + assert "OUTPUT" in output 153 + assert "TOOLS" in output 154 + assert "TAGS" in output 155 + 156 + # Group headers 106 157 assert "segment:" in output 107 158 assert "daily:" in output 159 + 160 + # Prompt names 108 161 assert "activity" in output 109 162 assert "flow" in output 163 + 164 + # Output path column shows path patterns 165 + assert "<segment>/activity.md" in output 166 + assert "<day>/agents/flow.md" in output 167 + 168 + # Tools column shows tools or dash 169 + assert "journal, todo, entities" in output # default prompt 110 170 111 171 112 172 def test_list_prompts_schedule_filter(capsys): ··· 208 268 assert "schedule" in record 209 269 # Should not contain expanded instruction text 210 270 assert "system_instruction" not in record 271 + 272 + 273 + def test_truncate_content(): 274 + """Content truncation works correctly.""" 275 + from think.muse_cli import _truncate_content 276 + 277 + # Short content not truncated 278 + short = "line1\nline2\nline3" 279 + result, omitted = _truncate_content(short, max_lines=10) 280 + assert result == short 281 + assert omitted == 0 282 + 283 + # Long content truncated 284 + long = "\n".join(f"line{i}" for i in range(200)) 285 + result, omitted = _truncate_content(long, max_lines=100) 286 + assert omitted == 100 287 + assert "lines omitted" in result 288 + assert "line0" in result # First lines kept 289 + assert "line199" in result # Last lines kept 290 + 291 + 292 + def test_yesterday(): 293 + """Yesterday helper returns correct format.""" 294 + from think.muse_cli import _yesterday 295 + 296 + result = _yesterday() 297 + assert len(result) == 8 298 + assert result.isdigit() 299 + 300 + 301 + def test_show_prompt_context_segment_validation(capsys): 302 + """Segment-scheduled prompts require --segment.""" 303 + from think.muse_cli import show_prompt_context 304 + 305 + with pytest.raises(SystemExit): 306 + show_prompt_context("activity", day="20260101") 307 + 308 + output = capsys.readouterr().err 309 + assert "segment-scheduled" in output.lower() 310 + 311 + 312 + def test_show_prompt_context_multi_facet_validation(capsys): 313 + """Multi-facet prompts require --facet.""" 314 + from think.muse_cli import show_prompt_context 315 + 316 + with pytest.raises(SystemExit): 317 + show_prompt_context("entities:entities") 318 + 319 + output = capsys.readouterr().err 320 + assert "multi-facet" in output.lower() 321 + 322 + 323 + def test_show_prompt_context_day_format_validation(capsys): 324 + """Day argument must be YYYYMMDD format.""" 325 + from think.muse_cli import show_prompt_context 326 + 327 + # Too short 328 + with pytest.raises(SystemExit): 329 + show_prompt_context("flow", day="2026") 330 + 331 + output = capsys.readouterr().err 332 + assert "invalid --day format" in output.lower() 333 + 334 + # Non-numeric 335 + with pytest.raises(SystemExit): 336 + show_prompt_context("flow", day="abcdefgh") 337 + 338 + output = capsys.readouterr().err 339 + assert "invalid --day format" in output.lower()
+117 -5
think/agents.py
··· 682 682 max_output_tokens = 8192 * 6 683 683 684 684 # Build context for provider routing and token logging 685 - output_type = "json" if json_output else "markdown" 686 - context = f"agent.{name}.{output_type}" if name else "agent.unknown" 685 + # Use muse.system.{name} pattern for generators 686 + context = f"muse.system.{name}" if name else "muse.system.unknown" 687 687 688 688 # Try to use cache if display name provided 689 689 # Note: caching is Google-specific, so we check provider first ··· 731 731 return result["text"] 732 732 733 733 734 - def _run_generator(config: dict, emit_event: Callable[[dict], None]) -> None: 734 + def _run_generator( 735 + config: dict, emit_event: Callable[[dict], None], *, dry_run: bool = False 736 + ) -> None: 735 737 """Execute generator pipeline with config from cortex. 736 738 737 739 Args: ··· 746 748 - provider: AI provider 747 749 - model: Model name 748 750 emit_event: Callback to emit JSONL events 751 + dry_run: If True, emit dry_run event instead of calling LLM 749 752 """ 750 753 name = config.get("name", "default") 751 754 day = config.get("day") ··· 919 922 920 923 usage_data = None 921 924 922 - if output_exists and not force: 925 + # Dry-run always goes through prompt assembly, regardless of existing output 926 + if output_exists and not force and not dry_run: 923 927 # Load existing content (no LLM call) 924 928 logging.info("Output exists, loading: %s", output_path) 925 929 with open(output_path, "r") as f: ··· 929 933 if output_exists and force: 930 934 logging.info("Force regenerating: %s", output_path) 931 935 936 + # Capture state before pre-hook for dry-run comparison 937 + pre_hook_info: dict[str, Any] = {} 938 + before_transcript = markdown 939 + before_prompt = prompt 940 + before_system = system_instruction 941 + 932 942 # Run pre-processing hook if present (before LLM call) 933 943 pre_hook = load_pre_hook(meta) 934 944 if pre_hook: 945 + hook_config = meta.get("hook", {}) 946 + pre_hook_name = hook_config.get("pre") if isinstance(hook_config, dict) else None 947 + pre_hook_info["name"] = pre_hook_name 948 + 935 949 pre_context = build_pre_hook_context( 936 950 meta, 937 951 name=name, ··· 951 965 system_instruction = modifications.get( 952 966 "system_instruction", system_instruction 953 967 ) 968 + # Track what was modified 969 + pre_hook_info["modifications"] = list(modifications.keys()) 970 + 971 + # Dry-run mode: emit context and return without LLM call 972 + if dry_run: 973 + dry_run_event: dict[str, Any] = { 974 + "event": "dry_run", 975 + "ts": int(time.time() * 1000), 976 + "type": "generator", 977 + "name": name, 978 + "provider": provider, 979 + "model": model or "unknown", 980 + "day": day, 981 + "segment": segment, 982 + "system_instruction": system_instruction, 983 + "system_instruction_source": system_prompt_name, 984 + "prompt": prompt, 985 + "prompt_source": str(agent_path), 986 + "transcript": markdown, 987 + "transcript_chars": len(markdown), 988 + "transcript_files": file_count, 989 + "output_path": str(output_path), 990 + } 991 + # Include pre-hook before/after if hook was run 992 + if pre_hook_info: 993 + dry_run_event["pre_hook"] = pre_hook_info.get("name") 994 + dry_run_event["pre_hook_modifications"] = pre_hook_info.get( 995 + "modifications", [] 996 + ) 997 + # Include before values if they changed 998 + if markdown != before_transcript: 999 + dry_run_event["transcript_before"] = before_transcript 1000 + dry_run_event["transcript_before_chars"] = len(before_transcript) 1001 + if prompt != before_prompt: 1002 + dry_run_event["prompt_before"] = before_prompt 1003 + if system_instruction != before_system: 1004 + dry_run_event["system_instruction_before"] = before_system 1005 + 1006 + emit_event(dry_run_event) 1007 + return 954 1008 955 1009 gen_result = generate_agent_output( 956 1010 markdown, ··· 1014 1068 parser = argparse.ArgumentParser( 1015 1069 description="solstone Agent CLI - Accepts NDJSON input via stdin" 1016 1070 ) 1071 + parser.add_argument( 1072 + "--dry-run", 1073 + action="store_true", 1074 + help="Show what would be sent to the provider without calling the LLM", 1075 + ) 1017 1076 1018 1077 args = setup_cli(parser) 1078 + dry_run = args.dry_run 1019 1079 1020 1080 app_logger = setup_logging(args.verbose) 1021 1081 ··· 1046 1106 if has_output and not has_tools: 1047 1107 # Generator: transcript analysis without tools 1048 1108 app_logger.debug(f"Processing generator: {config.get('name')}") 1049 - _run_generator(config, emit_event) 1109 + _run_generator(config, emit_event, dry_run=dry_run) 1050 1110 1051 1111 elif has_tools: 1052 1112 # Tool-using agent: validate prompt exists ··· 1085 1145 raise ValueError( 1086 1146 f"Unknown provider: {provider!r}. Valid providers: {valid}" 1087 1147 ) 1148 + 1149 + # Capture state before pre-hook for dry-run comparison 1150 + pre_hook_info: dict[str, Any] = {} 1151 + before_prompt = config.get("prompt", "") 1152 + before_system = config.get("system_instruction", "") 1153 + before_user = config.get("user_instruction", "") 1154 + before_extra = config.get("extra_context", "") 1088 1155 1089 1156 # Load pre hook if configured (before LLM call) 1090 1157 pre_hook = load_pre_hook(config) 1091 1158 if pre_hook: 1159 + hook_config = config.get("hook", {}) 1160 + pre_hook_name = ( 1161 + hook_config.get("pre") 1162 + if isinstance(hook_config, dict) 1163 + else None 1164 + ) 1165 + pre_hook_info["name"] = pre_hook_name 1166 + 1092 1167 pre_context = build_pre_hook_context(config) 1093 1168 modifications = run_pre_hook(pre_context, pre_hook) 1094 1169 if modifications: ··· 1101 1176 ): 1102 1177 if key in modifications: 1103 1178 config[key] = modifications[key] 1179 + pre_hook_info["modifications"] = list(modifications.keys()) 1180 + 1181 + # Dry-run mode: emit context and return without LLM call 1182 + if dry_run: 1183 + dry_run_event: dict[str, Any] = { 1184 + "event": "dry_run", 1185 + "ts": int(time.time() * 1000), 1186 + "type": "agent", 1187 + "name": config.get("name", "default"), 1188 + "provider": provider, 1189 + "model": config.get("model", "unknown"), 1190 + "system_instruction": config.get("system_instruction", ""), 1191 + "system_instruction_source": config.get( 1192 + "system_prompt_name", "journal" 1193 + ), 1194 + "user_instruction": config.get("user_instruction", ""), 1195 + "extra_context": config.get("extra_context", ""), 1196 + "prompt": config.get("prompt", ""), 1197 + "tools": config.get("tools", []), 1198 + } 1199 + # Include pre-hook before/after if hook was run 1200 + if pre_hook_info: 1201 + dry_run_event["pre_hook"] = pre_hook_info.get("name") 1202 + dry_run_event["pre_hook_modifications"] = pre_hook_info.get( 1203 + "modifications", [] 1204 + ) 1205 + if config.get("prompt") != before_prompt: 1206 + dry_run_event["prompt_before"] = before_prompt 1207 + if config.get("system_instruction") != before_system: 1208 + dry_run_event["system_instruction_before"] = before_system 1209 + if config.get("user_instruction") != before_user: 1210 + dry_run_event["user_instruction_before"] = before_user 1211 + if config.get("extra_context") != before_extra: 1212 + dry_run_event["extra_context_before"] = before_extra 1213 + 1214 + emit_event(dry_run_event) 1215 + continue 1104 1216 1105 1217 # Load post hook if configured 1106 1218 post_hook = load_post_hook(config)
+364 -16
think/muse_cli.py
··· 10 10 sol muse List all prompts grouped by schedule 11 11 sol muse <name> Show details for a specific prompt 12 12 sol muse <name> --json Output a single prompt as JSONL 13 + sol muse <name> --prompt Show full prompt context (dry-run) 13 14 sol muse --json Output all configs as JSONL 14 15 sol muse --schedule daily Filter by schedule type 15 16 """ ··· 19 20 import argparse 20 21 import json 21 22 import re 23 + import subprocess 22 24 import sys 25 + from datetime import datetime, timedelta 23 26 from pathlib import Path 24 27 from typing import Any 25 28 ··· 29 32 MUSE_DIR, 30 33 _load_prompt_metadata, 31 34 get_muse_configs, 35 + get_output_topic, 32 36 setup_cli, 33 37 ) 34 38 ··· 69 73 return result 70 74 71 75 72 - def _property_tags(info: dict[str, Any]) -> str: 73 - """Build compact property tags string for list view.""" 74 - tags: list[str] = [] 76 + def _format_output_path(key: str, info: dict[str, Any]) -> str: 77 + """Compose output path pattern for a prompt. 75 78 76 - if info.get("output"): 77 - tags.append(f"output:{info['output']}") 79 + Returns path pattern like '<day>/agents/flow.md' or '<segment>/activity.md', 80 + or '-' if the prompt has no output field. 81 + """ 82 + output_format = info.get("output") 83 + if not output_format: 84 + return "-" 78 85 79 - if info.get("tools"): 80 - tools = info["tools"] 81 - if isinstance(tools, list): 82 - tools = ",".join(tools) 83 - tags.append(f"tools:{tools}") 86 + # Determine extension 87 + ext = "json" if output_format == "json" else "md" 88 + 89 + # Get topic name (handles app namespacing like entities:entities -> _entities_entities) 90 + topic = get_output_topic(key) 91 + 92 + # Determine path based on schedule 93 + schedule = info.get("schedule") 94 + if schedule == "segment": 95 + return f"<segment>/{topic}.{ext}" 96 + else: 97 + # daily and unscheduled both go to agents/ 98 + return f"<day>/agents/{topic}.{ext}" 99 + 100 + 101 + def _format_tools(info: dict[str, Any]) -> str: 102 + """Extract tools field or return '-' if none.""" 103 + tools = info.get("tools") 104 + if not tools: 105 + return "-" 106 + if isinstance(tools, list): 107 + return ", ".join(tools) 108 + return tools 109 + 110 + 111 + def _format_tags(info: dict[str, Any]) -> str: 112 + """Build compact tags string for hook and disabled status.""" 113 + tags: list[str] = [] 84 114 85 115 if info.get("hook"): 86 116 hook = info["hook"] ··· 159 189 else: 160 190 groups["unscheduled"].append((key, info)) 161 191 162 - # Compute column width from longest name 192 + # Compute column widths 163 193 all_names = list(configs.keys()) 164 194 name_width = max(len(n) for n in all_names) if all_names else 20 165 195 name_width = max(name_width, 10) 166 196 197 + # Fixed widths for other columns 198 + title_width = 28 199 + output_width = 34 200 + tools_width = 24 201 + 202 + # Print column header 203 + header = ( 204 + f" {'NAME':<{name_width}} {'TITLE':<{title_width}} " 205 + f"{'OUTPUT':<{output_width}} {'TOOLS':<{tools_width}} TAGS" 206 + ) 207 + print(header) 208 + print() 209 + 167 210 # Print each non-empty group 168 211 for group_name in ("segment", "daily", "unscheduled"): 169 212 items = groups[group_name] ··· 175 218 print(f"{group_name}:") 176 219 177 220 for key, info in items: 178 - title = info.get("title", "") 179 - tags = _property_tags(info) 221 + title = info.get("title", "")[:title_width] 222 + output_path = _format_output_path(key, info) 223 + tools = _format_tools(info)[:tools_width] 224 + tags = _format_tags(info) 180 225 src = "" 181 226 if info.get("source") == "app": 182 - src = f" [{info.get('app', 'app')}]" 227 + src = f" [{info.get('app', 'app')}]" 183 228 184 - line = f" {key:<{name_width}} {title:<32} {tags}{src}" 229 + # Build line with columns: name, title, output, tools, tags 230 + tag_part = f" {tags}" if tags else "" 231 + line = ( 232 + f" {key:<{name_width}} {title:<{title_width}} " 233 + f"{output_path:<{output_width}} {tools:<{tools_width}}{tag_part}{src}" 234 + ) 185 235 print(line.rstrip()) 186 236 187 237 if not schedule: ··· 293 343 print(json.dumps(_to_jsonl_record(key, info), default=str)) 294 344 295 345 346 + def _truncate_content(text: str, max_lines: int = 100) -> tuple[str, int]: 347 + """Truncate text to max_lines, returning (text, omitted_count).""" 348 + lines = text.splitlines() 349 + if len(lines) <= max_lines: 350 + return text, 0 351 + # Show first half and last half 352 + half = max_lines // 2 353 + truncated = lines[:half] + ["", f"... ({len(lines) - max_lines} lines omitted)"] + lines[-half:] 354 + return "\n".join(truncated), len(lines) - max_lines 355 + 356 + 357 + def _format_section(title: str, content: str, full: bool = False) -> None: 358 + """Print a section with header and content.""" 359 + print(f"\n{'=' * 60}") 360 + print(f" {title}") 361 + print(f"{'=' * 60}\n") 362 + if not content or not content.strip(): 363 + print("(empty)") 364 + elif full: 365 + print(content) 366 + else: 367 + truncated, omitted = _truncate_content(content) 368 + print(truncated) 369 + if omitted: 370 + print(f"\n(use --full to see all {omitted + 100} lines)") 371 + 372 + 373 + def _yesterday() -> str: 374 + """Return yesterday's date in YYYYMMDD format.""" 375 + return (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") 376 + 377 + 378 + def show_prompt_context( 379 + name: str, 380 + *, 381 + day: str | None = None, 382 + segment: str | None = None, 383 + facet: str | None = None, 384 + query: str | None = None, 385 + full: bool = False, 386 + ) -> None: 387 + """Show full prompt context via dry-run. 388 + 389 + Builds config and pipes to `sol agents --dry-run` to show exactly 390 + what would be sent to the LLM provider. 391 + """ 392 + # Load prompt metadata 393 + configs = get_muse_configs(include_disabled=True) 394 + if name not in configs: 395 + print(f"Prompt not found: {name}", file=sys.stderr) 396 + sys.exit(1) 397 + 398 + info = configs[name] 399 + has_output = bool(info.get("output")) 400 + has_tools = bool(info.get("tools")) 401 + schedule = info.get("schedule") 402 + is_multi_facet = info.get("multi_facet", False) 403 + 404 + # Determine prompt type 405 + if has_output and not has_tools: 406 + prompt_type = "generator" 407 + elif has_tools: 408 + prompt_type = "agent" 409 + else: 410 + print(f"Prompt '{name}' has no output or tools field", file=sys.stderr) 411 + sys.exit(1) 412 + 413 + # Validate day format if provided 414 + if day and (len(day) != 8 or not day.isdigit()): 415 + print(f"Invalid --day format: {day}. Expected YYYYMMDD.", file=sys.stderr) 416 + sys.exit(1) 417 + 418 + # Validate arguments based on type and schedule 419 + if prompt_type == "generator": 420 + # Generators need day, and segment-scheduled need segment 421 + if schedule == "segment" and not segment: 422 + print( 423 + f"Prompt '{name}' is segment-scheduled. Use --segment HHMMSS_LEN", 424 + file=sys.stderr, 425 + ) 426 + sys.exit(1) 427 + if not day: 428 + day = _yesterday() 429 + print(f"Using day: {day} (yesterday)") 430 + 431 + if is_multi_facet and not facet: 432 + # List available facets 433 + try: 434 + from think.facets import get_facets 435 + 436 + facets = get_facets() 437 + facet_names = [k for k, v in facets.items() if not v.get("muted", False)] 438 + print( 439 + f"Prompt '{name}' is multi-facet. Use --facet NAME", 440 + file=sys.stderr, 441 + ) 442 + print(f"Available facets: {', '.join(facet_names)}", file=sys.stderr) 443 + except Exception: 444 + print( 445 + f"Prompt '{name}' is multi-facet. Use --facet NAME", 446 + file=sys.stderr, 447 + ) 448 + sys.exit(1) 449 + 450 + # Build config for dry-run 451 + config: dict[str, Any] = {"name": name} 452 + 453 + if prompt_type == "generator": 454 + config["day"] = day 455 + config["output"] = info.get("output", "md") 456 + if segment: 457 + config["segment"] = segment 458 + if facet: 459 + config["facet"] = facet 460 + else: 461 + # Tool agent - use get_agent() to build full config with instructions 462 + from think.utils import get_agent 463 + 464 + try: 465 + agent_config = get_agent(name, facet=facet) 466 + config.update(agent_config) 467 + except Exception as e: 468 + print(f"Failed to load agent config: {e}", file=sys.stderr) 469 + sys.exit(1) 470 + 471 + # Override prompt with user query 472 + if query: 473 + config["prompt"] = query 474 + else: 475 + config["prompt"] = "(no --query provided)" 476 + 477 + # Run sol agents --dry-run 478 + config_json = json.dumps(config) 479 + try: 480 + result = subprocess.run( 481 + ["sol", "agents", "--dry-run"], 482 + input=config_json + "\n", 483 + capture_output=True, 484 + text=True, 485 + timeout=30, 486 + ) 487 + except subprocess.TimeoutExpired: 488 + print("Dry-run timed out", file=sys.stderr) 489 + sys.exit(1) 490 + except FileNotFoundError: 491 + print("Could not find 'sol' command", file=sys.stderr) 492 + sys.exit(1) 493 + 494 + if result.returncode != 0: 495 + print(f"Dry-run failed: {result.stderr}", file=sys.stderr) 496 + sys.exit(1) 497 + 498 + # Parse JSONL output to find dry_run event 499 + dry_run_event = None 500 + for line in result.stdout.strip().splitlines(): 501 + if not line: 502 + continue 503 + try: 504 + event = json.loads(line) 505 + if event.get("event") == "dry_run": 506 + dry_run_event = event 507 + break 508 + elif event.get("event") == "error": 509 + print(f"Error: {event.get('error')}", file=sys.stderr) 510 + sys.exit(1) 511 + except json.JSONDecodeError: 512 + continue 513 + 514 + if not dry_run_event: 515 + print("No dry_run event received", file=sys.stderr) 516 + if result.stderr: 517 + print(result.stderr, file=sys.stderr) 518 + sys.exit(1) 519 + 520 + # Format and display output 521 + print(f"\n Dry-run for: {name} ({dry_run_event.get('type', 'unknown')})") 522 + print(f" Provider: {dry_run_event.get('provider')} / {dry_run_event.get('model')}") 523 + if dry_run_event.get("day"): 524 + print(f" Day: {dry_run_event.get('day')}") 525 + if dry_run_event.get("segment"): 526 + print(f" Segment: {dry_run_event.get('segment')}") 527 + if dry_run_event.get("output_path"): 528 + print(f" Output: {dry_run_event.get('output_path')}") 529 + 530 + # Pre-hook info 531 + if dry_run_event.get("pre_hook"): 532 + mods = dry_run_event.get("pre_hook_modifications", []) 533 + print(f" Pre-hook: {dry_run_event.get('pre_hook')} (modified: {', '.join(mods) or 'none'})") 534 + 535 + # System instruction (show before first if pre-hook modified it) 536 + if dry_run_event.get("system_instruction_before"): 537 + _format_section( 538 + "SYSTEM INSTRUCTION (before pre-hook)", 539 + dry_run_event.get("system_instruction_before", ""), 540 + full=full, 541 + ) 542 + _format_section( 543 + f"SYSTEM INSTRUCTION (source: {dry_run_event.get('system_instruction_source', 'unknown')})", 544 + dry_run_event.get("system_instruction", ""), 545 + full=full, 546 + ) 547 + 548 + # User instruction (agents only, show before first if pre-hook modified it) 549 + if dry_run_event.get("user_instruction"): 550 + if dry_run_event.get("user_instruction_before"): 551 + _format_section( 552 + "USER INSTRUCTION (before pre-hook)", 553 + dry_run_event.get("user_instruction_before", ""), 554 + full=full, 555 + ) 556 + _format_section("USER INSTRUCTION", dry_run_event.get("user_instruction", ""), full=full) 557 + 558 + # Extra context (agents only) 559 + if dry_run_event.get("extra_context"): 560 + _format_section("EXTRA CONTEXT", dry_run_event.get("extra_context", ""), full=full) 561 + 562 + # Prompt (show before first if pre-hook modified it) 563 + prompt_source = dry_run_event.get("prompt_source", "") 564 + if prompt_source: 565 + prompt_source = f" (source: {_relative_path(prompt_source)})" 566 + if dry_run_event.get("prompt_before"): 567 + _format_section("PROMPT (before pre-hook)", dry_run_event.get("prompt_before", ""), full=full) 568 + _format_section(f"PROMPT{prompt_source}", dry_run_event.get("prompt", ""), full=full) 569 + 570 + # Transcript (generators only, show before first if pre-hook modified it) 571 + if "transcript" in dry_run_event: 572 + chars = dry_run_event.get("transcript_chars", 0) 573 + files = dry_run_event.get("transcript_files", 0) 574 + if dry_run_event.get("transcript_before"): 575 + before_chars = dry_run_event.get("transcript_before_chars", 0) 576 + _format_section( 577 + f"TRANSCRIPT (before pre-hook, {before_chars:,} chars)", 578 + dry_run_event.get("transcript_before", ""), 579 + full=full, 580 + ) 581 + _format_section( 582 + f"TRANSCRIPT ({chars:,} chars from {files} files)", 583 + dry_run_event.get("transcript", ""), 584 + full=full, 585 + ) 586 + 587 + # Tools (agents only) 588 + if dry_run_event.get("tools"): 589 + tools = dry_run_event.get("tools", []) 590 + if isinstance(tools, list): 591 + tools_str = ", ".join(tools) 592 + else: 593 + tools_str = str(tools) 594 + print(f"\n{'=' * 60}") 595 + print(" TOOLS") 596 + print(f"{'=' * 60}\n") 597 + print(tools_str) 598 + 599 + print() 600 + 601 + 296 602 def main() -> None: 297 603 """Entry point for sol muse.""" 298 604 parser = argparse.ArgumentParser(description="Inspect muse prompt configurations") ··· 317 623 action="store_true", 318 624 help="Output as JSONL (one JSON object per line)", 319 625 ) 626 + parser.add_argument( 627 + "--prompt", 628 + action="store_true", 629 + help="Show full prompt context (dry-run mode)", 630 + ) 631 + parser.add_argument( 632 + "--day", 633 + metavar="YYYYMMDD", 634 + help="Day for prompt context (default: yesterday)", 635 + ) 636 + parser.add_argument( 637 + "--segment", 638 + metavar="HHMMSS_LEN", 639 + help="Segment for segment-scheduled prompts", 640 + ) 641 + parser.add_argument( 642 + "--facet", 643 + metavar="NAME", 644 + help="Facet for multi-facet prompts", 645 + ) 646 + parser.add_argument( 647 + "--query", 648 + metavar="TEXT", 649 + help="Sample query for tool agents", 650 + ) 651 + parser.add_argument( 652 + "--full", 653 + action="store_true", 654 + help="Show full content without truncation", 655 + ) 320 656 321 657 args = setup_cli(parser) 322 658 323 - if args.name: 659 + if args.prompt: 660 + if not args.name: 661 + print("--prompt requires a prompt name", file=sys.stderr) 662 + sys.exit(1) 663 + show_prompt_context( 664 + args.name, 665 + day=args.day, 666 + segment=args.segment, 667 + facet=args.facet, 668 + query=args.query, 669 + full=args.full, 670 + ) 671 + elif args.name: 324 672 show_prompt(args.name, as_json=args.json) 325 673 elif args.json: 326 674 json_output(