personal memory agent
0
fork

Configure Feed

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

facets: cap entity + activity rendering in facet_summaries()

Add keyword-only max_entities_per_facet (default 20) and
max_activities_per_facet (default 15) to facet_summaries(). Entities
are ranked by (observation_count desc, last_observed desc, name asc)
via a direct observation-file scan; activities preserve
get_facet_activities() order. When a cap trips, emit a single
trailing markdown bullet "- _and {N} more entities_" /
"- _and {N} more activities_" at the matching indent. Passing None
disables each cap. Principal filtering runs before the cap so the
principal never consumes budget.

Single production caller think/prompts.py:183 keeps today's API and
picks up default caps automatically.

Spec: cpo/specs/in-flight/facet-summaries-entity-cap.md

Co-authored-by: Codex <codex@openai.com>

+557 -30
+1 -1
tests/baselines/api/sol/preview.json
··· 1 1 { 2 - "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Capulet Industries; Juliet Capulet; Nurse Angela; Paris Duke; Tybalt Capulet\n - **Capulet Industries Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Balcony App; Balthasar Davi; Benvolio Montague; Friar Lawrence; Juliet Capulet; Mercutio Escalus; Mesh Routing; Montague Tech; Prince Escalus; Rosaline Prince; Schema Bridge; Verona Platform; Verona Ventures\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; John Smith; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Balcony App; Friar Lawrence; Juliet Capulet; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Request exec only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Exec\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless exec should be dispatched. When dispatching, include:\n - `task`: the exact work exec should perform\n - `context`: optional structured hints that will help exec start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- Prefer no dispatch over a weak or redundant dispatch.", 2 + "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Request exec only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Exec\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless exec should be dispatched. When dispatching, include:\n - `task`: the exact work exec should perform\n - `context`: optional structured hints that will help exec start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- Prefer no dispatch over a weak or redundant dispatch.", 3 3 "multi_facet": false, 4 4 "name": "chat", 5 5 "title": "Chat"
+365
tests/test_facets.py
··· 12 12 from think.facets import ( 13 13 _format_principal_role, 14 14 _get_principal_display_name, 15 + _rank_entities_by_signal, 15 16 facet_summaries, 16 17 facet_summary, 17 18 get_active_facets, ··· 61 62 relationship = {"entity_id": entity_id, "description": desc} 62 63 with open(facet_entity_dir / "entity.json", "w", encoding="utf-8") as f: 63 64 json.dump(relationship, f) 65 + 66 + 67 + def setup_facet( 68 + journal_path: Path, 69 + facet: str, 70 + *, 71 + title: str | None = None, 72 + description: str = "", 73 + ) -> Path: 74 + """Create a facet directory with minimal metadata for tests.""" 75 + facet_dir = journal_path / "facets" / facet 76 + facet_dir.mkdir(parents=True, exist_ok=True) 77 + facet_data = {"title": title or facet.replace("-", " ").title()} 78 + if description: 79 + facet_data["description"] = description 80 + (facet_dir / "facet.json").write_text(json.dumps(facet_data), encoding="utf-8") 81 + return facet_dir 82 + 83 + 84 + def write_identity_config( 85 + journal_path: Path, 86 + *, 87 + name: str = "Test User", 88 + preferred: str = "Tester", 89 + ) -> None: 90 + """Write a minimal journal identity config for principal tests.""" 91 + config_dir = journal_path / "config" 92 + config_dir.mkdir(parents=True, exist_ok=True) 93 + config = {"identity": {"name": name, "preferred": preferred}} 94 + (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") 95 + 96 + 97 + def write_observations( 98 + journal_path: Path, 99 + facet: str, 100 + entity_name: str, 101 + observed_at_values: list[object], 102 + ) -> None: 103 + """Write observations.jsonl records for a test entity.""" 104 + entity_id = slugify(entity_name, separator="_") 105 + observations_path = ( 106 + journal_path / "facets" / facet / "entities" / entity_id / "observations.jsonl" 107 + ) 108 + observations_path.parent.mkdir(parents=True, exist_ok=True) 109 + lines = [ 110 + json.dumps( 111 + { 112 + "content": f"Observation {index}", 113 + "observed_at": observed_at, 114 + "source_day": "20260420", 115 + } 116 + ) 117 + for index, observed_at in enumerate(observed_at_values, 1) 118 + ] 119 + observations_path.write_text( 120 + "\n".join(lines) + ("\n" if lines else ""), 121 + encoding="utf-8", 122 + ) 64 123 65 124 66 125 def test_facet_summary_full(monkeypatch): ··· 739 798 assert "Coding:" in summary 740 799 assert "Custom Activity:" in summary 741 800 assert "A custom test activity" in summary 801 + 802 + 803 + def test_rank_entities_by_signal_orders_by_count_then_last_observed( 804 + tmp_path, 805 + monkeypatch, 806 + ): 807 + """Rank entities by observation count, then recency, then name.""" 808 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 809 + setup_facet(tmp_path, "signals", title="Signals") 810 + entities = [ 811 + {"type": "Person", "name": "Alpha", "description": "A"}, 812 + {"type": "Person", "name": "Beta", "description": "B"}, 813 + {"type": "Person", "name": "Gamma", "description": "C"}, 814 + {"type": "Person", "name": "Delta", "description": "D"}, 815 + ] 816 + setup_entities_new_structure(tmp_path, "signals", entities) 817 + write_observations( 818 + tmp_path, 819 + "signals", 820 + "Alpha", 821 + ["2026-04-01T10:00:00Z", "2026-04-01T11:00:00Z", "2026-04-01T12:00:00Z"], 822 + ) 823 + write_observations( 824 + tmp_path, 825 + "signals", 826 + "Beta", 827 + ["2026-04-15T10:00:00Z", "2026-04-15T11:00:00Z", "2026-04-15T12:00:00Z"], 828 + ) 829 + write_observations(tmp_path, "signals", "Gamma", ["2026-04-10T09:00:00Z"]) 830 + 831 + ranked = _rank_entities_by_signal("signals", entities) 832 + 833 + assert [entity["name"] for entity in ranked] == ["Beta", "Alpha", "Gamma", "Delta"] 834 + 835 + 836 + def test_rank_entities_by_signal_uses_casefold_name_tiebreaker(tmp_path, monkeypatch): 837 + """Identical signals fall back to case-insensitive name ordering.""" 838 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 839 + setup_facet(tmp_path, "signals", title="Signals") 840 + entities = [ 841 + {"type": "Person", "name": "bravo", "description": "B"}, 842 + {"type": "Person", "name": "Alpha", "description": "A"}, 843 + ] 844 + setup_entities_new_structure(tmp_path, "signals", entities) 845 + observed = ["2026-04-15T12:00:00Z", "2026-04-15T13:00:00Z"] 846 + write_observations(tmp_path, "signals", "bravo", observed) 847 + write_observations(tmp_path, "signals", "Alpha", observed) 848 + 849 + ranked = _rank_entities_by_signal("signals", entities) 850 + 851 + assert [entity["name"] for entity in ranked] == ["Alpha", "bravo"] 852 + 853 + 854 + def test_facet_summaries_detailed_entity_cap_appends_trailing_bullet( 855 + tmp_path, 856 + monkeypatch, 857 + ): 858 + """Detailed mode caps entities and appends the trailing bullet.""" 859 + from think.activities import save_facet_activities 860 + 861 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 862 + setup_facet( 863 + tmp_path, 864 + "entity-cap", 865 + title="Entity Cap", 866 + description="Detailed entity cap", 867 + ) 868 + setup_entities_new_structure( 869 + tmp_path, 870 + "entity-cap", 871 + [ 872 + { 873 + "type": "Person", 874 + "name": f"Entity {index:02d}", 875 + "description": f"Description {index:02d}", 876 + } 877 + for index in range(1, 26) 878 + ], 879 + ) 880 + save_facet_activities("entity-cap", [{"id": "meeting"}, {"id": "coding"}]) 881 + 882 + summary = facet_summaries(detailed=True) 883 + 884 + assert " - Entity 01: Description 01" in summary 885 + assert " - Entity 20: Description 20" in summary 886 + assert " - _and 5 more entities_" in summary 887 + assert "Entity 21: Description 21" not in summary 888 + assert "_and 1 more activities_" not in summary 889 + 890 + 891 + def test_facet_summaries_detailed_activity_cap_appends_trailing_bullet( 892 + tmp_path, 893 + monkeypatch, 894 + ): 895 + """Detailed mode caps activities and appends the trailing bullet.""" 896 + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities 897 + 898 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 899 + setup_facet( 900 + tmp_path, 901 + "activity-cap", 902 + title="Activity Cap", 903 + description="Detailed activity cap", 904 + ) 905 + setup_entities_new_structure( 906 + tmp_path, 907 + "activity-cap", 908 + [ 909 + {"type": "Person", "name": "Alice", "description": "Lead"}, 910 + {"type": "Person", "name": "Bob", "description": "Partner"}, 911 + ], 912 + ) 913 + save_facet_activities( 914 + "activity-cap", 915 + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES], 916 + ) 917 + 918 + summary = facet_summaries(detailed=True) 919 + 920 + assert " - Meetings" in summary 921 + assert " - Design:" in summary 922 + assert " - _and 1 more activities_" in summary 923 + assert " - Music:" not in summary 924 + assert "_and 1 more entities_" not in summary 925 + 926 + 927 + def test_facet_summaries_simple_cap_trips_switches_capped_sections_to_bullets( 928 + tmp_path, 929 + monkeypatch, 930 + ): 931 + """Simple mode only switches capped sections to bullet lists.""" 932 + from think.activities import save_facet_activities 933 + 934 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 935 + setup_facet( 936 + tmp_path, 937 + "simple-cap", 938 + title="Simple Cap", 939 + description="Simple cap formatting", 940 + ) 941 + setup_entities_new_structure( 942 + tmp_path, 943 + "simple-cap", 944 + [ 945 + { 946 + "type": "Person", 947 + "name": f"Entity {index:02d}", 948 + "description": f"Description {index:02d}", 949 + } 950 + for index in range(1, 26) 951 + ], 952 + ) 953 + save_facet_activities("simple-cap", [{"id": "meeting"}, {"id": "coding"}]) 954 + 955 + summary = facet_summaries(detailed=False) 956 + 957 + assert " - **Simple Cap Entities**:\n - Entity 01" in summary 958 + assert " - _and 5 more entities_" in summary 959 + assert " - **Simple Cap Activities**: Meetings; Coding" in summary 960 + assert " - **Simple Cap Entities**: Entity 01; Entity 02" not in summary 961 + assert " - _and 1 more activities_" not in summary 962 + 963 + 964 + def test_facet_summaries_exactly_at_caps_has_no_trailing_bullets( 965 + tmp_path, 966 + monkeypatch, 967 + ): 968 + """Exactly-at-cap output stays uncapped and keeps simple one-line formatting.""" 969 + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities 970 + 971 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 972 + setup_facet( 973 + tmp_path, 974 + "exact-cap", 975 + title="Exact Cap", 976 + description="Exactly at cap", 977 + ) 978 + setup_entities_new_structure( 979 + tmp_path, 980 + "exact-cap", 981 + [ 982 + { 983 + "type": "Person", 984 + "name": f"Entity {index:02d}", 985 + "description": f"Description {index:02d}", 986 + } 987 + for index in range(1, 21) 988 + ], 989 + ) 990 + save_facet_activities( 991 + "exact-cap", 992 + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES[:15]], 993 + ) 994 + 995 + detailed_summary = facet_summaries(detailed=True) 996 + simple_summary = facet_summaries(detailed=False) 997 + 998 + assert "_and 0 more entities_" not in detailed_summary 999 + assert "_and 0 more activities_" not in detailed_summary 1000 + assert "_and 0 more entities_" not in simple_summary 1001 + assert "_and 0 more activities_" not in simple_summary 1002 + assert " - **Exact Cap Entities**: Entity 01; Entity 02" in simple_summary 1003 + assert " - **Exact Cap Activities**: Meetings; Coding" in simple_summary 1004 + 1005 + 1006 + def test_facet_summaries_none_entity_cap_is_unbounded(tmp_path, monkeypatch): 1007 + """None entity cap restores the full entity list.""" 1008 + from think.activities import save_facet_activities 1009 + 1010 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 1011 + setup_facet( 1012 + tmp_path, 1013 + "entity-unbounded", 1014 + title="Entity Unbounded", 1015 + description="No entity cap", 1016 + ) 1017 + setup_entities_new_structure( 1018 + tmp_path, 1019 + "entity-unbounded", 1020 + [ 1021 + { 1022 + "type": "Person", 1023 + "name": f"Entity {index:02d}", 1024 + "description": f"Description {index:02d}", 1025 + } 1026 + for index in range(1, 26) 1027 + ], 1028 + ) 1029 + save_facet_activities("entity-unbounded", [{"id": "meeting"}]) 1030 + 1031 + summary = facet_summaries(detailed=True, max_entities_per_facet=None) 1032 + 1033 + assert "Entity 25: Description 25" in summary 1034 + assert "_and 5 more entities_" not in summary 1035 + 1036 + 1037 + def test_facet_summaries_none_activity_cap_is_unbounded(tmp_path, monkeypatch): 1038 + """None activity cap restores the full activity list.""" 1039 + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities 1040 + 1041 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 1042 + setup_facet( 1043 + tmp_path, 1044 + "activity-unbounded", 1045 + title="Activity Unbounded", 1046 + description="No activity cap", 1047 + ) 1048 + setup_entities_new_structure( 1049 + tmp_path, 1050 + "activity-unbounded", 1051 + [{"type": "Person", "name": "Alice", "description": "Lead"}], 1052 + ) 1053 + save_facet_activities( 1054 + "activity-unbounded", 1055 + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES], 1056 + ) 1057 + 1058 + summary = facet_summaries(detailed=True, max_activities_per_facet=None) 1059 + 1060 + assert "Music:" in summary 1061 + assert "_and 1 more activities_" not in summary 1062 + 1063 + 1064 + def test_facet_summaries_principal_is_excluded_from_entity_budget( 1065 + tmp_path, 1066 + monkeypatch, 1067 + ): 1068 + """Principal role line does not count against the entity cap.""" 1069 + from think.activities import save_facet_activities 1070 + 1071 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 1072 + write_identity_config(tmp_path) 1073 + setup_facet( 1074 + tmp_path, 1075 + "principal-budget", 1076 + title="Principal Budget", 1077 + description="Principal excluded from cap", 1078 + ) 1079 + setup_entities_new_structure( 1080 + tmp_path, 1081 + "principal-budget", 1082 + [ 1083 + { 1084 + "type": "Person", 1085 + "name": "Test User", 1086 + "description": "Principal role", 1087 + "is_principal": True, 1088 + } 1089 + ] 1090 + + [ 1091 + { 1092 + "type": "Person", 1093 + "name": f"Entity {index:02d}", 1094 + "description": f"Description {index:02d}", 1095 + } 1096 + for index in range(1, 21) 1097 + ], 1098 + ) 1099 + save_facet_activities("principal-budget", [{"id": "meeting"}]) 1100 + 1101 + summary = facet_summaries(detailed=True) 1102 + 1103 + assert "**Tester's Role**: Principal role" in summary 1104 + assert " - Entity 20: Description 20" in summary 1105 + assert "Test User: Principal role" not in summary 1106 + assert "_and 1 more entities_" not in summary
+85
tests/test_prompts_facet_integration.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Integration coverage for prompt-layer facet substitution.""" 5 + 6 + import json 7 + from pathlib import Path 8 + 9 + from slugify import slugify 10 + 11 + from think.prompts import _resolve_facets 12 + 13 + 14 + def setup_entities_new_structure( 15 + journal_path: Path, 16 + facet: str, 17 + entities: list[dict], 18 + ) -> None: 19 + """Create journal-level entity files and facet relationships for tests.""" 20 + for entity in entities: 21 + name = entity.get("name", "") 22 + desc = entity.get("description", "") 23 + entity_id = slugify(name, separator="_") 24 + if not entity_id: 25 + continue 26 + 27 + journal_entity_dir = journal_path / "entities" / entity_id 28 + journal_entity_dir.mkdir(parents=True, exist_ok=True) 29 + (journal_entity_dir / "entity.json").write_text( 30 + json.dumps({"id": entity_id, "name": name, "type": entity.get("type", "")}), 31 + encoding="utf-8", 32 + ) 33 + 34 + facet_entity_dir = journal_path / "facets" / facet / "entities" / entity_id 35 + facet_entity_dir.mkdir(parents=True, exist_ok=True) 36 + (facet_entity_dir / "entity.json").write_text( 37 + json.dumps({"entity_id": entity_id, "description": desc}), 38 + encoding="utf-8", 39 + ) 40 + (facet_entity_dir / "observations.jsonl").write_text( 41 + json.dumps( 42 + { 43 + "content": f"Observed {name}", 44 + "observed_at": "2026-04-20T12:00:00Z", 45 + "source_day": "20260420", 46 + } 47 + ) 48 + + "\n", 49 + encoding="utf-8", 50 + ) 51 + 52 + 53 + def test_resolve_facets_none_uses_capped_facet_summaries(tmp_path, monkeypatch): 54 + """The prompt-layer $facets resolver uses capped facet_summaries output.""" 55 + config_dir = tmp_path / "config" 56 + config_dir.mkdir(parents=True) 57 + (config_dir / "journal.json").write_text( 58 + json.dumps({"identity": {"name": "Test User", "preferred": "Tester"}}), 59 + encoding="utf-8", 60 + ) 61 + facet_dir = tmp_path / "facets" / "capped" 62 + facet_dir.mkdir(parents=True) 63 + (facet_dir / "facet.json").write_text( 64 + json.dumps({"title": "Capped Facet", "description": "Prompt integration"}), 65 + encoding="utf-8", 66 + ) 67 + setup_entities_new_structure( 68 + tmp_path, 69 + "capped", 70 + [ 71 + { 72 + "type": "Person", 73 + "name": f"Entity {index:02d}", 74 + "description": f"Description {index:02d}", 75 + } 76 + for index in range(1, 26) 77 + ], 78 + ) 79 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 80 + 81 + resolved = _resolve_facets(None) 82 + 83 + assert "## Available Facets" in resolved 84 + assert "**Capped Facet** (`capped`)" in resolved 85 + assert " - _and 5 more entities_" in resolved
+106 -29
think/facets.py
··· 112 112 return name_part 113 113 114 114 115 + def _rank_entities_by_signal( 116 + facet: str, 117 + entities: list[dict[str, Any]], 118 + ) -> list[dict[str, Any]]: 119 + """Return entities ranked by observation count, recency, and name.""" 120 + from think.entities import load_observations 121 + 122 + ranked_items: list[tuple[int, str, str, dict[str, Any]]] = [] 123 + for entity in entities: 124 + name = entity.get("name", "") 125 + observations = load_observations(facet, name) 126 + observation_count = len(observations) 127 + last_observed = max( 128 + ( 129 + observation.get("observed_at") 130 + for observation in observations 131 + if observation.get("observed_at") 132 + ), 133 + default=None, 134 + ) 135 + last_observed_sort = "" if last_observed is None else str(last_observed) 136 + ranked_items.append( 137 + ( 138 + observation_count, 139 + last_observed_sort, 140 + name.casefold(), 141 + entity, 142 + ) 143 + ) 144 + 145 + ranked_items.sort(key=lambda item: item[2]) 146 + ranked_items.sort(key=lambda item: item[1], reverse=True) 147 + ranked_items.sort(key=lambda item: item[0], reverse=True) 148 + return [entity for _count, _last_observed, _name, entity in ranked_items] 149 + 150 + 115 151 def _write_action_log( 116 152 facet: str | None, 117 153 action: str, ··· 871 907 shutil.rmtree(facet_path) 872 908 873 909 874 - def facet_summaries(*, detailed: bool = False) -> str: 910 + def facet_summaries( 911 + *, 912 + detailed: bool = False, 913 + max_entities_per_facet: int | None = 20, 914 + max_activities_per_facet: int | None = 15, 915 + ) -> str: 875 916 """Generate a formatted list summary of enabled (non-muted) facets for use in agent prompts. 876 917 877 918 Returns a markdown-formatted string with each facet as a list item including: ··· 885 926 detailed: 886 927 If True, includes full entity and activity details (name: description). 887 928 If False (default), includes only names as semicolon-separated lists. 929 + max_entities_per_facet: 930 + Maximum entities to render per facet; defaults to 20, or None for no cap. 931 + max_activities_per_facet: 932 + Maximum activities to render per facet; defaults to 15, or None for no cap. 888 933 889 934 Returns 890 935 ------- ··· 914 959 915 960 # Load entities for this facet 916 961 try: 917 - if detailed: 918 - entities = load_entities(facet_name) 919 - if entities: 920 - # Extract principal role and filter from list 921 - role_line, display_entities = _format_principal_role(entities) 962 + entities = load_entities(facet_name) 963 + if entities: 964 + role_line, remaining_entities = _format_principal_role(entities) 965 + ranked_entities = _rank_entities_by_signal( 966 + facet_name, 967 + remaining_entities, 968 + ) 969 + if ( 970 + max_entities_per_facet is not None 971 + and len(ranked_entities) > max_entities_per_facet 972 + ): 973 + shown_entities = ranked_entities[:max_entities_per_facet] 974 + entity_overflow = len(ranked_entities) - max_entities_per_facet 975 + else: 976 + shown_entities = ranked_entities 977 + entity_overflow = 0 922 978 923 - if role_line: 924 - lines.append(f" - {role_line}") 979 + if role_line: 980 + lines.append(f" - {role_line}") 925 981 926 - if display_entities: 982 + if shown_entities: 983 + if detailed: 927 984 lines.append(f" - **{title} Entities**:") 928 - for entity in display_entities: 985 + for entity in shown_entities: 929 986 formatted_name = _format_entity_name_with_aka(entity) 930 987 desc = entity.get("description", "") 931 988 ··· 933 990 lines.append(f" - {formatted_name}: {desc}") 934 991 else: 935 992 lines.append(f" - {formatted_name}") 936 - else: 937 - # Simple mode: load entities, filter principal, show names only 938 - entities = load_entities(facet_name) 939 - if entities: 940 - role_line, display_entities = _format_principal_role(entities) 941 993 942 - if role_line: 943 - lines.append(f" - {role_line}") 994 + if entity_overflow: 995 + lines.append(f" - _and {entity_overflow} more entities_") 996 + else: 997 + if entity_overflow: 998 + lines.append(f" - **{title} Entities**:") 999 + for entity in shown_entities: 1000 + lines.append(f" - {entity.get('name', '')}") 1001 + lines.append(f" - _and {entity_overflow} more entities_") 1002 + else: 1003 + entity_names = "; ".join( 1004 + entity.get("name", "") for entity in shown_entities 1005 + ) 1006 + lines.append(f" - **{title} Entities**: {entity_names}") 944 1007 945 - if display_entities: 946 - # Build semicolon-separated names list 947 - entity_names = "; ".join( 948 - e.get("name", "") for e in display_entities 949 - ) 950 - lines.append(f" - **{title} Entities**: {entity_names}") 951 1008 except Exception: 952 1009 # No entities file or error loading - that's fine, skip it 953 1010 pass ··· 956 1013 try: 957 1014 activities = get_facet_activities(facet_name) 958 1015 if activities: 1016 + if ( 1017 + max_activities_per_facet is not None 1018 + and len(activities) > max_activities_per_facet 1019 + ): 1020 + shown_activities = activities[:max_activities_per_facet] 1021 + activity_overflow = len(activities) - max_activities_per_facet 1022 + else: 1023 + shown_activities = activities 1024 + activity_overflow = 0 1025 + 959 1026 if detailed: 960 1027 lines.append(f" - **{title} Activities**:") 961 - for activity in activities: 1028 + for activity in shown_activities: 962 1029 lines.append( 963 1030 f" - {_format_activity_line(activity, bold_name=False)}" 964 1031 ) 1032 + if activity_overflow: 1033 + lines.append(f" - _and {activity_overflow} more activities_") 965 1034 else: 966 - # Simple mode: activity names only 967 - activity_names = "; ".join( 968 - a.get("name", a.get("id", "")) for a in activities 969 - ) 970 - lines.append(f" - **{title} Activities**: {activity_names}") 1035 + if activity_overflow: 1036 + lines.append(f" - **{title} Activities**:") 1037 + for activity in shown_activities: 1038 + lines.append( 1039 + f" - {activity.get('name', activity.get('id', ''))}" 1040 + ) 1041 + lines.append(f" - _and {activity_overflow} more activities_") 1042 + else: 1043 + activity_names = "; ".join( 1044 + activity.get("name", activity.get("id", "")) 1045 + for activity in shown_activities 1046 + ) 1047 + lines.append(f" - **{title} Activities**: {activity_names}") 971 1048 except Exception: 972 1049 # No activities file or error loading - that's fine, skip it 973 1050 pass