personal memory agent
0

Configure Feed

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

refactor(entities): drop process-lifetime read caches; read entity domain fresh

Remove all six module-level read-cache globals and their five clear_*
functions from the entity domain so every accessor reads fresh from disk.
These caches were harmless in one-shot CLI but were silent stale-read bugs
under the long-lived convey server: an out-of-process write left the cache
serving data that no longer matched disk.

Removed caches:
- _JOURNAL_ENTITY_CACHE (entities/journal.py)
- _RELATIONSHIP_CACHE + _RELATIONSHIP_IDS_CACHE (entities/relationships.py)
- _ENTITY_LOADING_CACHE (entities/loading.py)
- _OBSERVATION_CACHE + _OBSERVATION_COUNT_CACHE (entities/observations.py)

Replaced the cache-warm patterns in the two provably-hot all-facets loops
with operation-scoped memos passed by argument (no module globals):
- load_all_attached_entities threads a single journal_entities dict into
_load_entities_from_relationships, computed once instead of per-facet.
- get_journal_entities_data builds an all_relationships memo once and passes
it into _build_facet_relationships, replacing the discarded pre-warm loop.
Single-entity routes (get_journal_entity, voice tools) read fresh.

merge: dropped _clear_merge_caches; caches_cleared now reports only
["discovery_clusters"] when that on-disk artifact is unlinked, else [].

indexer no longer clears domain caches (also removes a latent L6 smell).

Tests: removed the autouse cache-clearing fixtures and all clear_* call
sites (test isolation is now automatic per tmp/fixture journal); converted
the two cache-mechanism tests to freshness assertions; added same-process
write->fresh-read coverage for journal entities, relationships, and
observations. link/auth.py mtime-reload left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+160 -423
+1 -11
solstone/apps/entities/call.py
··· 22 22 from solstone.think.entities.consolidation import consolidate_detected_entities 23 23 from solstone.think.entities.core import entity_slug, is_valid_entity_type 24 24 from solstone.think.entities.journal import ( 25 - clear_journal_entity_cache, 26 25 create_journal_entity, 27 26 load_journal_entity, 28 27 save_journal_entity, 29 28 ) 30 - from solstone.think.entities.loading import clear_entity_loading_cache, load_entities 29 + from solstone.think.entities.loading import load_entities 31 30 from solstone.think.entities.matching import resolve_entity, validate_aka_uniqueness 32 31 from solstone.think.entities.observations import ( 33 32 add_observation, ··· 35 34 save_observations, 36 35 ) 37 36 from solstone.think.entities.relationships import ( 38 - clear_relationship_caches, 39 37 entity_memory_path, 40 38 load_facet_relationship, 41 39 save_facet_relationship, ··· 67 65 @app.callback() 68 66 def _require_up() -> None: 69 67 require_solstone() 70 - 71 - 72 - def _clear_all_caches(): 73 - """Clear all underlying think entity caches.""" 74 - clear_entity_loading_cache() 75 - clear_relationship_caches() 76 - clear_journal_entity_cache() 77 68 78 69 79 70 def _resolve_or_exit(facet: str, entity: str) -> dict: ··· 354 345 relationship["description"] = description 355 346 relationship["updated_at"] = now_ms() 356 347 save_facet_relationship(facet, entity_id, relationship) 357 - clear_entity_loading_cache() 358 348 log_call_action( 359 349 facet=facet, 360 350 action="entity_update",
+20 -5
solstone/apps/entities/routes.py
··· 37 37 ) 38 38 from solstone.convey.utils import error_response 39 39 from solstone.think.entities import ( 40 + EntityDict, 40 41 block_journal_entity, 41 42 count_observations, 42 43 entity_last_active_ts, ··· 717 718 718 719 719 720 def _build_facet_relationships( 720 - entity_id: str, entity_name: str, facets_config: dict 721 + entity_id: str, 722 + entity_name: str, 723 + facets_config: dict, 724 + *, 725 + all_relationships: dict[str, dict[str, EntityDict]] | None = None, 721 726 ) -> tuple[list, int, int]: 722 727 """Build facet relationships list for a journal entity. 723 728 ··· 734 739 latest_active_ts = 0 735 740 736 741 for facet_name in facets_config: 737 - relationship = load_facet_relationship(facet_name, entity_id) 742 + if all_relationships is None: 743 + relationship = load_facet_relationship(facet_name, entity_id) 744 + else: 745 + relationship = all_relationships.get(facet_name, {}).get(entity_id) 738 746 if not relationship: 739 747 continue 740 748 ··· 786 794 """ 787 795 facets_config = get_facets() 788 796 journal_entities = load_all_journal_entities() 789 - for facet_name in facets_config: 790 - load_all_facet_relationships(facet_name) 797 + all_relationships = { 798 + facet_name: load_all_facet_relationships(facet_name) 799 + for facet_name in facets_config 800 + } 791 801 792 802 entities = [] 793 803 for entity_id, journal_entity in journal_entities.items(): ··· 795 805 796 806 # Build facet relationships 797 807 facet_relationships, total_observation_count, latest_active_ts = ( 798 - _build_facet_relationships(entity_id, entity_name, facets_config) 808 + _build_facet_relationships( 809 + entity_id, 810 + entity_name, 811 + facets_config, 812 + all_relationships=all_relationships, 813 + ) 799 814 ) 800 815 801 816 # Build enriched entity
-30
solstone/apps/entities/tests/conftest.py
··· 26 26 27 27 from solstone.apps.speakers.tests.conftest import speakers_env as _speakers_env 28 28 from solstone.convey import create_app 29 - from solstone.think.entities.journal import clear_journal_entity_cache 30 - from solstone.think.entities.loading import clear_entity_loading_cache 31 29 from solstone.think.entities.observations import ( 32 30 add_observation, 33 - clear_observation_cache, 34 - clear_observation_count_cache, 35 31 save_observations, 36 32 ) 37 - from solstone.think.entities.relationships import clear_relationship_caches 38 33 from solstone.think.entities.saving import save_entities 39 34 from tests._baseline_harness import copytree_tracked 40 35 ··· 50 45 dst = tmp_path / "journal" 51 46 copytree_tracked(src, dst) 52 47 monkeypatch.setenv("SOLSTONE_JOURNAL", str(dst.resolve())) 53 - clear_journal_entity_cache() 54 - clear_entity_loading_cache() 55 - clear_relationship_caches() 56 - clear_observation_cache() 57 - clear_observation_count_cache() 58 48 import solstone.think.utils as think_utils 59 49 60 50 think_utils._journal_path_cache = None ··· 85 75 # SOLSTONE_JOURNAL is set, entity files exist 86 76 """ 87 77 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 88 - clear_journal_entity_cache() 89 - clear_entity_loading_cache() 90 - clear_relationship_caches() 91 - clear_observation_cache() 92 - clear_observation_count_cache() 93 78 import solstone.think.utils as think_utils 94 79 95 80 think_utils._journal_path_cache = None ··· 112 97 return tmp_path 113 98 114 99 yield _create 115 - clear_journal_entity_cache() 116 - clear_entity_loading_cache() 117 - clear_relationship_caches() 118 - clear_observation_cache() 119 - clear_observation_count_cache() 120 100 import solstone.think.utils as think_utils 121 101 122 102 think_utils._journal_path_cache = None ··· 126 106 def entity_move_env(tmp_path, monkeypatch): 127 107 """Create a two-facet environment for entity move tests.""" 128 108 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 129 - clear_journal_entity_cache() 130 - clear_entity_loading_cache() 131 - clear_relationship_caches() 132 - clear_observation_cache() 133 - clear_observation_count_cache() 134 109 import solstone.think.utils as think_utils 135 110 136 111 think_utils._journal_path_cache = None ··· 172 147 return tmp_path, src_facet, dst_facet, entity_name 173 148 174 149 yield _create 175 - clear_journal_entity_cache() 176 - clear_entity_loading_cache() 177 - clear_relationship_caches() 178 - clear_observation_cache() 179 - clear_observation_count_cache() 180 150 import solstone.think.utils as think_utils 181 151 182 152 think_utils._journal_path_cache = None
+67
solstone/apps/entities/tests/test_all_facets.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 + from typing import Any 8 + 9 + from solstone.apps.entities.routes import get_journal_entities_data 10 + from solstone.think.entities.observations import add_observation, count_observations 11 + 12 + 13 + def _entity(name: str) -> dict[str, Any]: 14 + return { 15 + "type": "Person", 16 + "name": name, 17 + "description": "Test entity", 18 + "attached_at": 1000, 19 + "updated_at": 1000, 20 + } 21 + 22 + 23 + def test_observation_count_reflects_fresh_writes(entity_env): 24 + facet = "personal" 25 + entity_name = "Alice Johnson" 26 + entity_env( 27 + attached=[_entity(entity_name)], 28 + observations=["Prefers async updates"], 29 + observation_entity=entity_name, 30 + facet=facet, 31 + ) 32 + 33 + assert count_observations(facet, entity_name) == 1 34 + 35 + add_observation(facet, entity_name, "Prefers morning meetings", "20260427") 36 + 37 + assert count_observations(facet, entity_name) == 2 38 + 39 + 40 + def test_journal_entities_data_reflects_fresh_relationship_writes(entity_env): 41 + facet = "personal" 42 + entity_name = "Alice Johnson" 43 + journal = entity_env(attached=[_entity(entity_name)], facet=facet) 44 + facet_dir = journal / "facets" / facet 45 + facet_dir.mkdir(parents=True, exist_ok=True) 46 + (facet_dir / "facet.json").write_text( 47 + json.dumps({"title": "Personal", "description": "Personal facet"}), 48 + encoding="utf-8", 49 + ) 50 + 51 + first = get_journal_entities_data() 52 + assert len(first["entities"]) == 1 53 + assert first["entities"][0]["facets"][0]["description"] == "Test entity" 54 + 55 + relationship_path = ( 56 + journal / "facets" / facet / "entities" / "alice_johnson" / "entity.json" 57 + ) 58 + relationship = json.loads(relationship_path.read_text(encoding="utf-8")) 59 + relationship["description"] = "Updated relationship" 60 + relationship_path.write_text( 61 + json.dumps(relationship, indent=2) + "\n", 62 + encoding="utf-8", 63 + ) 64 + 65 + second = get_journal_entities_data() 66 + assert len(second["entities"]) == 1 67 + assert second["entities"][0]["facets"][0]["description"] == "Updated relationship"
-92
solstone/apps/entities/tests/test_all_facets_cache.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - from __future__ import annotations 5 - 6 - import builtins 7 - import json 8 - from pathlib import Path 9 - from typing import Any 10 - 11 - from solstone.apps.entities.routes import get_journal_entities_data 12 - from solstone.think.entities.observations import add_observation, count_observations 13 - 14 - 15 - def _entity(name: str) -> dict[str, Any]: 16 - return { 17 - "type": "Person", 18 - "name": name, 19 - "description": "Test entity", 20 - "attached_at": 1000, 21 - "updated_at": 1000, 22 - } 23 - 24 - 25 - def test_observation_count_memo(entity_env, monkeypatch): 26 - facet = "personal" 27 - entity_name = "Alice Johnson" 28 - entity_env( 29 - attached=[_entity(entity_name)], 30 - observations=["Prefers async updates"], 31 - observation_entity=entity_name, 32 - facet=facet, 33 - ) 34 - 35 - real_open = builtins.open 36 - observation_opens = 0 37 - 38 - def counting_open(file, *args, **kwargs): 39 - nonlocal observation_opens 40 - path = Path(file) 41 - if path.name == "observations.jsonl": 42 - observation_opens += 1 43 - return real_open(file, *args, **kwargs) 44 - 45 - monkeypatch.setattr(builtins, "open", counting_open) 46 - 47 - assert count_observations(facet, entity_name) == 1 48 - assert count_observations(facet, entity_name) == 1 49 - assert observation_opens == 1 50 - 51 - add_observation(facet, entity_name, "Prefers morning meetings", "20260427") 52 - opens_after_write = observation_opens 53 - 54 - assert count_observations(facet, entity_name) == 2 55 - assert observation_opens == opens_after_write + 1 56 - 57 - 58 - def test_relationship_cache_warm(entity_env, monkeypatch): 59 - facet = "personal" 60 - entity_name = "Alice Johnson" 61 - journal = entity_env(attached=[_entity(entity_name)], facet=facet) 62 - facet_dir = journal / "facets" / facet 63 - facet_dir.mkdir(parents=True, exist_ok=True) 64 - (facet_dir / "facet.json").write_text( 65 - json.dumps({"title": "Personal", "description": "Personal facet"}), 66 - encoding="utf-8", 67 - ) 68 - 69 - real_open = builtins.open 70 - relationship_opens = 0 71 - 72 - def counting_open(file, *args, **kwargs): 73 - nonlocal relationship_opens 74 - path = Path(file) 75 - if ( 76 - path.name == "entity.json" 77 - and "facets" in path.parts 78 - and path.parent.parent.name == "entities" 79 - ): 80 - relationship_opens += 1 81 - return real_open(file, *args, **kwargs) 82 - 83 - monkeypatch.setattr(builtins, "open", counting_open) 84 - 85 - first = get_journal_entities_data() 86 - assert len(first["entities"]) == 1 87 - assert relationship_opens > 0 88 - 89 - relationship_opens = 0 90 - second = get_journal_entities_data() 91 - assert len(second["entities"]) == 1 92 - assert relationship_opens == 0
+1 -7
solstone/apps/entities/tests/test_merge.py
··· 243 243 assert data["segments"]["corrections_rewritten"] == 1 244 244 assert data["segments"]["errors"] == [] 245 245 assert data["audit_log_path"] == str(_audit_log_path(env)) 246 - assert set(data["caches_cleared"]) >= { 247 - "journal_entity_cache", 248 - "relationship_caches", 249 - "observation_cache", 250 - "entity_loading_cache", 251 - "discovery_clusters", 252 - } 246 + assert data["caches_cleared"] == ["discovery_clusters"] 253 247 254 248 assert load_journal_entity("alice_alias") is None 255 249 canonical = load_journal_entity("alice_canonical")
-12
solstone/apps/speakers/tests/conftest.py
··· 19 19 sys.path.insert(0, str(ROOT)) 20 20 21 21 from solstone.think.entities import entity_slug 22 - from solstone.think.entities.journal import clear_journal_entity_cache 23 - from solstone.think.entities.loading import clear_entity_loading_cache 24 - from solstone.think.entities.observations import clear_observation_cache 25 - from solstone.think.entities.relationships import clear_relationship_caches 26 22 27 23 # Default stream name for test fixtures 28 24 STREAM = "test" ··· 55 51 self.journal = journal_path 56 52 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_path)) 57 53 monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 58 - clear_journal_entity_cache() 59 - clear_entity_loading_cache() 60 - clear_relationship_caches() 61 - clear_observation_cache() 62 54 import solstone.think.utils as think_utils 63 55 64 56 think_utils._journal_path_cache = None ··· 471 463 return SpeakersEnv(tmp_path) 472 464 473 465 yield _create 474 - clear_journal_entity_cache() 475 - clear_entity_loading_cache() 476 - clear_relationship_caches() 477 - clear_observation_cache() 478 466 import solstone.think.utils as think_utils 479 467 480 468 think_utils._journal_path_cache = None
-34
solstone/think/entities/journal.py
··· 19 19 from solstone.think.journal_io import atomic_replace 20 20 from solstone.think.utils import get_journal, now_ms 21 21 22 - # Global cache for journal entities: {entity_id: EntityDict} 23 - _JOURNAL_ENTITY_CACHE: dict[str, EntityDict] | None = None 24 - 25 - 26 - def clear_journal_entity_cache() -> None: 27 - """Clear the journal entity cache.""" 28 - global _JOURNAL_ENTITY_CACHE 29 - _JOURNAL_ENTITY_CACHE = None 30 - 31 22 32 23 def journal_entity_path(entity_id: str) -> Path: 33 24 """Return path to journal-level entity file. ··· 51 42 Entity dict with id, name, type, aka, is_principal, created_at fields, 52 43 or None if not found. 53 44 """ 54 - global _JOURNAL_ENTITY_CACHE 55 - if _JOURNAL_ENTITY_CACHE is not None and entity_id in _JOURNAL_ENTITY_CACHE: 56 - return _JOURNAL_ENTITY_CACHE[entity_id] 57 - 58 45 path = journal_entity_path(entity_id) 59 46 if not path.exists(): 60 47 return None ··· 64 51 data = json.load(f) 65 52 # Ensure id is present 66 53 data["id"] = entity_id 67 - 68 - # Update cache if it exists (single entity load doesn't populate full cache) 69 - if _JOURNAL_ENTITY_CACHE is not None: 70 - _JOURNAL_ENTITY_CACHE[entity_id] = data 71 54 72 55 return data 73 56 except (json.JSONDecodeError, OSError): ··· 90 73 if not entity_id: 91 74 raise ValueError("Entity must have an 'id' field") 92 75 93 - # Clear cache on modification 94 - clear_journal_entity_cache() 95 - 96 76 path = journal_entity_path(entity_id) 97 77 content = json.dumps(entity, ensure_ascii=False, indent=2) + "\n" 98 78 atomic_replace(path, content) ··· 124 104 Returns: 125 105 Dict mapping entity_id to entity dict 126 106 """ 127 - global _JOURNAL_ENTITY_CACHE 128 - if _JOURNAL_ENTITY_CACHE is not None: 129 - return _JOURNAL_ENTITY_CACHE 130 - 131 107 entity_ids = scan_journal_entities() 132 108 entities = {} 133 109 for entity_id in entity_ids: ··· 135 111 if entity: 136 112 entities[entity_id] = entity 137 113 138 - _JOURNAL_ENTITY_CACHE = entities 139 114 return entities 140 115 141 116 ··· 267 242 if journal_entity.get("is_principal"): 268 243 raise ValueError("Cannot block the principal (self) entity") 269 244 270 - # Clear cache on modification 271 - clear_journal_entity_cache() 272 - 273 245 # Set blocked flag on journal entity 274 246 journal_entity["blocked"] = True 275 247 journal_entity["updated_at"] = now_ms() ··· 350 322 351 323 if journal_entity.get("is_principal"): 352 324 raise ValueError("Cannot delete the principal (self) entity") 353 - 354 - from solstone.think.entities.relationships import clear_relationship_caches 355 - 356 - # Clear cache on modification 357 - clear_journal_entity_cache() 358 - clear_relationship_caches() 359 325 360 326 facets_deleted = [] 361 327
+13 -28
solstone/think/entities/loading.py
··· 28 28 ) 29 29 from solstone.think.utils import get_journal 30 30 31 - # Global cache for loaded entities: {(facet, day, detached, blocked): list[EntityDict]} 32 - _ENTITY_LOADING_CACHE: dict[tuple, list[EntityDict]] | None = None 33 - 34 - 35 - def clear_entity_loading_cache() -> None: 36 - """Clear the entity loading cache.""" 37 - global _ENTITY_LOADING_CACHE 38 - _ENTITY_LOADING_CACHE = None 39 - 40 31 41 32 def detected_entities_path(facet: str, day: str) -> Path: 42 33 """Return path to detected entities file for a facet and day. ··· 115 106 116 107 117 108 def _load_entities_from_relationships( 118 - facet: str, *, include_detached: bool = False, include_blocked: bool = False 109 + facet: str, 110 + *, 111 + include_detached: bool = False, 112 + include_blocked: bool = False, 113 + journal_entities: dict[str, EntityDict] | None = None, 119 114 ) -> list[EntityDict]: 120 115 """Load attached entities from facet relationships + journal entities. 121 116 ··· 132 127 return [] 133 128 134 129 # Load all journal entities for enrichment 135 - journal_entities = load_all_journal_entities() 130 + if journal_entities is None: 131 + journal_entities = load_all_journal_entities() 136 132 137 133 entities = [] 138 134 for entity_id in entity_ids: ··· 188 184 >>> load_entities("personal") 189 185 [{"id": "john_smith", "type": "Person", "name": "John Smith", "description": "Friend"}] 190 186 """ 191 - global _ENTITY_LOADING_CACHE 192 - 193 - # Use cache if available 194 - cache_key = (facet, day, include_detached, include_blocked) 195 - if _ENTITY_LOADING_CACHE is not None: 196 - cached = _ENTITY_LOADING_CACHE.get(cache_key) 197 - if cached is not None: 198 - return cached 199 - 200 187 # For detected entities, use day-specific files 201 188 if day is not None: 202 189 path = detected_entities_path(facet, day) ··· 206 193 result = _load_entities_from_relationships( 207 194 facet, include_detached=include_detached, include_blocked=include_blocked 208 195 ) 209 - 210 - # Populate cache if initialized 211 - if _ENTITY_LOADING_CACHE is not None: 212 - _ENTITY_LOADING_CACHE[cache_key] = result 213 - else: 214 - # Initialize and populate 215 - _ENTITY_LOADING_CACHE = {cache_key: result} 216 196 217 197 return result 218 198 ··· 254 234 # Track seen IDs for deduplication (use ID instead of name for uniqueness) 255 235 seen_ids: set[str] = set() 256 236 all_entities: list[EntityDict] = [] 237 + journal_entities = load_all_journal_entities() 257 238 258 239 # Process facets in sorted order for deterministic results 259 240 for facet_path in sorted(facets_dir.iterdir()): ··· 262 243 263 244 facet_name = facet_path.name 264 245 265 - for entity in load_entities(facet_name, include_detached=False): 246 + for entity in _load_entities_from_relationships( 247 + facet_name, 248 + include_detached=False, 249 + journal_entities=journal_entities, 250 + ): 266 251 entity_id = entity.get("id", "") 267 252 # Keep first occurrence only (deduplicate by ID) 268 253 if entity_id and entity_id not in seen_ids:
+2 -24
solstone/think/entities/merge.py
··· 11 11 from typing import Any 12 12 13 13 from solstone.think.entities.journal import ( 14 - clear_journal_entity_cache, 15 14 load_journal_entity, 16 15 save_journal_entity, 17 16 scan_journal_entities, 18 17 ) 19 - from solstone.think.entities.loading import clear_entity_loading_cache 20 - from solstone.think.entities.observations import ( 21 - clear_observation_cache, 22 - clear_observation_count_cache, 23 - save_observations, 24 - ) 18 + from solstone.think.entities.observations import save_observations 25 19 from solstone.think.entities.relationships import ( 26 - clear_relationship_caches, 27 20 save_facet_relationship, 28 21 ) 29 22 from solstone.think.entities.voiceprints import ( ··· 549 542 tmp_path.rename(out_path) 550 543 551 544 552 - def _clear_merge_caches() -> list[str]: 553 - clear_journal_entity_cache() 554 - clear_relationship_caches() 555 - clear_observation_cache() 556 - clear_observation_count_cache() 557 - clear_entity_loading_cache() 558 - return [ 559 - "journal_entity_cache", 560 - "relationship_caches", 561 - "observation_cache", 562 - "observation_count_cache", 563 - "entity_loading_cache", 564 - ] 565 - 566 - 567 545 def _audit_counts(result: dict[str, Any]) -> dict[str, Any]: 568 546 return { 569 547 "identity": { ··· 698 676 _apply_segment_plan(segment_plan["operations"]) 699 677 700 678 discovery_cache = Path(get_journal()) / "awareness" / "discovery_clusters.json" 701 - caches_cleared = _clear_merge_caches() 679 + caches_cleared: list[str] = [] 702 680 if discovery_cache.exists(): 703 681 discovery_cache.unlink() 704 682 caches_cleared.append("discovery_clusters")
-43
solstone/think/entities/observations.py
··· 21 21 from solstone.think.journal_io import atomic_replace, hold_lock 22 22 from solstone.think.utils import get_journal, now_ms 23 23 24 - # Global cache for entity observations: {(facet, entity_slug): list[dict]} 25 - _OBSERVATION_CACHE: dict[tuple[str, str], list[dict[str, Any]]] | None = None 26 - # Global cache for observation counts: {path: count} 27 - _OBSERVATION_COUNT_CACHE: dict[Path, int] | None = None 28 - 29 - 30 - def clear_observation_cache() -> None: 31 - """Clear the entity observation cache.""" 32 - global _OBSERVATION_CACHE 33 - _OBSERVATION_CACHE = None 34 - 35 - 36 - def clear_observation_count_cache() -> None: 37 - """Clear the entity observation count cache.""" 38 - global _OBSERVATION_COUNT_CACHE 39 - _OBSERVATION_COUNT_CACHE = None 40 - 41 24 42 25 def observations_file_path(facet: str, name: str) -> Path: 43 26 """Return path to observations file for an entity. ··· 70 53 71 54 72 55 def _count_observation_file(obs_file: Path) -> int: 73 - global _OBSERVATION_COUNT_CACHE 74 56 if not obs_file.exists(): 75 57 return 0 76 58 77 - if _OBSERVATION_COUNT_CACHE is None: 78 - _OBSERVATION_COUNT_CACHE = {} 79 - 80 - cached = _OBSERVATION_COUNT_CACHE.get(obs_file) 81 - if cached is not None: 82 - return cached 83 - 84 59 try: 85 60 with open(obs_file, "r", encoding="utf-8") as f: 86 61 count = sum(1 for line in f if line.strip()) 87 62 except OSError: 88 63 return 0 89 64 90 - _OBSERVATION_COUNT_CACHE[obs_file] = count 91 65 return count 92 66 93 67 ··· 149 123 >>> load_observations("work", "Alice Johnson") 150 124 [{"content": "Prefers async communication", "observed_at": 1736784000000, "source_day": "20250113"}] 151 125 """ 152 - global _OBSERVATION_CACHE 153 - from solstone.think.entities.core import entity_slug 154 - 155 - slug = entity_slug(name) 156 - if _OBSERVATION_CACHE is not None: 157 - cached = _OBSERVATION_CACHE.get((facet, slug)) 158 - if cached is not None: 159 - return cached 160 - 161 126 path = observations_file_path(facet, name) 162 127 163 128 if not path.exists(): ··· 175 140 except json.JSONDecodeError: 176 141 continue # Skip malformed lines 177 142 178 - # Update cache if initialized 179 - if _OBSERVATION_CACHE is not None: 180 - _OBSERVATION_CACHE[(facet, slug)] = observations 181 - 182 143 return observations 183 144 184 145 ··· 202 163 name: Entity name 203 164 observations: List of observation dictionaries 204 165 """ 205 - # Clear cache on modification 206 - clear_observation_cache() 207 - clear_observation_count_cache() 208 - 209 166 path = observations_file_path(facet, name) 210 167 211 168 # Format observations as JSONL
-49
solstone/think/entities/relationships.py
··· 21 21 from solstone.think.journal_io import atomic_replace 22 22 from solstone.think.utils import get_journal 23 23 24 - # Global cache for facet relationships: {(facet, entity_id): EntityDict} 25 - _RELATIONSHIP_CACHE: dict[tuple[str, str], EntityDict] | None = None 26 - # Global cache for facet relationship IDs: {facet: [entity_id, ...]} 27 - _RELATIONSHIP_IDS_CACHE: dict[str, list[str]] | None = None 28 - 29 - 30 - def clear_relationship_caches() -> None: 31 - """Clear all relationship and ID caches.""" 32 - global _RELATIONSHIP_CACHE, _RELATIONSHIP_IDS_CACHE 33 - _RELATIONSHIP_CACHE = None 34 - _RELATIONSHIP_IDS_CACHE = None 35 - 36 24 37 25 def facet_relationship_path(facet: str, entity_id: str) -> Path: 38 26 """Return path to facet relationship file. ··· 60 48 Relationship dict with entity_id, description, timestamps, etc., 61 49 or None if not found. 62 50 """ 63 - global _RELATIONSHIP_CACHE 64 - if _RELATIONSHIP_CACHE is not None: 65 - cached = _RELATIONSHIP_CACHE.get((facet, entity_id)) 66 - if cached is not None: 67 - return cached 68 - 69 51 path = facet_relationship_path(facet, entity_id) 70 52 if not path.exists(): 71 53 return None ··· 76 58 # Ensure entity_id is present 77 59 data["entity_id"] = entity_id 78 60 79 - # Update cache if initialized 80 - if _RELATIONSHIP_CACHE is not None: 81 - _RELATIONSHIP_CACHE[(facet, entity_id)] = data 82 - 83 61 return data 84 62 except (json.JSONDecodeError, OSError): 85 63 return None ··· 97 75 entity_id: Entity ID (slug) 98 76 relationship: Relationship dict with description, timestamps, etc. 99 77 """ 100 - # Clear caches on modification 101 - clear_relationship_caches() 102 - 103 78 path = facet_relationship_path(facet, entity_id) 104 79 105 80 # Ensure entity_id is in the relationship ··· 120 95 Returns: 121 96 List of entity IDs (directory names) 122 97 """ 123 - global _RELATIONSHIP_IDS_CACHE 124 - if _RELATIONSHIP_IDS_CACHE is not None: 125 - cached = _RELATIONSHIP_IDS_CACHE.get(facet) 126 - if cached is not None: 127 - return cached 128 - 129 98 entities_dir = Path(get_journal()) / "facets" / facet / "entities" 130 99 if not entities_dir.exists(): 131 100 return [] ··· 136 105 entity_ids.append(entry.name) 137 106 138 107 entity_ids.sort() 139 - if _RELATIONSHIP_IDS_CACHE is None: 140 - _RELATIONSHIP_IDS_CACHE = {} 141 - _RELATIONSHIP_IDS_CACHE[facet] = entity_ids 142 108 return entity_ids 143 109 144 110 ··· 148 114 Returns: 149 115 Dict mapping entity_id to relationship dict 150 116 """ 151 - global _RELATIONSHIP_CACHE 152 117 entity_ids = scan_facet_relationships(facet) 153 - if _RELATIONSHIP_CACHE is not None and all( 154 - (facet, entity_id) in _RELATIONSHIP_CACHE for entity_id in entity_ids 155 - ): 156 - return { 157 - entity_id: _RELATIONSHIP_CACHE[(facet, entity_id)] 158 - for entity_id in entity_ids 159 - } 160 - 161 - if _RELATIONSHIP_CACHE is None: 162 - _RELATIONSHIP_CACHE = {} 163 - 164 118 relationships = {} 165 119 for entity_id in entity_ids: 166 120 relationship = load_facet_relationship(facet, entity_id) ··· 307 261 308 262 if new_folder.exists(): 309 263 raise OSError(f"Target folder already exists: {new_folder}") 310 - 311 - # Clear caches on modification 312 - clear_relationship_caches() 313 264 314 265 shutil.move(str(old_folder), str(new_folder)) 315 266 return True
-5
solstone/think/entities/saving.py
··· 19 19 save_journal_entity, 20 20 ) 21 21 from solstone.think.entities.loading import ( 22 - clear_entity_loading_cache, 23 22 detected_entities_path, 24 23 load_entities, 25 24 ) ··· 46 45 # Format as JSONL and write atomically 47 46 content = "".join(json.dumps(e, ensure_ascii=False) + "\n" for e in sorted_entities) 48 47 atomic_replace(path, content) 49 - clear_entity_loading_cache() 50 48 51 49 52 50 def _save_entities_attached(facet: str, entities: list[EntityDict]) -> None: ··· 150 148 # Save facet relationship 151 149 save_facet_relationship(facet, entity_id, relationship) 152 150 153 - clear_entity_loading_cache() 154 - 155 151 156 152 def save_entities( 157 153 facet: str, entities: list[EntityDict], day: str | None = None ··· 214 210 try: 215 211 with hold_lock(path): 216 212 # Fresh load inside lock — sees all prior writers' changes 217 - clear_entity_loading_cache() 218 213 entities = load_entities(facet, day) 219 214 entities = modify_fn(entities) 220 215 _save_entities_detected(facet, entities, day)
+1 -7
solstone/think/indexer/journal.py
··· 25 25 from pathlib import Path 26 26 from typing import Any, Iterable 27 27 28 - from solstone.think.entities.journal import ( 29 - clear_journal_entity_cache, 30 - load_all_journal_entities, 31 - ) 28 + from solstone.think.entities.journal import load_all_journal_entities 32 29 from solstone.think.entities.relationships import ( 33 - clear_relationship_caches, 34 30 load_all_facet_relationships_across_facets, 35 31 ) 36 32 from solstone.think.formatters import ( ··· 680 676 or (fresh_count > 0 and not has_entity_chunks) 681 677 ) 682 678 if entity_changed: 683 - clear_journal_entity_cache() 684 - clear_relationship_caches() 685 679 _index_entity_search_chunks(conn) 686 680 conn.execute( 687 681 "REPLACE INTO files(path, mtime) VALUES (?, ?)",
-18
tests/conftest.py
··· 119 119 120 120 121 121 from solstone.convey.chat import stop_all_chat_runtime 122 - from solstone.think.entities.journal import clear_journal_entity_cache 123 - from solstone.think.entities.loading import clear_entity_loading_cache 124 - from solstone.think.entities.observations import clear_observation_cache 125 - from solstone.think.entities.relationships import clear_relationship_caches 126 122 from solstone.think.push.runtime import stop_all_push_runtime 127 123 from solstone.think.utils import now_ms 128 124 from solstone.think.voice import brain as voice_brain ··· 142 138 str(Path("tests/fixtures/journal").resolve()), 143 139 ) 144 140 monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 145 - 146 - 147 - @pytest.fixture(autouse=True) 148 - def _clear_entity_caches(): 149 - """Clear all entity caches before/after each test.""" 150 - clear_entity_loading_cache() 151 - clear_journal_entity_cache() 152 - clear_relationship_caches() 153 - clear_observation_cache() 154 - yield 155 - clear_entity_loading_cache() 156 - clear_journal_entity_cache() 157 - clear_relationship_caches() 158 - clear_observation_cache() 159 141 160 142 161 143 @pytest.fixture(autouse=True)
-4
tests/test_activities_cli_create.py
··· 27 27 28 28 think_utils._journal_path_cache = None 29 29 30 - from solstone.think.entities.loading import clear_entity_loading_cache 31 - 32 - clear_entity_loading_cache() 33 - 34 30 35 31 def _base_payload() -> dict: 36 32 return {
+51 -11
tests/test_entities.py
··· 9 9 DEFAULT_ACTIVITY_TS, 10 10 add_observation, 11 11 block_journal_entity, 12 + count_observations, 12 13 delete_journal_entity, 13 14 detected_entities_path, 14 15 ensure_entity_memory, ··· 19 20 get_identity_names, 20 21 iter_detected_entity_names_since, 21 22 load_all_attached_entities, 23 + load_all_facet_relationships, 24 + load_all_journal_entities, 22 25 load_detected_entities_recent, 23 26 load_entities, 27 + load_facet_relationship, 24 28 load_journal_entity, 25 29 load_observations, 26 30 load_recent_entity_names, ··· 30 34 resolve_entity, 31 35 save_detected_entity, 32 36 save_entities, 37 + save_facet_relationship, 38 + save_journal_entity, 33 39 save_observations, 34 40 touch_entities_from_activity, 35 41 touch_entity, ··· 286 292 assert "beta_corp" in journal_ids 287 293 288 294 289 - def test_save_entities_detected_invalidates_loading_cache( 295 + def test_save_entities_detected_reflects_fresh_read( 290 296 fixture_journal, tmp_path, monkeypatch 291 297 ): 292 - """Regression: save_entities must invalidate the loading cache so load-after-save returns fresh data. 293 - 294 - The autouse _clear_entity_caches fixture only clears between tests, so within 295 - a single test the cache persists across calls. Before the fix, the first load 296 - populated the cache and a subsequent save did not invalidate it — the second 297 - load returned stale data from the cache rather than re-reading disk. 298 - """ 298 + """Detected entity reads reflect same-process saves.""" 299 299 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 300 300 (tmp_path / "facets" / "test_facet" / "entities").mkdir(parents=True) 301 301 ··· 321 321 assert {e["name"] for e in loaded_second} == {"Alice", "Bob"} 322 322 323 323 324 - def test_save_entities_attached_invalidates_loading_cache( 324 + def test_save_entities_attached_reflects_fresh_read( 325 325 fixture_journal, tmp_path, monkeypatch 326 326 ): 327 - """Regression: save_entities (attached path, day=None) must invalidate the loading cache.""" 327 + """Attached entity reads reflect same-process saves.""" 328 328 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 329 329 (tmp_path / "facets" / "test_facet").mkdir(parents=True) 330 330 ··· 343 343 344 344 loaded_second = load_entities("test_facet") 345 345 assert {e["name"] for e in loaded_second} == {"Alice", "Bob"} 346 + 347 + 348 + def test_save_journal_entity_reflects_fresh_reads(tmp_path, monkeypatch): 349 + """Journal entity readers reflect same-process saves.""" 350 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 351 + 352 + save_journal_entity({"id": "alice", "name": "Alice", "type": "Person"}) 353 + 354 + assert load_journal_entity("alice")["name"] == "Alice" 355 + assert load_all_journal_entities()["alice"]["name"] == "Alice" 356 + 357 + save_journal_entity({"id": "alice", "name": "Alice Updated", "type": "Person"}) 358 + 359 + assert load_journal_entity("alice")["name"] == "Alice Updated" 360 + assert load_all_journal_entities()["alice"]["name"] == "Alice Updated" 361 + 362 + 363 + def test_save_facet_relationship_reflects_fresh_reads(tmp_path, monkeypatch): 364 + """Facet relationship readers reflect same-process saves.""" 365 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 366 + 367 + save_facet_relationship("work", "alice", {"description": "First"}) 368 + 369 + assert load_facet_relationship("work", "alice")["description"] == "First" 370 + assert load_all_facet_relationships("work")["alice"]["description"] == "First" 371 + 372 + save_facet_relationship("work", "alice", {"description": "Second"}) 373 + 374 + assert load_facet_relationship("work", "alice")["description"] == "Second" 375 + assert load_all_facet_relationships("work")["alice"]["description"] == "Second" 346 376 347 377 348 378 def test_save_detected_entity_basic(fixture_journal, tmp_path, monkeypatch): ··· 2029 2059 2030 2060 2031 2061 def test_save_and_load_observations(fixture_journal, tmp_path, monkeypatch): 2032 - """Test saving and loading observations.""" 2062 + """Observation readers reflect same-process saves.""" 2033 2063 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 2034 2064 2035 2065 # Save observations ··· 2050 2080 assert loaded[0]["observed_at"] == 1700000000000 2051 2081 assert loaded[0]["source_day"] == "20250113" 2052 2082 assert loaded[1]["content"] == "Expert in Kubernetes" 2083 + assert count_observations("personal", "Alice Johnson") == 2 2084 + 2085 + updated_observations = [ 2086 + {"content": "Prefers afternoon meetings", "observed_at": 1700000002000}, 2087 + ] 2088 + save_observations("personal", "Alice Johnson", updated_observations) 2089 + 2090 + loaded_updated = load_observations("personal", "Alice Johnson") 2091 + assert [obs["content"] for obs in loaded_updated] == ["Prefers afternoon meetings"] 2092 + assert count_observations("personal", "Alice Johnson") == 1 2053 2093 2054 2094 2055 2095 def test_add_observation_success(fixture_journal, tmp_path, monkeypatch):
+2 -10
tests/test_entities_locking.py
··· 118 118 ) -> None: 119 119 _run_workers(tmp_path, monkeypatch, "observations", _observation_worker) 120 120 121 - from solstone.think.entities.observations import ( 122 - clear_observation_cache, 123 - load_observations, 124 - ) 121 + from solstone.think.entities.observations import load_observations 125 122 126 - clear_observation_cache() 127 123 observations = load_observations("work", "Alice") 128 124 129 125 assert sorted(obs["content"] for obs in observations) == [ ··· 140 136 ) -> None: 141 137 _run_workers(tmp_path, monkeypatch, "detected", _detected_entity_worker) 142 138 143 - from solstone.think.entities.loading import ( 144 - clear_entity_loading_cache, 145 - load_entities, 146 - ) 139 + from solstone.think.entities.loading import load_entities 147 140 148 - clear_entity_loading_cache() 149 141 entities = load_entities("work", "20250101") 150 142 151 143 assert sorted(entity["name"] for entity in entities) == ["E0", "E1", "E2", "E3"]
+1 -11
tests/test_entity_observer_context.py
··· 9 9 10 10 from solstone.apps.entities.talent.entity_observer import post_process, pre_process 11 11 from solstone.think.entities.context import assemble_observer_context 12 - from solstone.think.entities.journal import clear_journal_entity_cache 13 - from solstone.think.entities.loading import clear_entity_loading_cache 14 - from solstone.think.entities.observations import ( 15 - clear_observation_cache, 16 - load_observations, 17 - ) 18 - from solstone.think.entities.relationships import clear_relationship_caches 12 + from solstone.think.entities.observations import load_observations 19 13 from solstone.think.talent import get_talent 20 14 21 15 22 16 def _set_journal(monkeypatch, path: str) -> None: 23 17 monkeypatch.setenv("SOLSTONE_JOURNAL", path) 24 - clear_entity_loading_cache() 25 - clear_observation_cache() 26 - clear_relationship_caches() 27 - clear_journal_entity_cache() 28 18 29 19 30 20 def _write_json(path: Path, data: dict) -> None:
+1 -6
tests/test_export_integration.py
··· 23 23 export_segments, 24 24 main, 25 25 ) 26 - from solstone.think.entities.journal import ( 27 - clear_journal_entity_cache, 28 - save_journal_entity, 29 - ) 26 + from solstone.think.entities.journal import save_journal_entity 30 27 31 28 journal_sources = import_module("solstone.apps.import.journal_sources") 32 29 import_routes = import_module("solstone.apps.import.routes") ··· 41 38 def _set_active_journal(monkeypatch, journal: Path) -> None: 42 39 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 43 40 think_utils._journal_path_cache = None 44 - clear_journal_entity_cache() 45 41 46 42 47 43 def _extract_path(url: str) -> str: ··· 182 178 } 183 179 184 180 think_utils._journal_path_cache = None 185 - clear_journal_entity_cache() 186 181 187 182 188 183 def _write_bytes(path: Path, content: bytes) -> None:
-2
tests/test_import_call.py
··· 14 14 import solstone.think.utils as think_utils 15 15 from solstone.think.call import call_app 16 16 from solstone.think.entities.journal import ( 17 - clear_journal_entity_cache, 18 17 load_journal_entity, 19 18 save_journal_entity, 20 19 ) ··· 59 58 monkeypatch.setattr(convey_state, "journal_root", str(tmp_path), raising=False) 60 59 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 61 60 think_utils._journal_path_cache = None 62 - clear_journal_entity_cache() 63 61 (tmp_path / "apps" / "import" / "journal_sources").mkdir( 64 62 parents=True, exist_ok=True 65 63 )
-8
tests/test_surfaces_health.py
··· 25 25 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 26 26 monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 27 27 28 - from solstone.think.entities.journal import clear_journal_entity_cache 29 - from solstone.think.entities.loading import clear_entity_loading_cache 30 - from solstone.think.entities.relationships import clear_relationship_caches 31 - 32 - clear_journal_entity_cache() 33 - clear_entity_loading_cache() 34 - clear_relationship_caches() 35 - 36 28 37 29 def _set_now(monkeypatch: pytest.MonkeyPatch, value: datetime) -> None: 38 30 assert value.tzinfo == UTC
-6
tests/test_surfaces_profile.py
··· 17 17 monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 18 18 19 19 import solstone.think.utils as think_utils 20 - from solstone.think.entities.journal import clear_journal_entity_cache 21 - from solstone.think.entities.loading import clear_entity_loading_cache 22 - from solstone.think.entities.relationships import clear_relationship_caches 23 20 24 21 think_utils._journal_path_cache = None 25 - clear_journal_entity_cache() 26 - clear_entity_loading_cache() 27 - clear_relationship_caches() 28 22 29 23 30 24 def _write_journal_entity(