personal memory agent
0
fork

Configure Feed

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

Add --run flag to sol dream for single prompt execution

Enables iterating on individual prompts without re-running the entire
dream pipeline. Key changes:

- Add --run NAME to execute a single generator or agent by name
- Add --facet NAME to target specific facet for multi-facet agents
- Validate schedule compatibility (segment prompts require --segment)
- Check agent end states and surface errors in single-prompt mode
- Add iso_date() utility to replace inline YYYYMMDD formatting

Usage examples:
sol dream --day 20250129 --run activity --segment 120000_300 --force
sol dream --day 20250129 --run timeline --force
sol dream --day 20250129 --run facet_newsletter --facet work

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

+248 -61
+231 -1
think/dream.py
··· 18 18 day_path, 19 19 get_journal, 20 20 get_muse_configs, 21 + iso_date, 21 22 setup_cli, 22 23 ) 23 24 ··· 283 284 action="store_true", 284 285 help="Skip agent processing, run generators only", 285 286 ) 287 + parser.add_argument( 288 + "--run", 289 + metavar="NAME", 290 + help="Run a single prompt by name (e.g., 'activity', 'timeline')", 291 + ) 292 + parser.add_argument( 293 + "--facet", 294 + metavar="NAME", 295 + help="Target a specific facet (only used with --run for multi-facet agents)", 296 + ) 286 297 return parser 287 298 288 299 ··· 326 337 return (0, 0) 327 338 328 339 # Pre-compute shared data for multi-facet agents 329 - day_formatted = f"{day[:4]}-{day[4:6]}-{day[6:8]}" 340 + day_formatted = iso_date(day) 330 341 input_summary = day_input_summary(day) 331 342 facets = get_facets() 332 343 enabled_facets = {k: v for k, v in facets.items() if not v.get("muted", False)} ··· 489 500 return spawned 490 501 491 502 503 + def run_single_prompt( 504 + day: str, 505 + name: str, 506 + segment: str | None = None, 507 + force: bool = False, 508 + facet: str | None = None, 509 + ) -> bool: 510 + """Run a single prompt (generator or agent) by name. 511 + 512 + Args: 513 + day: Day in YYYYMMDD format 514 + name: Prompt name from muse/*.md (e.g., 'activity', 'timeline') 515 + segment: Optional segment key in HHMMSS_LEN format 516 + force: Whether to regenerate existing output 517 + facet: Optional facet name for multi-facet agents 518 + 519 + Returns: 520 + True if successful, False if failed 521 + """ 522 + from think.cortex_client import get_agent_end_state 523 + 524 + # Load all configs to find the prompt 525 + all_configs = get_muse_configs(include_disabled=True) 526 + 527 + if name not in all_configs: 528 + logging.error(f"Prompt not found: {name}") 529 + logging.info(f"Available prompts: {', '.join(sorted(all_configs.keys()))}") 530 + return False 531 + 532 + config = all_configs[name] 533 + 534 + # Check if disabled 535 + if config.get("disabled"): 536 + logging.warning(f"Prompt '{name}' is disabled") 537 + return False 538 + 539 + # Determine if this is a generator (has output, no tools) or agent (has tools) 540 + has_tools = bool(config.get("tools")) 541 + has_output = bool(config.get("output")) 542 + is_generator = has_output and not has_tools 543 + 544 + # Validate segment compatibility with schedule 545 + prompt_schedule = config.get("schedule") 546 + if segment and prompt_schedule == "daily": 547 + logging.error( 548 + f"'{name}' is a daily prompt (schedule='daily'), " 549 + "but --segment was specified. Remove --segment to run this prompt." 550 + ) 551 + return False 552 + if not segment and prompt_schedule == "segment": 553 + logging.error( 554 + f"'{name}' is a segment prompt (schedule='segment'), " 555 + "but no --segment was specified. Add --segment HHMMSS_LEN to run this prompt." 556 + ) 557 + return False 558 + 559 + # Validate facet usage 560 + if facet and not config.get("multi_facet"): 561 + logging.warning(f"'{name}' is not a multi-facet agent, --facet will be ignored") 562 + facet = None 563 + 564 + day_formatted = iso_date(day) 565 + 566 + if is_generator: 567 + # Run as generator 568 + logging.info(f"Running generator: {name}") 569 + 570 + request_config = { 571 + "day": day, 572 + "output": config.get("output", "md"), 573 + } 574 + if segment: 575 + request_config["segment"] = segment 576 + if force: 577 + request_config["force"] = True 578 + 579 + try: 580 + agent_id = cortex_request( 581 + prompt="", # Generators don't use prompt 582 + name=name, 583 + config=request_config, 584 + ) 585 + logging.info(f"Spawned generator {name} (ID: {agent_id})") 586 + 587 + # Wait for completion 588 + completed, timed_out = wait_for_agents([agent_id], timeout=600) 589 + 590 + if timed_out: 591 + logging.error(f"Generator {name} timed out (ID: {agent_id})") 592 + return False 593 + 594 + end_state = get_agent_end_state(agent_id) 595 + if end_state == "finish": 596 + logging.info(f"Generator {name} completed successfully") 597 + day_log(day, f"dream --run {name} ok") 598 + return True 599 + else: 600 + logging.error(f"Generator {name} ended with state: {end_state}") 601 + return False 602 + 603 + except Exception as e: 604 + logging.error(f"Failed to run generator {name}: {e}") 605 + return False 606 + 607 + else: 608 + # Run as agent 609 + logging.info(f"Running agent: {name}") 610 + 611 + input_summary = day_input_summary(day) 612 + spawned_ids = [] 613 + 614 + if config.get("multi_facet"): 615 + # Multi-facet agent - run for specific facet or all active facets 616 + facets_data = get_facets() 617 + enabled_facets = { 618 + k: v for k, v in facets_data.items() if not v.get("muted", False) 619 + } 620 + active_facets = get_active_facets(day) 621 + always_run = config.get("always", False) 622 + 623 + if facet: 624 + # Run for specific facet 625 + if facet not in enabled_facets: 626 + logging.error(f"Facet '{facet}' not found or is muted") 627 + return False 628 + target_facets = [facet] 629 + else: 630 + # Run for all active facets (or all if always=true) 631 + target_facets = [ 632 + f for f in enabled_facets.keys() if always_run or f in active_facets 633 + ] 634 + 635 + if not target_facets: 636 + logging.warning(f"No active facets for {name} on {day_formatted}") 637 + return True # Not a failure, just nothing to do 638 + 639 + for facet_name in target_facets: 640 + try: 641 + logging.info(f"Spawning {name} for facet: {facet_name}") 642 + agent_id = cortex_request( 643 + prompt=f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context.", 644 + name=name, 645 + config={"facet": facet_name}, 646 + ) 647 + spawned_ids.append(agent_id) 648 + logging.info(f"Started {name} for {facet_name} (ID: {agent_id})") 649 + except Exception as e: 650 + logging.error(f"Failed to spawn {name} for {facet_name}: {e}") 651 + 652 + else: 653 + # Regular single-instance agent 654 + try: 655 + request_config = {} 656 + if segment: 657 + request_config["segment"] = segment 658 + request_config["env"] = {"SEGMENT_KEY": segment} 659 + 660 + agent_id = cortex_request( 661 + prompt=f"Running task for {day_formatted}: {input_summary}.", 662 + name=name, 663 + config=request_config if request_config else None, 664 + ) 665 + spawned_ids.append(agent_id) 666 + logging.info(f"Started {name} agent (ID: {agent_id})") 667 + except Exception as e: 668 + logging.error(f"Failed to spawn {name}: {e}") 669 + return False 670 + 671 + if not spawned_ids: 672 + return False 673 + 674 + # Wait for all spawned agents 675 + logging.info(f"Waiting for {len(spawned_ids)} agent(s)...") 676 + completed, timed_out = wait_for_agents(spawned_ids, timeout=600) 677 + 678 + if timed_out: 679 + logging.warning(f"{len(timed_out)} agent(s) timed out: {timed_out}") 680 + 681 + # Check end states for completed agents 682 + error_count = 0 683 + for agent_id in completed: 684 + end_state = get_agent_end_state(agent_id) 685 + if end_state == "error": 686 + logging.error(f"Agent {agent_id} ended with error") 687 + error_count += 1 688 + 689 + success = len(completed) > 0 and len(timed_out) == 0 and error_count == 0 690 + if success: 691 + day_log(day, f"dream --run {name} ok") 692 + elif error_count > 0: 693 + logging.error(f"{error_count} agent(s) ended with errors") 694 + return success 695 + 696 + 492 697 def emit(event: str, **fields) -> None: 493 698 """Emit a dream tract event if callosum is connected.""" 494 699 if _callosum: ··· 508 713 509 714 if not day_dir.is_dir(): 510 715 parser.error(f"Day folder not found: {day_dir}") 716 + 717 + # Validate --run is mutually exclusive with --skip-generators/--skip-agents 718 + if args.run and (args.skip_generators or args.skip_agents): 719 + parser.error("--run cannot be used with --skip-generators or --skip-agents") 720 + 721 + # Validate --facet requires --run 722 + if args.facet and not args.run: 723 + parser.error("--facet requires --run") 724 + 725 + # Handle single prompt execution mode 726 + if args.run: 727 + # Start callosum for cortex communication 728 + _callosum = CallosumConnection() 729 + _callosum.start() 730 + try: 731 + success = run_single_prompt( 732 + day=day, 733 + name=args.run, 734 + segment=args.segment, 735 + force=args.force, 736 + facet=args.facet, 737 + ) 738 + sys.exit(0 if success else 1) 739 + finally: 740 + _callosum.stop() 511 741 512 742 # Start callosum connection for event emission 513 743 _callosum = CallosumConnection()
+17 -60
think/utils.py
··· 529 529 return day 530 530 531 531 532 + def iso_date(day: str) -> str: 533 + """Convert a day string (YYYYMMDD) to ISO format (YYYY-MM-DD). 534 + 535 + Parameters 536 + ---------- 537 + day: 538 + Day in YYYYMMDD format. 539 + 540 + Returns 541 + ------- 542 + str 543 + ISO formatted date like "2026-01-24". 544 + """ 545 + return f"{day[:4]}-{day[4:6]}-{day[6:8]}" 546 + 547 + 532 548 def format_segment_times(segment: str) -> tuple[str, str] | tuple[None, None]: 533 549 """Format segment start and end times as human-readable strings. 534 550 ··· 817 833 Returns 818 834 ------- 819 835 dict 820 - Metadata dict with path, mtime, color, hook_path (if exists), and frontmatter fields. 836 + Metadata dict with path, mtime, color, and frontmatter fields. 821 837 """ 822 838 mtime = int(md_path.stat().st_mtime) 823 839 info: dict[str, object] = { ··· 838 854 if "color" not in info: 839 855 info["color"] = "#6c757d" 840 856 841 - # Resolve hook path - named hook takes precedence over co-located .py 842 - hook_name = info.get("hook") 843 - if hook_name and isinstance(hook_name, str): 844 - # Named hook: look in muse/{hook}.py 845 - named_hook_path = MUSE_DIR / f"{hook_name}.py" 846 - if named_hook_path.exists(): 847 - info["hook_path"] = str(named_hook_path) 848 - else: 849 - # Co-located hook: check for .py file next to the .md file 850 - hook_path = md_path.with_suffix(".py") 851 - if hook_path.exists(): 852 - info["hook_path"] = str(hook_path) 853 - 854 857 return info 855 - 856 - 857 - def load_output_hook(hook_path: str | Path) -> Callable[[str, dict], str | None]: 858 - """Load an output post-processing hook from a Python file. 859 - 860 - Hooks are Python modules with a ``process(result, context)`` function that 861 - transforms generator output. The hook is loaded in isolation without polluting 862 - sys.modules. 863 - 864 - Parameters 865 - ---------- 866 - hook_path: 867 - Path to the .py hook file. 868 - 869 - Returns 870 - ------- 871 - Callable 872 - The ``process`` function from the hook module. 873 - 874 - Raises 875 - ------ 876 - ValueError 877 - If the hook file doesn't define a ``process`` function. 878 - ImportError 879 - If the hook file cannot be loaded. 880 - """ 881 - import importlib.util 882 - 883 - hook_path = Path(hook_path) 884 - spec = importlib.util.spec_from_file_location( 885 - f"output_hook_{hook_path.stem}", hook_path 886 - ) 887 - if spec is None or spec.loader is None: 888 - raise ImportError(f"Cannot load hook from {hook_path}") 889 - 890 - module = importlib.util.module_from_spec(spec) 891 - spec.loader.exec_module(module) 892 - 893 - if not hasattr(module, "process"): 894 - raise ValueError(f"Hook {hook_path} must define a 'process' function") 895 - 896 - process_func = getattr(module, "process") 897 - if not callable(process_func): 898 - raise ValueError(f"Hook {hook_path} 'process' must be callable") 899 - 900 - return process_func 901 858 902 859 903 860 def get_muse_configs(