personal memory agent
0
fork

Configure Feed

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

Remove observation number guard from entity observations

The guard required callers to compute and pass the expected next
observation number, adding an extra round trip (load + count) for
no practical benefit — agents always provide the correct sequence.
The file lock already serializes concurrent writes safely.

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

+19 -75
+9 -22
apps/entities/call.py
··· 11 11 import typer 12 12 13 13 from think.entities.core import entity_slug, is_valid_entity_type 14 - from think.entities.journal import get_or_create_journal_entity, save_journal_entity 14 + from think.entities.journal import ( 15 + get_or_create_journal_entity, 16 + load_journal_entity, 17 + save_journal_entity, 18 + ) 15 19 from think.entities.loading import load_entities 16 20 from think.entities.matching import resolve_entity, validate_aka_uniqueness 17 - from think.entities.observations import ( 18 - ObservationNumberError, 19 - add_observation, 20 - load_observations, 21 - ) 21 + from think.entities.observations import add_observation, load_observations 22 22 from think.entities.relationships import ( 23 23 load_facet_relationship, 24 24 save_facet_relationship, ··· 294 294 raise typer.Exit(1) 295 295 296 296 entity_id = resolved.get("id", entity_slug(resolved_name)) 297 - aka_list.append(aka_value) 298 297 299 - # Update journal entity aka (identity-level) 300 - from think.entities.journal import load_journal_entity 301 - 298 + # Update journal entity aka (identity-level, not facet-specific) 302 299 journal_entity = load_journal_entity(entity_id) 303 300 if journal_entity: 304 301 existing_aka = set(journal_entity.get("aka", [])) 305 302 existing_aka.add(aka_value) 306 303 journal_entity["aka"] = sorted(existing_aka) 307 304 save_journal_entity(journal_entity) 308 - 309 - # Update facet relationship (per-entity file) 310 - relationship = load_facet_relationship(facet, entity_id) 311 - if relationship is not None: 312 - relationship["aka"] = aka_list 313 - relationship["updated_at"] = now_ms() 314 - save_facet_relationship(facet, entity_id, relationship) 315 305 316 306 log_call_action( 317 307 facet=facet, ··· 356 346 facet = resolve_sol_facet(facet) 357 347 resolved = _resolve_or_exit(facet, entity) 358 348 resolved_name = resolved.get("name", "") 359 - obs = load_observations(facet, resolved_name) 360 - observation_number = len(obs) + 1 361 349 362 350 try: 363 - add_observation(facet, resolved_name, content, observation_number, source_day) 364 - except (ValueError, ObservationNumberError) as exc: 351 + add_observation(facet, resolved_name, content, source_day) 352 + except ValueError as exc: 365 353 typer.echo(f"Error: {exc}", err=True) 366 354 raise typer.Exit(1) 367 355 ··· 372 360 "entity": entity, 373 361 "name": resolved_name, 374 362 "content": content, 375 - "observation_number": observation_number, 376 363 }, 377 364 ) 378 365 typer.echo(f"Observation added to '{resolved_name}'.")
+6 -24
tests/test_entities.py
··· 9 9 10 10 from think.entities import ( 11 11 DEFAULT_ACTIVITY_TS, 12 - ObservationNumberError, 13 12 add_observation, 14 13 block_journal_entity, 15 14 delete_journal_entity, ··· 1951 1950 1952 1951 1953 1952 def test_add_observation_success(fixture_journal, tmp_path): 1954 - """Test adding observation with correct guard.""" 1953 + """Test adding observations sequentially.""" 1955 1954 os.environ["JOURNAL_PATH"] = str(tmp_path) 1956 1955 1957 - # First observation (observation_number=1 for empty list) 1958 1956 result = add_observation( 1959 - "personal", "Alice", "Prefers async communication", 1, "20250113" 1957 + "personal", "Alice", "Prefers async communication", "20250113" 1960 1958 ) 1961 1959 assert result["count"] == 1 1962 1960 assert len(result["observations"]) == 1 ··· 1964 1962 assert result["observations"][0]["source_day"] == "20250113" 1965 1963 assert "observed_at" in result["observations"][0] 1966 1964 1967 - # Second observation (observation_number=2) 1968 - result = add_observation("personal", "Alice", "Works PST timezone", 2) 1965 + result = add_observation("personal", "Alice", "Works PST timezone") 1969 1966 assert result["count"] == 2 1970 1967 assert len(result["observations"]) == 2 1971 1968 ··· 1974 1971 assert len(loaded) == 2 1975 1972 1976 1973 1977 - def test_add_observation_guard_failure(fixture_journal, tmp_path): 1978 - """Test adding observation with wrong guard fails.""" 1979 - os.environ["JOURNAL_PATH"] = str(tmp_path) 1980 - 1981 - # First observation 1982 - add_observation("personal", "Alice", "First observation", 1) 1983 - 1984 - # Try to add with wrong observation_number (should be 2, not 1) 1985 - with pytest.raises(ObservationNumberError) as exc_info: 1986 - add_observation("personal", "Alice", "Second observation", 1) 1987 - 1988 - assert exc_info.value.expected == 2 1989 - assert exc_info.value.actual == 1 1990 - 1991 - 1992 1974 def test_add_observation_empty_content(fixture_journal, tmp_path): 1993 1975 """Test adding observation with empty content fails.""" 1994 1976 os.environ["JOURNAL_PATH"] = str(tmp_path) 1995 1977 1996 1978 with pytest.raises(ValueError, match="cannot be empty"): 1997 - add_observation("personal", "Alice", "", 1) 1979 + add_observation("personal", "Alice", "") 1998 1980 1999 1981 with pytest.raises(ValueError, match="cannot be empty"): 2000 - add_observation("personal", "Alice", " ", 1) 1982 + add_observation("personal", "Alice", " ") 2001 1983 2002 1984 2003 1985 def test_observations_with_entity_rename(fixture_journal, tmp_path): ··· 2006 1988 2007 1989 # Create entity memory folder and add observations 2008 1990 ensure_entity_memory("work", "Alice Johnson") 2009 - add_observation("work", "Alice Johnson", "Test observation", 1) 1991 + add_observation("work", "Alice Johnson", "Test observation") 2010 1992 2011 1993 # Verify observation exists 2012 1994 observations = load_observations("work", "Alice Johnson")
-4
think/entities/__init__.py
··· 82 82 83 83 # Observations 84 84 from think.entities.observations import ( 85 - ObservationNumberError, 86 85 add_observation, 87 86 load_observations, 88 87 observations_file_path, ··· 105 104 save_detected_entity, 106 105 save_entities, 107 106 update_detected_entity, 108 - update_entity_description, 109 107 ) 110 108 111 109 __all__ = [ ··· 152 150 "save_detected_entity", 153 151 "save_entities", 154 152 "update_detected_entity", 155 - "update_entity_description", 156 153 # Matching 157 154 "find_matching_entity", 158 155 "resolve_entity", ··· 163 160 "touch_entities_from_activity", 164 161 "touch_entity", 165 162 # Observations 166 - "ObservationNumberError", 167 163 "add_observation", 168 164 "load_observations", 169 165 "observations_file_path",
+4 -25
think/entities/observations.py
··· 22 22 from think.utils import now_ms 23 23 24 24 25 - class ObservationNumberError(Exception): 26 - """Raised when observation_number doesn't match expected value.""" 27 - 28 - def __init__(self, expected: int, actual: int): 29 - self.expected = expected 30 - self.actual = actual 31 - super().__init__( 32 - f"Observation number mismatch: expected {expected}, got {actual}" 33 - ) 34 - 35 - 36 25 def observations_file_path(facet: str, name: str) -> Path: 37 26 """Return path to observations file for an entity. 38 27 ··· 111 100 facet: str, 112 101 name: str, 113 102 content: str, 114 - observation_number: int, 115 103 source_day: str | None = None, 116 104 max_retries: int = 3, 117 105 ) -> dict[str, Any]: 118 - """Add an observation to an entity with guard validation and file locking. 106 + """Add an observation to an entity with file locking. 119 107 120 108 Acquires an exclusive file lock to serialize concurrent writes to the 121 - same entity's observations file. Requires the caller to provide the 122 - expected next observation number (current count + 1) to prevent stale 123 - writes. 109 + same entity's observations file. 124 110 125 111 Args: 126 112 facet: Facet name 127 113 name: Entity name 128 114 content: The observation text 129 - observation_number: Expected next number; must be current_count + 1 130 115 source_day: Optional day (YYYYMMDD) when observation was made 131 116 max_retries: Maximum attempts on transient OS errors (default 3) 132 117 ··· 134 119 Dictionary with updated observations list and count 135 120 136 121 Raises: 137 - ObservationNumberError: If observation_number doesn't match expected 138 122 ValueError: If content is empty 139 123 OSError: If all retries exhausted 140 124 141 125 Example: 142 - >>> add_observation("work", "Alice", "Prefers morning meetings", 1, "20250113") 126 + >>> add_observation("work", "Alice", "Prefers morning meetings", "20250113") 143 127 {"observations": [...], "count": 1} 144 128 """ 145 129 content = content.strip() ··· 156 140 with open(lock_path, "w") as lock_file: 157 141 fcntl.flock(lock_file, fcntl.LOCK_EX) 158 142 try: 159 - # Fresh load inside lock 160 143 observations = load_observations(facet, name) 161 - expected = len(observations) + 1 162 - 163 - if observation_number != expected: 164 - raise ObservationNumberError(expected, observation_number) 165 144 166 145 observation: dict[str, Any] = { 167 146 "content": content, ··· 179 158 } 180 159 finally: 181 160 fcntl.flock(lock_file, fcntl.LOCK_UN) 182 - except (ValueError, ObservationNumberError): 161 + except ValueError: 183 162 raise # Logical errors — don't retry 184 163 except OSError as exc: 185 164 last_error = exc