personal memory agent
0
fork

Configure Feed

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

Unify agent and generator config to muse.{source}.{name} context pattern

- Rename context pattern from agent.* to muse.* across all files
- Add key_to_context() utility in think/utils.py for DRY conversion
- Fix bug: app generators now get correct muse.{app}.{name} context
- Move user overrides from journal.json.agents to providers.contexts
- Add /api/generators compatibility layer for settings UI
- Update docs (CORTEX.md, JOURNAL.md, PROVIDERS.md) with new patterns

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

+358 -330
+3 -1
apps/insights/routes.py
··· 85 85 meta = info["meta"] 86 86 87 87 # Get generation cost for this generator 88 - cost_data = get_usage_cost(day, context=f"agent.{key}") 88 + from think.utils import key_to_context 89 + 90 + cost_data = get_usage_cost(day, context=key_to_context(key)) 89 91 cost = cost_data["cost"] if cost_data["cost"] > 0 else None 90 92 91 93 files.append(
+202 -210
apps/settings/routes.py
··· 251 251 - default: Current default provider and tier 252 252 - contexts: Configured context overrides from journal.json 253 253 - context_defaults: Context registry with labels/groups for UI 254 + (includes muse configs with has_tools, schedule, disabled, extract) 254 255 - api_keys: Boolean status for each provider's API key 255 256 """ 256 257 try: ··· 260 261 get_context_registry, 261 262 ) 262 263 from think.providers import get_provider_list 264 + from think.utils import get_muse_configs 263 265 264 266 config = get_journal_config() 265 267 providers_config = config.get("providers", {}) ··· 280 282 "label": ctx_config["label"], 281 283 "group": ctx_config["group"], 282 284 } 285 + # Include has_tools for muse contexts 286 + if "has_tools" in ctx_config: 287 + context_defaults[pattern]["has_tools"] = ctx_config["has_tools"] 288 + 289 + # Enhance muse contexts with additional metadata from get_muse_configs 290 + from think.utils import key_to_context 291 + 292 + muse_configs = get_muse_configs(include_disabled=True) 293 + for key, info in muse_configs.items(): 294 + context_key = key_to_context(key) 295 + 296 + if context_key in context_defaults: 297 + # Add muse-specific fields 298 + if "schedule" in info: 299 + context_defaults[context_key]["schedule"] = info["schedule"] 300 + context_defaults[context_key]["disabled"] = info.get("disabled", False) 301 + # Include extract for generators with occurrence/anticipation hooks 302 + hook = info.get("hook") 303 + has_extraction = ( 304 + isinstance(hook, dict) 305 + and hook.get("post") in ("occurrence", "anticipation") 306 + ) or hook in ("occurrence", "anticipation") 307 + if has_extraction: 308 + context_defaults[context_key]["extract"] = info.get("extract", True) 283 309 284 310 # Get providers list from registry 285 311 providers_list = get_provider_list() ··· 313 339 314 340 Accepts JSON with optional keys: 315 341 - default: {provider, tier} - Set default provider and/or tier 316 - - contexts: {pattern: {provider?, tier?} | null} - Set or clear context overrides 342 + - contexts: {pattern: {provider?, tier?, disabled?, extract?} | null} 343 + Set or clear context overrides 317 344 318 345 Setting a context to null removes the override. 346 + For muse contexts, disabled and extract can also be set. 319 347 """ 320 348 try: 321 - from think.models import get_context_registry 322 349 from think.providers import PROVIDER_REGISTRY 323 350 324 351 request_data = request.get_json() ··· 391 418 config["providers"]["contexts"] = {} 392 419 393 420 old_contexts = old_providers.get("contexts", {}) 394 - context_registry = get_context_registry() 395 421 396 422 for pattern, ctx_config in contexts_data.items(): 397 - # Validate pattern exists in context registry 398 - if pattern not in context_registry: 399 - return ( 400 - jsonify({"error": f"Unknown context pattern: {pattern}"}), 401 - 400, 402 - ) 403 - 404 423 old_ctx = old_contexts.get(pattern) 405 424 406 425 # null means remove the override ··· 433 452 400, 434 453 ) 435 454 455 + # Validate disabled if specified (must be boolean) 456 + if "disabled" in ctx_config: 457 + if not isinstance(ctx_config["disabled"], bool): 458 + return ( 459 + jsonify( 460 + {"error": f"disabled for {pattern} must be a boolean"} 461 + ), 462 + 400, 463 + ) 464 + 465 + # Validate extract if specified (must be boolean) 466 + if "extract" in ctx_config: 467 + if not isinstance(ctx_config["extract"], bool): 468 + return ( 469 + jsonify( 470 + {"error": f"extract for {pattern} must be a boolean"} 471 + ), 472 + 400, 473 + ) 474 + 436 475 # Only store if there's something to override 437 476 if ctx_config: 438 477 if old_ctx != ctx_config: ··· 464 503 465 504 466 505 # --------------------------------------------------------------------------- 506 + # Generators API (compatibility layer for Settings UI) 507 + # --------------------------------------------------------------------------- 508 + 509 + 510 + def _build_generator_info(key: str, info: dict) -> dict: 511 + """Build generator info dict from muse config for Settings UI. 512 + 513 + Transforms muse config metadata into the format expected by the 514 + Settings UI Insights section. 515 + """ 516 + # Determine if extraction is supported (occurrence/anticipation hooks) 517 + hook = info.get("hook") 518 + has_extraction = ( 519 + isinstance(hook, dict) and hook.get("post") in ("occurrence", "anticipation") 520 + ) or hook in ("occurrence", "anticipation") 521 + 522 + return { 523 + "key": key, 524 + "title": info.get("title", info.get("label", key)), 525 + "description": info.get("description", ""), 526 + "source": info.get("source", "system"), 527 + "app": info.get("app"), 528 + "disabled": info.get("disabled", False), 529 + "extract": info.get("extract", True) if has_extraction else None, 530 + "has_extraction": has_extraction, 531 + } 532 + 533 + 534 + @settings_bp.route("/api/generators") 535 + def get_generators() -> Any: 536 + """Return generators grouped by schedule for Settings UI. 537 + 538 + This is a compatibility layer that transforms the unified muse config 539 + into the format expected by the Settings UI Insights section. 540 + 541 + Returns: 542 + - segment: List of segment-schedule generators 543 + - daily: List of daily-schedule generators 544 + """ 545 + try: 546 + from think.utils import get_muse_configs 547 + 548 + # Get all generators (has output but no tools) 549 + all_generators = get_muse_configs( 550 + has_tools=False, has_output=True, include_disabled=True 551 + ) 552 + 553 + segment = [] 554 + daily = [] 555 + 556 + for key, info in all_generators.items(): 557 + gen_info = _build_generator_info(key, info) 558 + schedule = info.get("schedule") 559 + 560 + if schedule == "segment": 561 + segment.append(gen_info) 562 + elif schedule == "daily": 563 + daily.append(gen_info) 564 + # Skip generators without valid schedule 565 + 566 + return jsonify({"segment": segment, "daily": daily}) 567 + 568 + except Exception as e: 569 + return jsonify({"error": str(e)}), 500 570 + 571 + 572 + @settings_bp.route("/api/generators", methods=["PUT"]) 573 + def update_generators() -> Any: 574 + """Update generator settings via providers.contexts. 575 + 576 + This is a compatibility layer that accepts the old generators API 577 + format and stores settings in the unified providers.contexts location. 578 + 579 + Accepts JSON with generator keys mapping to {disabled?, extract?}. 580 + """ 581 + try: 582 + request_data = request.get_json() 583 + if not request_data: 584 + return jsonify({"error": "No data provided"}), 400 585 + 586 + config_dir = Path(state.journal_root) / "config" 587 + config_dir.mkdir(parents=True, exist_ok=True) 588 + config_path = config_dir / "journal.json" 589 + 590 + # Load existing config 591 + config = get_journal_config() 592 + old_providers = copy.deepcopy(config.get("providers", {})) 593 + 594 + if "providers" not in config: 595 + config["providers"] = {} 596 + if "contexts" not in config["providers"]: 597 + config["providers"]["contexts"] = {} 598 + 599 + old_contexts = old_providers.get("contexts", {}) 600 + changed_fields = {} 601 + 602 + from think.utils import key_to_context 603 + 604 + for key, updates in request_data.items(): 605 + if not isinstance(updates, dict): 606 + continue 607 + 608 + context_key = key_to_context(key) 609 + 610 + # Get or create context config 611 + ctx_config = config["providers"]["contexts"].get(context_key, {}) 612 + old_ctx = old_contexts.get(context_key, {}) 613 + 614 + # Apply updates 615 + if "disabled" in updates: 616 + if not isinstance(updates["disabled"], bool): 617 + return ( 618 + jsonify({"error": f"disabled must be boolean for {key}"}), 619 + 400, 620 + ) 621 + ctx_config["disabled"] = updates["disabled"] 622 + 623 + if "extract" in updates: 624 + if not isinstance(updates["extract"], bool): 625 + return jsonify({"error": f"extract must be boolean for {key}"}), 400 626 + ctx_config["extract"] = updates["extract"] 627 + 628 + # Only store if there's something to override 629 + if ctx_config: 630 + if old_ctx != ctx_config: 631 + changed_fields[f"contexts.{context_key}"] = { 632 + "old": old_ctx if old_ctx else None, 633 + "new": ctx_config, 634 + } 635 + config["providers"]["contexts"][context_key] = ctx_config 636 + 637 + # Write back to file 638 + with open(config_path, "w", encoding="utf-8") as f: 639 + json.dump(config, f, indent=2, ensure_ascii=False) 640 + f.write("\n") 641 + 642 + # Log if something changed 643 + if changed_fields: 644 + log_app_action( 645 + app="settings", 646 + facet=None, 647 + action="generators_update", 648 + params={"changed_fields": changed_fields}, 649 + ) 650 + 651 + # Return updated generators 652 + return get_generators() 653 + 654 + except Exception as e: 655 + return jsonify({"error": str(e)}), 500 656 + 657 + 658 + # --------------------------------------------------------------------------- 467 659 # Vision API 468 660 # --------------------------------------------------------------------------- 469 661 ··· 774 966 ) 775 967 776 968 return get_observe() 777 - 778 - except Exception as e: 779 - return jsonify({"error": str(e)}), 500 780 - 781 - 782 - # --------------------------------------------------------------------------- 783 - # Generators API 784 - # --------------------------------------------------------------------------- 785 - 786 - 787 - def _build_generator_info(key: str, meta: dict) -> dict: 788 - """Build generator info dict for API response.""" 789 - # Determine if insight supports extraction via named hook 790 - hook = meta.get("hook") 791 - has_extraction = hook in ("occurrence", "anticipation") 792 - 793 - info = { 794 - "key": key, 795 - "title": meta.get("title", key.replace("_", " ").title()), 796 - "description": meta.get("description", ""), 797 - "color": meta.get("color", "#6c757d"), 798 - "source": meta.get("source", "system"), 799 - "schedule": meta.get("schedule"), 800 - "disabled": bool(meta.get("disabled", False)), 801 - "extract": (meta.get("extract", True) if has_extraction else None), 802 - "has_extraction": has_extraction, 803 - } 804 - 805 - # Add app name if applicable 806 - if meta.get("app"): 807 - info["app"] = meta["app"] 808 - 809 - return info 810 - 811 - 812 - @settings_bp.route("/api/generators") 813 - def get_generators() -> Any: 814 - """Return generators grouped by schedule with config overrides. 815 - 816 - Returns: 817 - - segment: List of segment-level generators 818 - - daily: List of daily generators 819 - 820 - Each generator contains: 821 - - key: Generator identifier 822 - - title, description, color: Display metadata 823 - - source: "system" or "app" 824 - - app: App name (if source is "app") 825 - - schedule: "segment" or "daily" 826 - - disabled: Whether generator is disabled 827 - - extract: Whether event extraction is enabled 828 - - has_extraction: Whether generator supports event extraction 829 - 830 - Generators with missing or invalid schedule are excluded. 831 - """ 832 - try: 833 - from think.utils import get_muse_configs 834 - 835 - # Get generators by schedule (include disabled for settings toggle UI) 836 - segment_generators = [ 837 - _build_generator_info(key, meta) 838 - for key, meta in sorted( 839 - get_muse_configs( 840 - has_tools=False, 841 - has_output=True, 842 - schedule="segment", 843 - include_disabled=True, 844 - ).items() 845 - ) 846 - ] 847 - daily_generators = [ 848 - _build_generator_info(key, meta) 849 - for key, meta in sorted( 850 - get_muse_configs( 851 - has_tools=False, 852 - has_output=True, 853 - schedule="daily", 854 - include_disabled=True, 855 - ).items() 856 - ) 857 - ] 858 - 859 - # Sort within each group: system first, then by key 860 - def sort_key(x: dict) -> tuple: 861 - return (0 if x["source"] == "system" else 1, x.get("app", ""), x["key"]) 862 - 863 - segment_generators.sort(key=sort_key) 864 - daily_generators.sort(key=sort_key) 865 - 866 - return jsonify({"segment": segment_generators, "daily": daily_generators}) 867 - 868 - except Exception as e: 869 - return jsonify({"error": str(e)}), 500 870 - 871 - 872 - @settings_bp.route("/api/generators", methods=["PUT"]) 873 - def update_generators() -> Any: 874 - """Update generator configuration overrides. 875 - 876 - Accepts JSON mapping generator keys to override settings: 877 - { 878 - "<generator_key>": { 879 - "disabled": bool, # Disable generator entirely 880 - "extract": bool # Disable event extraction 881 - } | null # Remove overrides (reset to default) 882 - } 883 - 884 - Only boolean values are accepted for disabled and extract. 885 - Setting a generator to null removes all overrides for that generator. 886 - """ 887 - try: 888 - from think.utils import get_muse_configs 889 - 890 - request_data = request.get_json() 891 - if not request_data: 892 - return jsonify({"error": "No data provided"}), 400 893 - 894 - if not isinstance(request_data, dict): 895 - return jsonify({"error": "Request must be an object"}), 400 896 - 897 - # Get valid generator keys 898 - all_generators = get_muse_configs(has_tools=False, has_output=True) 899 - valid_keys = set(all_generators.keys()) 900 - 901 - config_dir = Path(state.journal_root) / "config" 902 - config_dir.mkdir(parents=True, exist_ok=True) 903 - config_path = config_dir / "journal.json" 904 - 905 - # Load existing config 906 - config = get_journal_config() 907 - old_config = copy.deepcopy(config.get("agents", {})) 908 - 909 - # Ensure agents section exists 910 - if "agents" not in config: 911 - config["agents"] = {} 912 - 913 - changed_fields = {} 914 - 915 - for key, override in request_data.items(): 916 - # Validate generator key exists 917 - if key not in valid_keys: 918 - return ( 919 - jsonify({"error": f"Unknown generator: {key}"}), 920 - 400, 921 - ) 922 - 923 - # Handle null - remove overrides 924 - if override is None: 925 - if key in config["agents"]: 926 - changed_fields[key] = {"old": config["agents"][key], "new": None} 927 - del config["agents"][key] 928 - continue 929 - 930 - if not isinstance(override, dict): 931 - return ( 932 - jsonify({"error": f"Override for {key} must be an object or null"}), 933 - 400, 934 - ) 935 - 936 - # Validate and apply fields 937 - old_override = old_config.get(key, {}) 938 - new_override = config["agents"].get(key, {}) 939 - 940 - for field in ["disabled", "extract"]: 941 - if field in override: 942 - value = override[field] 943 - if not isinstance(value, bool): 944 - return ( 945 - jsonify({"error": f"{field} must be a boolean"}), 946 - 400, 947 - ) 948 - old_val = old_override.get(field) 949 - if old_val != value: 950 - if key not in changed_fields: 951 - changed_fields[key] = {} 952 - changed_fields[key][field] = {"old": old_val, "new": value} 953 - new_override[field] = value 954 - 955 - if new_override: 956 - config["agents"][key] = new_override 957 - 958 - # Clean up empty agents section 959 - if not config["agents"]: 960 - del config["agents"] 961 - 962 - # Write updated config 963 - with open(config_path, "w", encoding="utf-8") as f: 964 - json.dump(config, f, indent=2, ensure_ascii=False) 965 - f.write("\n") 966 - 967 - # Log changes if any 968 - if changed_fields: 969 - log_app_action( 970 - app="settings", 971 - facet=None, 972 - action="generators_update", 973 - params={"changed_fields": changed_fields}, 974 - ) 975 - 976 - return get_generators() 977 969 978 970 except Exception as e: 979 971 return jsonify({"error": str(e)}), 500
+15 -10
docs/CORTEX.md
··· 60 60 } 61 61 ``` 62 62 63 - The model is automatically resolved based on the agent context (`agent.{app}.{name}`) 63 + The model is automatically resolved based on the muse context (`muse.{source}.{name}`) 64 64 and the configured tier in `journal.json`. Provider can optionally be overridden at 65 65 request time, which will resolve the appropriate model for that provider at the same tier. 66 66 ··· 303 303 ### Model Resolution 304 304 305 305 Models are resolved automatically based on context and tier: 306 - 1. Each agent has a context pattern: `agent.{app}.{name}` (e.g., `agent.system.default`) 306 + 1. Each muse config has a context pattern: `muse.{source}.{name}` (e.g., `muse.system.default`) 307 307 2. The context determines the tier (pro/flash/lite) from `journal.json` or system defaults 308 308 3. The tier + provider determines the actual model to use 309 309 ··· 312 312 { 313 313 "providers": { 314 314 "contexts": { 315 - "agent.system.default": {"tier": 1}, 316 - "agent.*": {"tier": 2} 315 + "muse.system.default": {"tier": 1}, 316 + "muse.*": {"tier": 2} 317 317 } 318 318 } 319 319 } ··· 347 347 - Use consistent event structures across providers 348 348 - Process events are written to stdout for Cortex to capture 349 349 350 - ## Scheduled Agents 350 + ## Scheduled Agents and Generators 351 351 352 - Agents with `"schedule": "daily"` run automatically via `sol dream` at midnight each day: 352 + Both agents and generators support scheduling via `sol dream`. Agents have `"schedule": "daily"` and generators have `"schedule": "segment"` or `"schedule": "daily"`. 353 353 354 354 ### Execution Order 355 - Scheduled agents run in priority order (lower numbers first): 356 - 1. Agents are sorted by their `priority` field (default: 50) 357 - 2. Agents with the same priority run in alphabetical order by filename 358 - 3. Each agent completes before the next begins 355 + Scheduled items run in priority order (lower numbers first): 356 + 1. Items are sorted by their `priority` field (default: 50) 357 + 2. Items with the same priority run in alphabetical order by filename 358 + 3. Each item completes before the next begins 359 + 360 + **Priority ranges (recommended):** 361 + - **1-20**: Foundation tasks (early processing) 362 + - **50**: Default (most generators and agents) 363 + - **80-99**: Synthesis tasks that consume other outputs 359 364 360 365 ### Multi-Facet Agents 361 366 When an agent has `"multi_facet": true`:
+16 -3
docs/JOURNAL.md
··· 276 276 }, 277 277 "contexts": { 278 278 "observe.*": {"provider": "google", "tier": 3}, 279 - "agent.*": {"tier": 1}, 280 - "agent.helper": {"provider": "openai", "model": "gpt-5-mini"} 279 + "muse.system.*": {"tier": 1}, 280 + "muse.system.meetings": {"provider": "anthropic", "disabled": true}, 281 + "muse.entities.observer": {"tier": 2, "extract": false} 281 282 }, 282 283 "models": { 283 284 "google": { ··· 307 308 #### Context matching 308 309 309 310 Contexts are matched in order of specificity: 310 - 1. **Exact match** – `"agent.meetings"` matches only that exact context 311 + 1. **Exact match** – `"muse.system.meetings"` matches only that exact context 311 312 2. **Glob pattern** – `"observe.*"` matches any context starting with `observe.` 312 313 3. **Default** – Falls back to the `default` configuration 313 314 315 + #### Context naming convention 316 + 317 + Muse configs (agents and generators) use the pattern `muse.{source}.{name}`: 318 + - System configs: `muse.system.{name}` (e.g., `muse.system.meetings`, `muse.system.default`) 319 + - App configs: `muse.{app}.{name}` (e.g., `muse.entities.observer`, `muse.chat.helper`) 320 + 321 + Other contexts follow the pattern `{module}.{feature}[.{operation}]`: 322 + - Observe pipeline: `observe.describe.frame`, `observe.enrich`, `observe.transcribe.gemini` 323 + - Apps: `app.chat.title` 324 + 314 325 #### Configuration options 315 326 316 327 **default** – Global defaults applied when no context matches: ··· 322 333 - `provider` (string) – Override provider (optional, inherits from default). 323 334 - `tier` (integer) – Tier number (optional). 324 335 - `model` (string) – Explicit model name (optional, overrides tier). 336 + - `disabled` (boolean) – Disable this muse config (optional, muse contexts only). 337 + - `extract` (boolean) – Enable/disable event extraction for generators with occurrence/anticipation hooks (optional). 325 338 326 339 **models** – Per-provider tier overrides. Maps provider name to tier-model mappings: 327 340 ```json
+10 -5
docs/PROVIDERS.md
··· 242 242 243 243 Context strings determine provider and model selection. Providers receive already-resolved models, but understanding the system helps: 244 244 245 - **Context naming convention:** `{module}.{feature}[.{operation}]` 245 + **Context naming convention:** 246 + - Muse configs (agents/generators): `muse.{source}.{name}` where source is `system` or app name 247 + - System: `muse.system.meetings`, `muse.system.default` 248 + - App: `muse.entities.observer`, `muse.chat.helper` 249 + - Other contexts: `{module}.{feature}[.{operation}]` 250 + - Examples: `observe.describe.frame`, `app.chat.title` 246 251 247 - **Dynamic discovery:** Categories and agents can express their own tier/label/group in their configs: 252 + **Dynamic discovery:** Categories and muse configs express their own tier/label/group in their configs: 248 253 - Categories: `observe/categories/*.json` - add `tier`, `label`, `group` fields 249 - - System agents: `muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 250 - - App agents: `apps/*/muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 254 + - System muse: `muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 255 + - App muse: `apps/*/muse/*.md` - add `tier`, `label`, `group` fields in frontmatter 251 256 252 257 These are discovered at runtime and merged with static defaults. Use `get_context_registry()` to get the complete context map including discovered entries. 253 258 254 - See `CONTEXT_DEFAULTS` in `think/models.py` for static context patterns (non-discoverable contexts like `observe.detect.*`, `agent.*`). 259 + See `CONTEXT_DEFAULTS` in `think/models.py` for static context patterns (non-discoverable contexts like `observe.detect.*`). 255 260 256 261 **Resolution** (handled by `think/models.py` `resolve_provider()`): 257 262 1. Exact match in journal.json `providers.contexts`
+1 -1
muse/anticipation.py
··· 55 55 try: 56 56 response_text = generate( 57 57 contents=contents, 58 - context=f"agent.{name}.extraction", 58 + context=f"muse.system.{name}", 59 59 temperature=0.3, 60 60 max_output_tokens=16384, 61 61 thinking_budget=0,
+1 -1
muse/occurrence.py
··· 60 60 try: 61 61 response_text = generate( 62 62 contents=contents, 63 - context=f"agent.{name}.extraction", 63 + context=f"muse.system.{name}", 64 64 temperature=0.3, 65 65 max_output_tokens=16384, 66 66 thinking_budget=0,
+1 -1
tests/test_app_generators.py
··· 55 55 assert callable(routes.get_usage_cost) 56 56 57 57 # Verify it returns expected structure for non-existent day 58 - result = routes.get_usage_cost("19000101", context="agent.test") 58 + result = routes.get_usage_cost("19000101", context="muse.system.test") 59 59 assert "cost" in result 60 60 assert "requests" in result 61 61 assert "tokens" in result
+20 -19
tests/test_models.py
··· 225 225 assert CONTEXT_DEFAULTS["observe.describe.frame"]["tier"] == TIER_LITE 226 226 assert CONTEXT_DEFAULTS["observe.describe.frame"]["group"] == "Observe" 227 227 228 - assert "agent.*" in CONTEXT_DEFAULTS 229 - assert CONTEXT_DEFAULTS["agent.*"]["tier"] == TIER_FLASH 230 - assert CONTEXT_DEFAULTS["agent.*"]["group"] == "Think" 228 + # agent.* static defaults removed - now discovered from muse/*.md 229 + assert "agent.*" not in CONTEXT_DEFAULTS 231 230 232 231 assert "app.chat.title" in CONTEXT_DEFAULTS 233 232 assert CONTEXT_DEFAULTS["app.chat.title"]["tier"] == TIER_LITE ··· 393 392 assert registry[context]["tier"] in (TIER_PRO, TIER_FLASH, TIER_LITE) 394 393 395 394 396 - def test_context_registry_includes_agents(): 397 - """Test that registry includes discovered agent contexts.""" 395 + def test_context_registry_includes_muse_configs(): 396 + """Test that registry includes discovered muse contexts (agents + generators).""" 398 397 registry = get_context_registry() 399 398 400 - # Should have agent entries (from muse/*.md and apps/*/muse/*.md) 401 - agent_contexts = [k for k in registry if k.startswith("agent.")] 399 + # Should have muse entries (from muse/*.md and apps/*/muse/*.md) 400 + muse_contexts = [k for k in registry if k.startswith("muse.")] 402 401 403 - # Should have more than just the fallback pattern 404 - assert len(agent_contexts) > 1, "Should discover agent contexts" 402 + # Should have multiple muse contexts (agents + generators) 403 + assert len(muse_contexts) > 1, "Should discover muse contexts" 405 404 406 - # Should have system agents 407 - system_agents = [k for k in agent_contexts if k.startswith("agent.system.")] 408 - assert len(system_agents) > 0, "Should discover system agents" 405 + # Should have system muse configs 406 + system_muse = [k for k in muse_contexts if k.startswith("muse.system.")] 407 + assert len(system_muse) > 0, "Should discover system muse configs" 409 408 410 - # Should have app agents 411 - app_agents = [ 409 + # Should have app muse configs 410 + app_muse = [ 412 411 k 413 - for k in agent_contexts 414 - if k.startswith("agent.") 415 - and not k.startswith("agent.system.") 416 - and k != "agent.*" 412 + for k in muse_contexts 413 + if k.startswith("muse.") and not k.startswith("muse.system.") 417 414 ] 418 - assert len(app_agents) > 0, "Should discover app agents" 415 + assert len(app_muse) > 0, "Should discover app muse configs" 416 + 417 + # Should include has_tools field for muse contexts 418 + for context in muse_contexts: 419 + assert "has_tools" in registry[context], f"{context} missing has_tools field" 419 420 420 421 421 422 def test_context_registry_structure():
+9 -4
think/agents.py
··· 682 682 max_output_tokens = 8192 * 6 683 683 684 684 # Build context for provider routing and token logging 685 - # Use muse.system.{name} pattern for generators 686 - context = f"muse.system.{name}" if name else "muse.system.unknown" 685 + from think.utils import key_to_context 686 + 687 + context = key_to_context(name) if name else "muse.system.unknown" 687 688 688 689 # Try to use cache if display name provided 689 690 # Note: caching is Google-specific, so we check provider first ··· 943 944 pre_hook = load_pre_hook(meta) 944 945 if pre_hook: 945 946 hook_config = meta.get("hook", {}) 946 - pre_hook_name = hook_config.get("pre") if isinstance(hook_config, dict) else None 947 + pre_hook_name = ( 948 + hook_config.get("pre") if isinstance(hook_config, dict) else None 949 + ) 947 950 pre_hook_info["name"] = pre_hook_name 948 951 949 952 pre_context = build_pre_hook_context( ··· 1205 1208 if config.get("prompt") != before_prompt: 1206 1209 dry_run_event["prompt_before"] = before_prompt 1207 1210 if config.get("system_instruction") != before_system: 1208 - dry_run_event["system_instruction_before"] = before_system 1211 + dry_run_event["system_instruction_before"] = ( 1212 + before_system 1213 + ) 1209 1214 if config.get("user_instruction") != before_user: 1210 1215 dry_run_event["user_instruction_before"] = before_user 1211 1216 if config.get("extra_context") != before_extra:
+4 -14
think/cortex.py
··· 323 323 config["agent_id"] = agent_id 324 324 325 325 # Resolve provider and model from context 326 - # Context format: agent.{app}.{name} where app="system" for system agents 327 326 from think.models import resolve_model_for_provider, resolve_provider 327 + from think.utils import key_to_context 328 328 329 - if ":" in name: 330 - app, name = name.split(":", 1) 331 - else: 332 - app, name = "system", name 333 - agent_context = f"agent.{app}.{name}" 329 + agent_context = key_to_context(name) 334 330 335 331 # Resolve default provider and model from context 336 332 default_provider, model = resolve_provider(agent_context) ··· 600 596 if usage_data and original_request: 601 597 try: 602 598 from think.models import log_token_usage 599 + from think.utils import key_to_context 603 600 604 601 model = original_request.get("model", "unknown") 605 602 name = original_request.get("name", "unknown") 606 - 607 - # Build context in same format as model resolution: 608 - # agent.{app}.{name} where app="system" for system agents 609 - if ":" in name: 610 - app, name = name.split(":", 1) 611 - else: 612 - app, name = "system", name 613 - context = f"agent.{app}.{name}" 603 + context = key_to_context(name) 614 604 615 605 # Extract segment from env if set (flat merge puts env at top level) 616 606 env_config = original_request.get("env", {})
+32 -54
think/models.py
··· 108 108 # Examples: 109 109 # - observe.describe.frame -> observe module, describe feature, frame operation 110 110 # - observe.enrich -> observe module, enrich feature (no sub-operation) 111 - # - agent.* -> agent module, all features (wildcard) 111 + # - muse.system.meetings -> muse module, system source, meetings config 112 + # - muse.entities.observer -> muse module, entities app, observer config 112 113 # - app.chat.title -> apps module, chat app, title operation 113 114 # 114 115 # DYNAMIC DISCOVERY: ··· 169 170 "label": "Summarization", 170 171 "group": "Import", 171 172 }, 172 - # Generator pipeline - daily analysis and summaries 173 - "agent.entities.*": { 174 - "tier": TIER_LITE, 175 - "label": "Entity Extraction", 176 - "group": "Think", 177 - }, 178 - "agent.daily_schedule.*": { 179 - "tier": TIER_LITE, 180 - "label": "Maintenance Window", 181 - "group": "Think", 182 - }, 183 - "agent.*": { 184 - "tier": TIER_FLASH, 185 - "label": "Agent Outputs", 186 - "group": "Think", 187 - }, 188 173 # Utilities - miscellaneous processing tasks 189 174 "detect.created": { 190 175 "tier": TIER_LITE, ··· 213 198 _context_registry: Optional[Dict[str, Dict[str, Any]]] = None 214 199 215 200 216 - def _discover_agent_contexts() -> Dict[str, Dict[str, Any]]: 217 - """Discover agent context defaults from JSON config files. 201 + def _discover_muse_contexts() -> Dict[str, Dict[str, Any]]: 202 + """Discover muse context defaults from muse/*.md config files. 218 203 219 - Scans system agents (muse/*.md) and app agents (apps/*/muse/*.md) 220 - for tier/label/group metadata. This is a lightweight scan that only reads 221 - the JSON metadata, not the full agent configuration. 204 + Scans system muse configs (muse/*.md) and app muse configs (apps/*/muse/*.md) 205 + for tier/label/group metadata. Includes both tool-using agents and generators. 222 206 223 207 Returns 224 208 ------- 225 209 Dict[str, Dict[str, Any]] 226 - Mapping of context patterns to {tier, label, group} dicts. 227 - Context patterns are: agent.system.{name} or agent.{app}.{name} 210 + Mapping of context patterns to {tier, label, group, has_tools} dicts. 211 + Context patterns are: muse.system.{name} or muse.{app}.{name} 228 212 """ 229 213 contexts = {} 230 214 231 - # System agents from muse/ (agents have "tools" field, generators don't) 215 + # System muse configs from muse/ 232 216 muse_dir = Path(__file__).parent.parent / "muse" 233 217 if muse_dir.exists(): 234 218 for md_path in muse_dir.glob("*.md"): 235 - agent_name = md_path.stem 219 + config_name = md_path.stem 236 220 try: 237 221 post = frontmatter.load( 238 222 md_path, 239 223 ) 240 224 config = post.metadata if post.metadata else {} 241 225 242 - # Only include agents (they have "tools" field) 243 - if "tools" not in config: 244 - continue 245 - 246 - context = f"agent.system.{agent_name}" 226 + context = f"muse.system.{config_name}" 247 227 contexts[context] = { 248 228 "tier": config.get("tier", TIER_FLASH), 249 - "label": config.get("label", config.get("title", agent_name)), 250 - "group": config.get("group", "Agents"), 229 + "label": config.get("label", config.get("title", config_name)), 230 + "group": config.get("group", "Think"), 231 + "has_tools": "tools" in config, 251 232 } 252 233 except Exception: 253 - pass # Skip agents that can't be loaded 234 + pass # Skip configs that can't be loaded 254 235 255 - # App agents from apps/*/muse/ 236 + # App muse configs from apps/*/muse/ 256 237 apps_dir = Path(__file__).parent.parent / "apps" 257 238 if apps_dir.is_dir(): 258 239 for app_path in apps_dir.iterdir(): ··· 263 244 continue 264 245 app_name = app_path.name 265 246 for md_path in muse_subdir.glob("*.md"): 266 - agent_name = md_path.stem 247 + config_name = md_path.stem 267 248 try: 268 249 post = frontmatter.load( 269 250 md_path, 270 251 ) 271 252 config = post.metadata if post.metadata else {} 272 253 273 - # Only include agents (they have "tools" field) 274 - if "tools" not in config: 275 - continue 276 - 277 - context = f"agent.{app_name}.{agent_name}" 254 + context = f"muse.{app_name}.{config_name}" 278 255 contexts[context] = { 279 256 "tier": config.get("tier", TIER_FLASH), 280 - "label": config.get("label", config.get("title", agent_name)), 281 - "group": config.get("group", "Agents"), 257 + "label": config.get("label", config.get("title", config_name)), 258 + "group": config.get("group", "Think"), 259 + "has_tools": "tools" in config, 282 260 } 283 261 except Exception: 284 - pass # Skip agents that can't be loaded 262 + pass # Skip configs that can't be loaded 285 263 286 264 return contexts 287 265 ··· 316 294 except ImportError: 317 295 pass # observe module not available 318 296 319 - # Merge agent contexts 320 - agent_contexts = _discover_agent_contexts() 321 - registry.update(agent_contexts) 297 + # Merge muse contexts (agents + generators) 298 + muse_contexts = _discover_muse_contexts() 299 + registry.update(muse_contexts) 322 300 323 301 return registry 324 302 ··· 345 323 Parameters 346 324 ---------- 347 325 context 348 - Context string (e.g., "agent.system.default", "agent.meetings"). 326 + Context string (e.g., "muse.system.default", "observe.describe.frame"). 349 327 350 328 Returns 351 329 ------- ··· 436 414 Parameters 437 415 ---------- 438 416 context 439 - Context string (e.g., "agent.system.default"). 417 + Context string (e.g., "muse.system.default"). 440 418 provider 441 419 Provider name ("google", "openai", "anthropic"). 442 420 ··· 472 450 Parameters 473 451 ---------- 474 452 context 475 - Context string (e.g., "observe.describe.frame", "agent.meetings"). 453 + Context string (e.g., "observe.describe.frame", "muse.system.meetings"). 476 454 477 455 Returns 478 456 ------- ··· 588 566 cached_tokens, reasoning_tokens, requests} 589 567 Response objects: Gemini GenerateContentResponse with usage_metadata attribute 590 568 context : str, optional 591 - Context string (e.g., "module.function:123" or "agent.name.id"). 569 + Context string (e.g., "module.function:123" or "muse.system.default"). 592 570 If None, auto-detects from call stack. 593 571 segment : str, optional 594 572 Segment key (e.g., "143022_300") for attribution. ··· 897 875 Filter to entries with this exact segment key. 898 876 context : str, optional 899 877 Filter to entries where context starts with this prefix. 900 - For example, "agent.system" matches "agent.system.default". 878 + For example, "muse.system" matches "muse.system.default". 901 879 902 880 Returns 903 881 ------- ··· 980 958 contents : str or List 981 959 The content to send to the model. 982 960 context : str 983 - Context string for routing and token logging (e.g., "agent.meetings"). 961 + Context string for routing and token logging (e.g., "muse.system.meetings"). 984 962 This is required and determines which provider/model to use. 985 963 temperature : float 986 964 Temperature for generation (default: 0.3). ··· 1117 1095 contents : str or List 1118 1096 The content to send to the model. 1119 1097 context : str 1120 - Context string for routing and token logging (e.g., "agent.meetings"). 1098 + Context string for routing and token logging (e.g., "muse.system.meetings"). 1121 1099 This is required and determines which provider/model to use. 1122 1100 temperature : float 1123 1101 Temperature for generation (default: 0.3).
+44 -7
think/utils.py
··· 12 12 from datetime import datetime 13 13 from pathlib import Path 14 14 from string import Template 15 - from typing import Any, Callable, NamedTuple, Optional 15 + from typing import Any, NamedTuple, Optional 16 16 17 17 import frontmatter 18 18 import platformdirs ··· 779 779 return key 780 780 781 781 782 + def key_to_context(key: str) -> str: 783 + """Convert muse config key to context pattern. 784 + 785 + Parameters 786 + ---------- 787 + key: 788 + Muse config key in format "name" (system) or "app:name" (app). 789 + 790 + Returns 791 + ------- 792 + str 793 + Context pattern: "muse.system.{name}" or "muse.{app}.{name}". 794 + 795 + Examples 796 + -------- 797 + >>> key_to_context("meetings") 798 + 'muse.system.meetings' 799 + >>> key_to_context("entities:observer") 800 + 'muse.entities.observer' 801 + """ 802 + if ":" in key: 803 + app, name = key.split(":", 1) 804 + return f"muse.{app}.{name}" 805 + return f"muse.system.{key}" 806 + 807 + 782 808 def get_output_path( 783 809 day_dir: os.PathLike[str], 784 810 key: str, ··· 948 974 info["app"] = app_name 949 975 configs[key] = info 950 976 951 - # Merge journal config overrides (applies to generators) 952 - overrides = get_config().get("agents", {}) 953 - for key, override in overrides.items(): 954 - if key in configs and isinstance(override, dict): 977 + # Merge journal config overrides from providers.contexts 978 + providers_config = get_config().get("providers", {}) 979 + contexts = providers_config.get("contexts", {}) 980 + 981 + for key, info in configs.items(): 982 + context_key = key_to_context(key) 983 + 984 + # Check for exact match in contexts 985 + override = contexts.get(context_key) 986 + if override and isinstance(override, dict): 987 + # Merge supported override fields 955 988 if "disabled" in override: 956 - configs[key]["disabled"] = override["disabled"] 989 + info["disabled"] = override["disabled"] 957 990 if "extract" in override: 958 - configs[key]["extract"] = override["extract"] 991 + info["extract"] = override["extract"] 992 + if "tier" in override: 993 + info["tier"] = override["tier"] 994 + if "provider" in override: 995 + info["provider"] = override["provider"] 959 996 960 997 return configs 961 998