diff --git a/tests/baselines/api/sol/preview.json b/tests/baselines/api/sol/preview.json index 6c8039513..56b5d08ea 100644 --- a/tests/baselines/api/sol/preview.json +++ b/tests/baselines/api/sol/preview.json @@ -1,5 +1,5 @@ { - "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.", + "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.", "multi_facet": false, "name": "chat", "title": "Chat" diff --git a/tests/test_facets.py b/tests/test_facets.py index 0124aadfd..4adec97b9 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -12,6 +12,7 @@ from slugify import slugify from think.facets import ( _format_principal_role, _get_principal_display_name, + _rank_entities_by_signal, facet_summaries, facet_summary, get_active_facets, @@ -63,6 +64,64 @@ def setup_entities_new_structure( json.dump(relationship, f) +def setup_facet( + journal_path: Path, + facet: str, + *, + title: str | None = None, + description: str = "", +) -> Path: + """Create a facet directory with minimal metadata for tests.""" + facet_dir = journal_path / "facets" / facet + facet_dir.mkdir(parents=True, exist_ok=True) + facet_data = {"title": title or facet.replace("-", " ").title()} + if description: + facet_data["description"] = description + (facet_dir / "facet.json").write_text(json.dumps(facet_data), encoding="utf-8") + return facet_dir + + +def write_identity_config( + journal_path: Path, + *, + name: str = "Test User", + preferred: str = "Tester", +) -> None: + """Write a minimal journal identity config for principal tests.""" + config_dir = journal_path / "config" + config_dir.mkdir(parents=True, exist_ok=True) + config = {"identity": {"name": name, "preferred": preferred}} + (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") + + +def write_observations( + journal_path: Path, + facet: str, + entity_name: str, + observed_at_values: list[object], +) -> None: + """Write observations.jsonl records for a test entity.""" + entity_id = slugify(entity_name, separator="_") + observations_path = ( + journal_path / "facets" / facet / "entities" / entity_id / "observations.jsonl" + ) + observations_path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + json.dumps( + { + "content": f"Observation {index}", + "observed_at": observed_at, + "source_day": "20260420", + } + ) + for index, observed_at in enumerate(observed_at_values, 1) + ] + observations_path.write_text( + "\n".join(lines) + ("\n" if lines else ""), + encoding="utf-8", + ) + + def test_facet_summary_full(monkeypatch): """Test facet_summary with full metadata.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(FIXTURES_PATH)) @@ -739,3 +798,309 @@ def test_facet_summaries_detailed_with_activities(monkeypatch): assert "Coding:" in summary assert "Custom Activity:" in summary assert "A custom test activity" in summary + + +def test_rank_entities_by_signal_orders_by_count_then_last_observed( + tmp_path, + monkeypatch, +): + """Rank entities by observation count, then recency, then name.""" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet(tmp_path, "signals", title="Signals") + entities = [ + {"type": "Person", "name": "Alpha", "description": "A"}, + {"type": "Person", "name": "Beta", "description": "B"}, + {"type": "Person", "name": "Gamma", "description": "C"}, + {"type": "Person", "name": "Delta", "description": "D"}, + ] + setup_entities_new_structure(tmp_path, "signals", entities) + write_observations( + tmp_path, + "signals", + "Alpha", + ["2026-04-01T10:00:00Z", "2026-04-01T11:00:00Z", "2026-04-01T12:00:00Z"], + ) + write_observations( + tmp_path, + "signals", + "Beta", + ["2026-04-15T10:00:00Z", "2026-04-15T11:00:00Z", "2026-04-15T12:00:00Z"], + ) + write_observations(tmp_path, "signals", "Gamma", ["2026-04-10T09:00:00Z"]) + + ranked = _rank_entities_by_signal("signals", entities) + + assert [entity["name"] for entity in ranked] == ["Beta", "Alpha", "Gamma", "Delta"] + + +def test_rank_entities_by_signal_uses_casefold_name_tiebreaker(tmp_path, monkeypatch): + """Identical signals fall back to case-insensitive name ordering.""" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet(tmp_path, "signals", title="Signals") + entities = [ + {"type": "Person", "name": "bravo", "description": "B"}, + {"type": "Person", "name": "Alpha", "description": "A"}, + ] + setup_entities_new_structure(tmp_path, "signals", entities) + observed = ["2026-04-15T12:00:00Z", "2026-04-15T13:00:00Z"] + write_observations(tmp_path, "signals", "bravo", observed) + write_observations(tmp_path, "signals", "Alpha", observed) + + ranked = _rank_entities_by_signal("signals", entities) + + assert [entity["name"] for entity in ranked] == ["Alpha", "bravo"] + + +def test_facet_summaries_detailed_entity_cap_appends_trailing_bullet( + tmp_path, + monkeypatch, +): + """Detailed mode caps entities and appends the trailing bullet.""" + from think.activities import save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "entity-cap", + title="Entity Cap", + description="Detailed entity cap", + ) + setup_entities_new_structure( + tmp_path, + "entity-cap", + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 26) + ], + ) + save_facet_activities("entity-cap", [{"id": "meeting"}, {"id": "coding"}]) + + summary = facet_summaries(detailed=True) + + assert " - Entity 01: Description 01" in summary + assert " - Entity 20: Description 20" in summary + assert " - _and 5 more entities_" in summary + assert "Entity 21: Description 21" not in summary + assert "_and 1 more activities_" not in summary + + +def test_facet_summaries_detailed_activity_cap_appends_trailing_bullet( + tmp_path, + monkeypatch, +): + """Detailed mode caps activities and appends the trailing bullet.""" + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "activity-cap", + title="Activity Cap", + description="Detailed activity cap", + ) + setup_entities_new_structure( + tmp_path, + "activity-cap", + [ + {"type": "Person", "name": "Alice", "description": "Lead"}, + {"type": "Person", "name": "Bob", "description": "Partner"}, + ], + ) + save_facet_activities( + "activity-cap", + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES], + ) + + summary = facet_summaries(detailed=True) + + assert " - Meetings" in summary + assert " - Design:" in summary + assert " - _and 1 more activities_" in summary + assert " - Music:" not in summary + assert "_and 1 more entities_" not in summary + + +def test_facet_summaries_simple_cap_trips_switches_capped_sections_to_bullets( + tmp_path, + monkeypatch, +): + """Simple mode only switches capped sections to bullet lists.""" + from think.activities import save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "simple-cap", + title="Simple Cap", + description="Simple cap formatting", + ) + setup_entities_new_structure( + tmp_path, + "simple-cap", + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 26) + ], + ) + save_facet_activities("simple-cap", [{"id": "meeting"}, {"id": "coding"}]) + + summary = facet_summaries(detailed=False) + + assert " - **Simple Cap Entities**:\n - Entity 01" in summary + assert " - _and 5 more entities_" in summary + assert " - **Simple Cap Activities**: Meetings; Coding" in summary + assert " - **Simple Cap Entities**: Entity 01; Entity 02" not in summary + assert " - _and 1 more activities_" not in summary + + +def test_facet_summaries_exactly_at_caps_has_no_trailing_bullets( + tmp_path, + monkeypatch, +): + """Exactly-at-cap output stays uncapped and keeps simple one-line formatting.""" + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "exact-cap", + title="Exact Cap", + description="Exactly at cap", + ) + setup_entities_new_structure( + tmp_path, + "exact-cap", + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 21) + ], + ) + save_facet_activities( + "exact-cap", + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES[:15]], + ) + + detailed_summary = facet_summaries(detailed=True) + simple_summary = facet_summaries(detailed=False) + + assert "_and 0 more entities_" not in detailed_summary + assert "_and 0 more activities_" not in detailed_summary + assert "_and 0 more entities_" not in simple_summary + assert "_and 0 more activities_" not in simple_summary + assert " - **Exact Cap Entities**: Entity 01; Entity 02" in simple_summary + assert " - **Exact Cap Activities**: Meetings; Coding" in simple_summary + + +def test_facet_summaries_none_entity_cap_is_unbounded(tmp_path, monkeypatch): + """None entity cap restores the full entity list.""" + from think.activities import save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "entity-unbounded", + title="Entity Unbounded", + description="No entity cap", + ) + setup_entities_new_structure( + tmp_path, + "entity-unbounded", + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 26) + ], + ) + save_facet_activities("entity-unbounded", [{"id": "meeting"}]) + + summary = facet_summaries(detailed=True, max_entities_per_facet=None) + + assert "Entity 25: Description 25" in summary + assert "_and 5 more entities_" not in summary + + +def test_facet_summaries_none_activity_cap_is_unbounded(tmp_path, monkeypatch): + """None activity cap restores the full activity list.""" + from think.activities import DEFAULT_ACTIVITIES, save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + setup_facet( + tmp_path, + "activity-unbounded", + title="Activity Unbounded", + description="No activity cap", + ) + setup_entities_new_structure( + tmp_path, + "activity-unbounded", + [{"type": "Person", "name": "Alice", "description": "Lead"}], + ) + save_facet_activities( + "activity-unbounded", + [{"id": activity["id"]} for activity in DEFAULT_ACTIVITIES], + ) + + summary = facet_summaries(detailed=True, max_activities_per_facet=None) + + assert "Music:" in summary + assert "_and 1 more activities_" not in summary + + +def test_facet_summaries_principal_is_excluded_from_entity_budget( + tmp_path, + monkeypatch, +): + """Principal role line does not count against the entity cap.""" + from think.activities import save_facet_activities + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + write_identity_config(tmp_path) + setup_facet( + tmp_path, + "principal-budget", + title="Principal Budget", + description="Principal excluded from cap", + ) + setup_entities_new_structure( + tmp_path, + "principal-budget", + [ + { + "type": "Person", + "name": "Test User", + "description": "Principal role", + "is_principal": True, + } + ] + + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 21) + ], + ) + save_facet_activities("principal-budget", [{"id": "meeting"}]) + + summary = facet_summaries(detailed=True) + + assert "**Tester's Role**: Principal role" in summary + assert " - Entity 20: Description 20" in summary + assert "Test User: Principal role" not in summary + assert "_and 1 more entities_" not in summary diff --git a/tests/test_prompts_facet_integration.py b/tests/test_prompts_facet_integration.py new file mode 100644 index 000000000..888525e6c --- /dev/null +++ b/tests/test_prompts_facet_integration.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Integration coverage for prompt-layer facet substitution.""" + +import json +from pathlib import Path + +from slugify import slugify + +from think.prompts import _resolve_facets + + +def setup_entities_new_structure( + journal_path: Path, + facet: str, + entities: list[dict], +) -> None: + """Create journal-level entity files and facet relationships for tests.""" + for entity in entities: + name = entity.get("name", "") + desc = entity.get("description", "") + entity_id = slugify(name, separator="_") + if not entity_id: + continue + + journal_entity_dir = journal_path / "entities" / entity_id + journal_entity_dir.mkdir(parents=True, exist_ok=True) + (journal_entity_dir / "entity.json").write_text( + json.dumps({"id": entity_id, "name": name, "type": entity.get("type", "")}), + encoding="utf-8", + ) + + facet_entity_dir = journal_path / "facets" / facet / "entities" / entity_id + facet_entity_dir.mkdir(parents=True, exist_ok=True) + (facet_entity_dir / "entity.json").write_text( + json.dumps({"entity_id": entity_id, "description": desc}), + encoding="utf-8", + ) + (facet_entity_dir / "observations.jsonl").write_text( + json.dumps( + { + "content": f"Observed {name}", + "observed_at": "2026-04-20T12:00:00Z", + "source_day": "20260420", + } + ) + + "\n", + encoding="utf-8", + ) + + +def test_resolve_facets_none_uses_capped_facet_summaries(tmp_path, monkeypatch): + """The prompt-layer $facets resolver uses capped facet_summaries output.""" + config_dir = tmp_path / "config" + config_dir.mkdir(parents=True) + (config_dir / "journal.json").write_text( + json.dumps({"identity": {"name": "Test User", "preferred": "Tester"}}), + encoding="utf-8", + ) + facet_dir = tmp_path / "facets" / "capped" + facet_dir.mkdir(parents=True) + (facet_dir / "facet.json").write_text( + json.dumps({"title": "Capped Facet", "description": "Prompt integration"}), + encoding="utf-8", + ) + setup_entities_new_structure( + tmp_path, + "capped", + [ + { + "type": "Person", + "name": f"Entity {index:02d}", + "description": f"Description {index:02d}", + } + for index in range(1, 26) + ], + ) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + resolved = _resolve_facets(None) + + assert "## Available Facets" in resolved + assert "**Capped Facet** (`capped`)" in resolved + assert " - _and 5 more entities_" in resolved diff --git a/think/facets.py b/think/facets.py index 52e472f85..a71c5b7be 100644 --- a/think/facets.py +++ b/think/facets.py @@ -112,6 +112,42 @@ def _format_activity_line(activity: dict[str, Any], *, bold_name: bool = False) return name_part +def _rank_entities_by_signal( + facet: str, + entities: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Return entities ranked by observation count, recency, and name.""" + from think.entities import load_observations + + ranked_items: list[tuple[int, str, str, dict[str, Any]]] = [] + for entity in entities: + name = entity.get("name", "") + observations = load_observations(facet, name) + observation_count = len(observations) + last_observed = max( + ( + observation.get("observed_at") + for observation in observations + if observation.get("observed_at") + ), + default=None, + ) + last_observed_sort = "" if last_observed is None else str(last_observed) + ranked_items.append( + ( + observation_count, + last_observed_sort, + name.casefold(), + entity, + ) + ) + + ranked_items.sort(key=lambda item: item[2]) + ranked_items.sort(key=lambda item: item[1], reverse=True) + ranked_items.sort(key=lambda item: item[0], reverse=True) + return [entity for _count, _last_observed, _name, entity in ranked_items] + + def _write_action_log( facet: str | None, action: str, @@ -871,7 +907,12 @@ def delete_facet(name: str, *, consent: bool = False) -> None: shutil.rmtree(facet_path) -def facet_summaries(*, detailed: bool = False) -> str: +def facet_summaries( + *, + detailed: bool = False, + max_entities_per_facet: int | None = 20, + max_activities_per_facet: int | None = 15, +) -> str: """Generate a formatted list summary of enabled (non-muted) facets for use in agent prompts. Returns a markdown-formatted string with each facet as a list item including: @@ -885,6 +926,10 @@ def facet_summaries(*, detailed: bool = False) -> str: detailed: If True, includes full entity and activity details (name: description). If False (default), includes only names as semicolon-separated lists. + max_entities_per_facet: + Maximum entities to render per facet; defaults to 20, or None for no cap. + max_activities_per_facet: + Maximum activities to render per facet; defaults to 15, or None for no cap. Returns ------- @@ -914,18 +959,30 @@ def facet_summaries(*, detailed: bool = False) -> str: # Load entities for this facet try: - if detailed: - entities = load_entities(facet_name) - if entities: - # Extract principal role and filter from list - role_line, display_entities = _format_principal_role(entities) + entities = load_entities(facet_name) + if entities: + role_line, remaining_entities = _format_principal_role(entities) + ranked_entities = _rank_entities_by_signal( + facet_name, + remaining_entities, + ) + if ( + max_entities_per_facet is not None + and len(ranked_entities) > max_entities_per_facet + ): + shown_entities = ranked_entities[:max_entities_per_facet] + entity_overflow = len(ranked_entities) - max_entities_per_facet + else: + shown_entities = ranked_entities + entity_overflow = 0 - if role_line: - lines.append(f" - {role_line}") + if role_line: + lines.append(f" - {role_line}") - if display_entities: + if shown_entities: + if detailed: lines.append(f" - **{title} Entities**:") - for entity in display_entities: + for entity in shown_entities: formatted_name = _format_entity_name_with_aka(entity) desc = entity.get("description", "") @@ -933,21 +990,21 @@ def facet_summaries(*, detailed: bool = False) -> str: lines.append(f" - {formatted_name}: {desc}") else: lines.append(f" - {formatted_name}") - else: - # Simple mode: load entities, filter principal, show names only - entities = load_entities(facet_name) - if entities: - role_line, display_entities = _format_principal_role(entities) - - if role_line: - lines.append(f" - {role_line}") - - if display_entities: - # Build semicolon-separated names list - entity_names = "; ".join( - e.get("name", "") for e in display_entities - ) - lines.append(f" - **{title} Entities**: {entity_names}") + + if entity_overflow: + lines.append(f" - _and {entity_overflow} more entities_") + else: + if entity_overflow: + lines.append(f" - **{title} Entities**:") + for entity in shown_entities: + lines.append(f" - {entity.get('name', '')}") + lines.append(f" - _and {entity_overflow} more entities_") + else: + entity_names = "; ".join( + entity.get("name", "") for entity in shown_entities + ) + lines.append(f" - **{title} Entities**: {entity_names}") + except Exception: # No entities file or error loading - that's fine, skip it pass @@ -956,18 +1013,38 @@ def facet_summaries(*, detailed: bool = False) -> str: try: activities = get_facet_activities(facet_name) if activities: + if ( + max_activities_per_facet is not None + and len(activities) > max_activities_per_facet + ): + shown_activities = activities[:max_activities_per_facet] + activity_overflow = len(activities) - max_activities_per_facet + else: + shown_activities = activities + activity_overflow = 0 + if detailed: lines.append(f" - **{title} Activities**:") - for activity in activities: + for activity in shown_activities: lines.append( f" - {_format_activity_line(activity, bold_name=False)}" ) + if activity_overflow: + lines.append(f" - _and {activity_overflow} more activities_") else: - # Simple mode: activity names only - activity_names = "; ".join( - a.get("name", a.get("id", "")) for a in activities - ) - lines.append(f" - **{title} Activities**: {activity_names}") + if activity_overflow: + lines.append(f" - **{title} Activities**:") + for activity in shown_activities: + lines.append( + f" - {activity.get('name', activity.get('id', ''))}" + ) + lines.append(f" - _and {activity_overflow} more activities_") + else: + activity_names = "; ".join( + activity.get("name", activity.get("id", "")) + for activity in shown_activities + ) + lines.append(f" - **{title} Activities**: {activity_names}") except Exception: # No activities file or error loading - that's fine, skip it pass