personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-juoyubxl-observer-generate'

+583 -74
+35 -74
apps/entities/talent/entity_observer.md
··· 1 1 { 2 - "type": "cogitate", 3 - "tier": 3, 2 + "type": "generate", 3 + "tier": 2, 4 4 5 5 "title": "Entity Observer", 6 6 "description": "Extracts durable factoids about attached entities from journal content", ··· 8 8 "schedule": "daily", 9 9 "priority": 57, 10 10 "multi_facet": true, 11 - "group": "Entities" 11 + "group": "Entities", 12 + "output": "json", 13 + "thinking_budget": 2048, 14 + "hook": {"pre": "entities:entity_observer", "post": "entities:entity_observer"}, 15 + "load": {"transcripts": false, "percepts": false, "agents": false} 12 16 } 13 - 14 - $sol_identity 15 - 16 - $facets 17 17 18 18 ## Core Mission 19 19 20 - Extract durable factoids about attached entities from recent journal content within this specific facet. Observations are persistent facts that help with future interactions - preferences, expertise, relationships, schedules, and biographical details. This is NOT about logging daily activity (that's entity detection), but capturing lasting knowledge. 20 + Extract durable factoids about attached entities from recent journal content. Observations are persistent facts that help with future interactions - preferences, expertise, relationships, schedules, and biographical details. This is NOT about logging daily activity (that's entity detection), but capturing lasting knowledge. 21 21 22 - ## Input Context 22 + ## Pre-computed Context 23 23 24 - You receive: 25 - 1. **Facet context** - the specific facet (e.g., "personal", "work") you are observing entities for 26 - 2. **Current date/time** - to focus on recent journal content 27 - 3. **Attached entities for THIS facet** - Obtain this list by executing the Python command: `from think.entities.loading import load_entities; entities = load_entities(SOL_FACET)`. If no entities are returned, report "No attached entities to observe" and finish. 24 + Below you'll find the pre-computed context for this observation run, including: 25 + - Active entities (those that appeared in today's content) 26 + - Recent observations for each entity (last 3) 27 + - Relevant knowledge graph content 28 28 29 - ## Tooling 30 - 31 - SOL_DAY and SOL_FACET are set in your environment. When performing actions, use the following Python calls: 32 - 33 - - **List Entities:** Execute: `from think.entities.loading import load_entities; entities = load_entities(SOL_FACET)` 34 - - The result will be a list of entities. 35 - - **Read Current Observations:** Execute: `from think.entities.observations import load_observations; observations = load_observations(SOL_FACET, entity_id)` 36 - - **MUST execute this before adding observations.** Note the `count` for guard awareness. 37 - - The `entity_id` can be an entity ID, full name, or alias. 38 - - **Add New Observation:** Execute: `from think.entities.observations import add_observation; add_observation(SOL_FACET, entity_id, content, SOL_DAY)` 39 - - This adds an observation with guard (observation number auto-calculated). 40 - - Use entity_id from the `load_observations` response for consistency. 41 - 42 - Discovery tools: 43 - - `sol call journal read AGENT` - read full agent output (e.g., knowledge_graph, followups) 44 - - `sol call journal search QUERY -d DAY -a AGENT -f FACET -n LIMIT` - unified search across journal content 45 - - `sol call journal events [-f FACET]` - get structured events 29 + $observer_context 46 30 47 31 ## What Makes a Good Observation 48 32 ··· 79 63 - **Projects**: Architecture decisions, design principles, known constraints, key technical learnings. NOT commit logs or deployment activity. 80 64 - **Tools**: Capabilities, limitations, best-practice configurations. NOT "was used for X on Y" — that's a usage log, not a fact about the tool. 81 65 82 - ## Observation Process 66 + ## Output Format 83 67 84 - ### Phase 1: Load Context 68 + Respond with a JSON object in this exact format: 85 69 86 - 1. Use the provided current date and analysis day in YYYYMMDD format 87 - 2. Execute Python: `from think.entities.loading import load_entities; entities = load_entities(SOL_FACET)` 88 - 3. If no attached entities, report "No attached entities to observe" and finish 89 - 90 - ### Phase 2: Identify Active Entities 91 - 92 - Before deep-mining every entity, scan the day's content to find which entities actually appeared: 93 - 94 - 1. Check knowledge graph: `sol call journal read knowledge_graph` 95 - 2. Check events: `sol call journal events -f FACET` 96 - 3. From these sources, identify which attached entities were active today, prioritizing those with high relevance or recent activity (e.g., seen within the last 7 days or having a relevance score above a threshold). 97 - 4. Focus your deep mining (Phase 3) on entities that appeared in today's content 98 - 5. For entities NOT mentioned today, skip — no content means no new observations 99 - 100 - This is especially important for large facets (50+ entities). Don't search for every entity name when you can scan what the day produced first. 101 - 102 - ### Phase 3: Mine and Observe Active Entities 103 - 104 - For each entity that appeared in today's content: 105 - 106 - 1. **Read current observations** (REQUIRED - guard mechanism): 107 - Execute Python: `from think.entities.observations import load_observations; observations = load_observations(SOL_FACET, entity_id)` 108 - Note the `count` for guard awareness. 109 - 110 - 2. **Mine recent content** for factoids about this entity: 111 - - Search transcripts: `sol call journal search "{name}" -a audio -n 5` 112 - - Search insights: `sol call journal search "{name}" -n 5` 70 + ```json 71 + { 72 + "observations": { 73 + "entity_slug": [ 74 + {"content": "The durable observation text", "reasoning": "Why this qualifies (1 sentence)"} 75 + ] 76 + }, 77 + "skipped": ["entity_ids_examined_but_no_new_observations"], 78 + "summary": "Observed X entities, Y new observations total." 79 + } 80 + ``` 113 81 114 - 3. **Extract and filter observations**: 115 - - Apply the litmus test (both questions must be yes) 116 - - Apply the entity-type strategy (people = who they are, projects = design decisions, etc.) 117 - - Check for semantic duplicates against existing observations (see Deduplication below) 118 - - One fact per observation — no compound sentences 119 - 120 - 4. **Add new observations** (one at a time; guard handled by CLI): 121 - Execute Python: `from think.entities.observations import add_observation; add_observation(SOL_FACET, entity_id, content, SOL_DAY)` 122 - 123 - ### Phase 4: Report Summary 124 - 125 - Summarize what was observed: 126 - - "Observed 3 entities for [facet]: Alice (2 new observations), Bob (1 new observation), Acme Corp (0 - nothing new)" 127 - 128 - Remember: Your goal is to build a curated knowledge base of the most important facts about entities — not a comprehensive activity log. Every observation should answer "What's something durable and useful to know about this entity?" not "What happened with them today?" When the knowledge base is already rich, restraint is the right call. 82 + Rules: 83 + - Use the entity_id (slug) from the context as the key 84 + - One fact per observation — no compound sentences 85 + - Check for semantic duplicates against the existing observations shown in context 86 + - If existing observations are already rich, zero new observations is valid and correct 87 + - The `reasoning` field is for audit only 88 + - Include ALL examined entities in either `observations` or `skipped` 89 + - Empty observations dict is valid when nothing new is found
+80
apps/entities/talent/entity_observer.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Entity observer talent hook — pre-computes context and persists observations. 5 + 6 + pre_process: Assembles entity context (attached entities, recent observations, 7 + KG excerpts) and injects it as $observer_context template variable. 8 + 9 + post_process: Parses JSON output, validates entity_ids against attached entities, 10 + and persists valid observations via add_observation(). 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import json 16 + import logging 17 + 18 + from think.entities.context import assemble_observer_context 19 + from think.entities.loading import load_entities 20 + from think.entities.observations import add_observation, load_observations 21 + 22 + logger = logging.getLogger(__name__) 23 + 24 + 25 + def pre_process(context: dict) -> dict | None: 26 + facet = context.get("facet") 27 + day = context.get("day") 28 + if not facet or not day: 29 + return None 30 + 31 + observer_context = assemble_observer_context(facet, day) 32 + return {"template_vars": {"observer_context": observer_context}} 33 + 34 + 35 + def post_process(result: str, context: dict) -> str | None: 36 + facet = context.get("facet") 37 + day = context.get("day") 38 + if not facet or not day: 39 + return None 40 + 41 + try: 42 + data = json.loads(result) 43 + except json.JSONDecodeError: 44 + logger.warning("entity_observer: could not parse result as JSON") 45 + return None 46 + 47 + if not isinstance(data, dict): 48 + return None 49 + 50 + observations = data.get("observations") 51 + if not isinstance(observations, dict) or not observations: 52 + return None 53 + 54 + valid_entity_ids = {entity.get("id") for entity in load_entities(facet) if entity.get("id")} 55 + 56 + for entity_id, items in observations.items(): 57 + if entity_id not in valid_entity_ids: 58 + logger.debug("Skipping unrecognized entity_id: %s", entity_id) 59 + continue 60 + if not isinstance(items, list): 61 + continue 62 + 63 + existing = { 64 + obs.get("content", "").strip().lower() 65 + for obs in load_observations(facet, entity_id) 66 + } 67 + 68 + for item in items: 69 + if not isinstance(item, dict): 70 + continue 71 + content = str(item.get("content", "")).strip() 72 + if not content: 73 + continue 74 + if content.lower() in existing: 75 + logger.debug("Skipping duplicate observation for %s: %s", entity_id, content[:60]) 76 + continue 77 + add_observation(facet, entity_id, content, day) 78 + existing.add(content.lower()) 79 + 80 + return None
+339
tests/test_entity_observer_context.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import os 8 + from pathlib import Path 9 + 10 + import pytest 11 + 12 + from apps.entities.talent.entity_observer import post_process, pre_process 13 + from think.entities.context import assemble_observer_context 14 + from think.entities.journal import clear_journal_entity_cache 15 + from think.entities.loading import clear_entity_loading_cache 16 + from think.entities.observations import clear_observation_cache, load_observations 17 + from think.entities.relationships import clear_relationship_caches 18 + from think.talent import get_agent 19 + 20 + 21 + def _clear_entity_caches() -> None: 22 + clear_entity_loading_cache() 23 + clear_observation_cache() 24 + clear_relationship_caches() 25 + clear_journal_entity_cache() 26 + 27 + 28 + @pytest.fixture(autouse=True) 29 + def _clean_journal_env(): 30 + old = os.environ.get("_SOLSTONE_JOURNAL_OVERRIDE") 31 + yield 32 + _clear_entity_caches() 33 + if old is None: 34 + os.environ.pop("_SOLSTONE_JOURNAL_OVERRIDE", None) 35 + else: 36 + os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = old 37 + 38 + 39 + def _set_journal(path: str) -> None: 40 + os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = path 41 + _clear_entity_caches() 42 + 43 + 44 + def _write_json(path: Path, data: dict) -> None: 45 + path.parent.mkdir(parents=True, exist_ok=True) 46 + path.write_text(json.dumps(data), encoding="utf-8") 47 + 48 + 49 + def _write_jsonl(path: Path, rows: list[dict]) -> None: 50 + path.parent.mkdir(parents=True, exist_ok=True) 51 + path.write_text( 52 + "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), 53 + encoding="utf-8", 54 + ) 55 + 56 + 57 + def _attach_entity( 58 + root: Path, 59 + facet: str, 60 + entity_id: str, 61 + name: str, 62 + entity_type: str = "Person", 63 + description: str = "Attached entity", 64 + ) -> None: 65 + _write_json( 66 + root / "entities" / entity_id / "entity.json", 67 + {"id": entity_id, "name": name, "type": entity_type}, 68 + ) 69 + _write_json( 70 + root / "facets" / facet / "entities" / entity_id / "entity.json", 71 + {"entity_id": entity_id, "description": description}, 72 + ) 73 + 74 + 75 + def _obs_path(facet: str, entity_id: str) -> Path: 76 + return Path("facets") / facet / "entities" / entity_id / "observations.jsonl" 77 + 78 + 79 + # ============================================================================ 80 + # Context assembly tests 81 + # ============================================================================ 82 + 83 + 84 + def test_assemble_observer_context_with_fixture_data(): 85 + _set_journal("tests/fixtures/journal") 86 + 87 + result = assemble_observer_context("capulet", "20260304") 88 + 89 + assert result 90 + assert "Juliet Capulet" in result 91 + assert "Knowledge Graph" in result 92 + assert "Prepared revenue projections for Verona Platform board presentation" in result 93 + 94 + 95 + def test_assemble_observer_context_no_kg(tmp_path): 96 + _set_journal(str(tmp_path)) 97 + facet = "work" 98 + day = "20260304" 99 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 100 + _write_jsonl( 101 + tmp_path / "facets" / facet / "entities" / f"{day}.jsonl", 102 + [ 103 + { 104 + "id": "alice_johnson", 105 + "type": "Person", 106 + "name": "Alice Johnson", 107 + "description": "Detected from activity", 108 + } 109 + ], 110 + ) 111 + 112 + result = assemble_observer_context(facet, day) 113 + 114 + assert result 115 + assert "No knowledge graph available for this day." in result 116 + assert "Alice Johnson" in result 117 + 118 + 119 + def test_assemble_observer_context_no_active_entities(tmp_path): 120 + _set_journal(str(tmp_path)) 121 + _attach_entity(tmp_path, "work", "alice_johnson", "Alice Johnson") 122 + 123 + result = assemble_observer_context("work", "20260304") 124 + 125 + assert "No active entities" in result 126 + 127 + 128 + def test_assemble_observer_context_empty_facet(tmp_path): 129 + _set_journal(str(tmp_path)) 130 + (tmp_path / "facets" / "empty" / "entities").mkdir(parents=True) 131 + 132 + result = assemble_observer_context("empty", "20260304") 133 + 134 + assert "No active entities" in result 135 + 136 + 137 + def test_assemble_observer_context_observations_sliced(tmp_path): 138 + _set_journal(str(tmp_path)) 139 + facet = "work" 140 + day = "20260304" 141 + entity_id = "alice_johnson" 142 + _attach_entity(tmp_path, facet, entity_id, "Alice Johnson") 143 + _write_jsonl( 144 + tmp_path / "facets" / facet / "entities" / f"{day}.jsonl", 145 + [{"id": entity_id, "type": "Person", "name": "Alice Johnson", "description": ""}], 146 + ) 147 + _write_jsonl( 148 + tmp_path / _obs_path(facet, entity_id), 149 + [ 150 + {"content": "Observation 1", "observed_at": 1, "source_day": "20260301"}, 151 + {"content": "Observation 2", "observed_at": 2, "source_day": "20260302"}, 152 + {"content": "Observation 3", "observed_at": 3, "source_day": "20260303"}, 153 + {"content": "Observation 4", "observed_at": 4, "source_day": "20260304"}, 154 + {"content": "Observation 5", "observed_at": 5, "source_day": "20260305"}, 155 + ], 156 + ) 157 + 158 + result = assemble_observer_context(facet, day) 159 + 160 + assert "Observation 1" not in result 161 + assert "Observation 2" not in result 162 + assert result.count("(source: ") == 3 163 + 164 + 165 + # ============================================================================ 166 + # Hook tests 167 + # ============================================================================ 168 + 169 + 170 + def test_pre_process_returns_template_vars(): 171 + _set_journal("tests/fixtures/journal") 172 + 173 + result = pre_process({"facet": "capulet", "day": "20260304"}) 174 + 175 + assert isinstance(result, dict) 176 + assert "template_vars" in result 177 + assert "observer_context" in result["template_vars"] 178 + assert result["template_vars"]["observer_context"] 179 + 180 + 181 + def test_pre_process_missing_facet(): 182 + assert pre_process({"day": "20260304"}) is None 183 + 184 + 185 + def test_pre_process_missing_day(): 186 + assert pre_process({"facet": "work"}) is None 187 + 188 + 189 + def test_post_process_persists_observations(tmp_path): 190 + _set_journal(str(tmp_path)) 191 + facet = "work" 192 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 193 + 194 + result = post_process( 195 + json.dumps( 196 + { 197 + "observations": { 198 + "alice_johnson": [ 199 + { 200 + "content": "Prefers morning meetings", 201 + "reasoning": "Durable preference", 202 + } 203 + ] 204 + }, 205 + "skipped": [], 206 + "summary": "1 entity, 1 observation", 207 + } 208 + ), 209 + {"facet": facet, "day": "20260304"}, 210 + ) 211 + 212 + assert result is None 213 + observations = load_observations(facet, "alice_johnson") 214 + assert [obs["content"] for obs in observations] == ["Prefers morning meetings"] 215 + 216 + 217 + def test_post_process_filters_unrecognized_entity(tmp_path): 218 + _set_journal(str(tmp_path)) 219 + facet = "work" 220 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 221 + 222 + result = post_process( 223 + json.dumps( 224 + { 225 + "observations": { 226 + "alice_johnson": [{"content": "Prefers morning meetings"}], 227 + "unknown_entity": [{"content": "Should be ignored"}], 228 + }, 229 + "skipped": [], 230 + "summary": "2 entities, 1 persisted observation", 231 + } 232 + ), 233 + {"facet": facet, "day": "20260304"}, 234 + ) 235 + 236 + assert result is None 237 + observations = load_observations(facet, "alice_johnson") 238 + assert [obs["content"] for obs in observations] == ["Prefers morning meetings"] 239 + assert load_observations(facet, "unknown_entity") == [] 240 + 241 + 242 + def test_post_process_skips_empty_content(tmp_path): 243 + _set_journal(str(tmp_path)) 244 + facet = "work" 245 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 246 + 247 + post_process( 248 + json.dumps( 249 + { 250 + "observations": { 251 + "alice_johnson": [ 252 + {"content": "", "reasoning": "empty"}, 253 + {"content": " ", "reasoning": "whitespace"}, 254 + {"content": "Valid observation", "reasoning": "ok"}, 255 + ] 256 + }, 257 + "skipped": [], 258 + "summary": "", 259 + } 260 + ), 261 + {"facet": facet, "day": "20260304"}, 262 + ) 263 + 264 + observations = load_observations(facet, "alice_johnson") 265 + assert len(observations) == 1 266 + assert observations[0]["content"] == "Valid observation" 267 + 268 + 269 + def test_post_process_skips_non_list_items(tmp_path): 270 + _set_journal(str(tmp_path)) 271 + facet = "work" 272 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 273 + 274 + post_process( 275 + json.dumps( 276 + { 277 + "observations": {"alice_johnson": "not a list"}, 278 + "skipped": [], 279 + "summary": "", 280 + } 281 + ), 282 + {"facet": facet, "day": "20260304"}, 283 + ) 284 + 285 + assert load_observations(facet, "alice_johnson") == [] 286 + 287 + 288 + def test_post_process_deduplicates_existing(tmp_path): 289 + _set_journal(str(tmp_path)) 290 + facet = "work" 291 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 292 + _write_jsonl( 293 + tmp_path / _obs_path(facet, "alice_johnson"), 294 + [{"content": "Prefers morning meetings", "observed_at": 1}], 295 + ) 296 + 297 + post_process( 298 + json.dumps( 299 + { 300 + "observations": { 301 + "alice_johnson": [ 302 + {"content": "Prefers morning meetings", "reasoning": "dupe"}, 303 + {"content": "Expert in distributed systems", "reasoning": "new"}, 304 + ] 305 + }, 306 + "skipped": [], 307 + "summary": "", 308 + } 309 + ), 310 + {"facet": facet, "day": "20260304"}, 311 + ) 312 + 313 + observations = load_observations(facet, "alice_johnson") 314 + contents = [obs["content"] for obs in observations] 315 + assert contents.count("Prefers morning meetings") == 1 316 + assert "Expert in distributed systems" in contents 317 + 318 + 319 + def test_post_process_handles_malformed_json(): 320 + assert post_process("not valid json", {"facet": "work", "day": "20260304"}) is None 321 + 322 + 323 + # ============================================================================ 324 + # Agent config test 325 + # ============================================================================ 326 + 327 + 328 + def test_entity_observer_agent_config(): 329 + _set_journal("tests/fixtures/journal") 330 + 331 + config = get_agent("entities:entity_observer") 332 + 333 + assert config["type"] == "generate" 334 + assert config.get("output") == "json" 335 + assert config.get("tier") == 2 336 + assert config.get("thinking_budget") == 2048 337 + assert config.get("hook", {}).get("pre") == "entities:entity_observer" 338 + assert config.get("hook", {}).get("post") == "entities:entity_observer" 339 + assert "$observer_context" in config["user_instruction"]
+129
think/entities/context.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Context assembly for entity_observer generate agent. 5 + 6 + Pre-computes the observation context that the cogitate version built 7 + through sequential tool calls. Used by the entity_observer pre-hook 8 + to inject $observer_context into the prompt. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + from pathlib import Path 14 + 15 + from think.entities.activity import parse_knowledge_graph_entities 16 + from think.entities.loading import load_entities 17 + from think.entities.matching import find_matching_entity 18 + from think.entities.observations import load_observations 19 + from think.utils import get_journal 20 + 21 + 22 + def _active_entity_ids(facet: str, day: str, attached: list[dict]) -> set[str]: 23 + active_ids: set[str] = set() 24 + 25 + for name in parse_knowledge_graph_entities(day): 26 + match = find_matching_entity(name, attached) 27 + if match: 28 + entity_id = match.get("id") 29 + if entity_id: 30 + active_ids.add(entity_id) 31 + 32 + for detected in load_entities(facet, day): 33 + match = find_matching_entity(detected.get("name", ""), attached) 34 + if match: 35 + entity_id = match.get("id") 36 + if entity_id: 37 + active_ids.add(entity_id) 38 + 39 + return active_ids 40 + 41 + 42 + def _load_knowledge_graph(day: str) -> str: 43 + kg_path = Path(get_journal()) / day / "agents" / "knowledge_graph.md" 44 + if not kg_path.exists(): 45 + return "No knowledge graph available for this day." 46 + 47 + try: 48 + content = kg_path.read_text(encoding="utf-8").strip() 49 + except (OSError, UnicodeDecodeError): 50 + return "No knowledge graph available for this day." 51 + 52 + return content or "No knowledge graph available for this day." 53 + 54 + 55 + def _format_observations(facet: str, entity_id: str) -> list[str]: 56 + observations = load_observations(facet, entity_id)[-3:] 57 + if not observations: 58 + return ["No prior observations."] 59 + 60 + lines = [] 61 + for observation in observations: 62 + content = str(observation.get("content", "")).strip() 63 + if not content: 64 + continue 65 + source_day = observation.get("source_day") or "unknown" 66 + lines.append(f"- {content} (source: {source_day})") 67 + 68 + return lines or ["No prior observations."] 69 + 70 + 71 + def _format_entity_section(facet: str, entity: dict) -> str: 72 + entity_id = entity.get("id", "") 73 + entity_name = entity.get("name", entity_id) 74 + description = entity.get("description", "") or "" 75 + 76 + lines = [ 77 + f"#### {entity_name} ({entity_id})", 78 + f"- Type: {entity.get('type', '')}", 79 + f"- Description: {description}", 80 + ] 81 + 82 + aka_list = entity.get("aka") 83 + if isinstance(aka_list, list) and aka_list: 84 + lines.append(f"- AKA: {', '.join(str(item) for item in aka_list if item)}") 85 + 86 + lines.append("") 87 + lines.append("Recent observations:") 88 + lines.extend(_format_observations(facet, entity_id)) 89 + 90 + return "\n".join(lines) 91 + 92 + 93 + def assemble_observer_context(facet: str, day: str) -> str: 94 + """Assemble structured observation context for a facet/day run.""" 95 + attached = load_entities(facet) 96 + active_ids = _active_entity_ids(facet, day, attached) 97 + 98 + if not active_ids: 99 + return "No active entities found for this day." 100 + 101 + active_entities = [entity for entity in attached if entity.get("id") in active_ids] 102 + kg_content = _load_knowledge_graph(day) 103 + 104 + lines = [ 105 + "# Entity Observer Context", 106 + "", 107 + f"## Facet: {facet}", 108 + f"## Day: {day}", 109 + f"## Active Entities: {len(active_entities)} of {len(attached)} attached", 110 + "", 111 + "### Entities", 112 + "", 113 + ] 114 + 115 + for index, entity in enumerate(active_entities): 116 + if index: 117 + lines.extend(["", "---", ""]) 118 + lines.append(_format_entity_section(facet, entity)) 119 + 120 + lines.extend( 121 + [ 122 + "", 123 + "### Knowledge Graph", 124 + "", 125 + kg_content, 126 + ] 127 + ) 128 + 129 + return "\n".join(lines)