personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-lkrr7qi4'

+724
+292
apps/entities/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for entity management. 5 + 6 + Provides human-friendly CLI access to entity operations, paralleling the 7 + MCP tools in ``apps/entities/tools.py`` but optimized for terminal use. 8 + 9 + Auto-discovered by ``think.call`` and mounted as ``sol call entities ...``. 10 + """ 11 + 12 + import re 13 + 14 + import typer 15 + 16 + from think.entities.core import is_valid_entity_type 17 + from think.entities.loading import load_entities 18 + from think.entities.matching import resolve_entity, validate_aka_uniqueness 19 + from think.entities.observations import add_observation, load_observations 20 + from think.entities.saving import save_entities 21 + from think.utils import now_ms 22 + 23 + app = typer.Typer(help="Entity management.") 24 + 25 + 26 + def _resolve_or_exit(facet: str, entity: str) -> dict: 27 + """Resolve entity or exit with CLI error.""" 28 + resolved, candidates = resolve_entity(facet, entity) 29 + if resolved: 30 + return resolved 31 + 32 + blocked_match, _ = resolve_entity(facet, entity, include_blocked=True) 33 + if blocked_match and blocked_match.get("blocked"): 34 + name = blocked_match.get("name", entity) 35 + typer.echo(f"Error: Entity '{name}' is blocked.", err=True) 36 + raise typer.Exit(1) 37 + 38 + if candidates: 39 + names = ", ".join(c.get("name", "") for c in candidates[:3]) 40 + typer.echo( 41 + f"Error: Entity '{entity}' not found. Did you mean: {names}", err=True 42 + ) 43 + raise typer.Exit(1) 44 + 45 + typer.echo(f"Error: Entity '{entity}' not found in facet '{facet}'.", err=True) 46 + raise typer.Exit(1) 47 + 48 + 49 + @app.command("list") 50 + def list_entities( 51 + facet: str = typer.Argument(help="Facet name."), 52 + day: str | None = typer.Option( 53 + None, "--day", "-d", help="Day (YYYYMMDD) for detected entities." 54 + ), 55 + ) -> None: 56 + """List entities for a facet.""" 57 + entities = load_entities(facet, day) 58 + if not entities: 59 + typer.echo("No entities found.") 60 + return 61 + 62 + label = f"detected for {day}" if day else "attached" 63 + typer.echo(f"{len(entities)} {label} entities:") 64 + for e in entities: 65 + typer.echo(f" - {e.get('name')} ({e.get('type')}): {e.get('description', '')}") 66 + 67 + 68 + @app.command("detect") 69 + def detect_entity( 70 + day: str = typer.Argument(help="Day (YYYYMMDD)."), 71 + facet: str = typer.Argument(help="Facet name."), 72 + type_: str = typer.Argument(metavar="TYPE", help="Entity type."), 73 + entity: str = typer.Argument(help="Entity name or identifier."), 74 + description: str = typer.Argument(help="Description."), 75 + ) -> None: 76 + """Record a detected entity for a day in a facet.""" 77 + if not is_valid_entity_type(type_): 78 + typer.echo(f"Error: Invalid entity type '{type_}'.", err=True) 79 + raise typer.Exit(1) 80 + 81 + resolved, _ = resolve_entity(facet, entity) 82 + 83 + if not resolved: 84 + blocked_match, _ = resolve_entity(facet, entity, include_blocked=True) 85 + if blocked_match and blocked_match.get("blocked"): 86 + name = blocked_match.get("name", entity) 87 + typer.echo(f"Error: Entity '{name}' is blocked.", err=True) 88 + raise typer.Exit(1) 89 + 90 + name = resolved.get("name", entity) if resolved else entity 91 + existing = load_entities(facet, day) 92 + 93 + name_lower = name.lower() 94 + for e in existing: 95 + if e.get("name", "").lower() == name_lower: 96 + typer.echo(f"Error: Entity '{name}' already detected for {day}.", err=True) 97 + raise typer.Exit(1) 98 + 99 + existing.append({"type": type_, "name": name, "description": description}) 100 + save_entities(facet, existing, day) 101 + 102 + typer.echo(f"Entity '{name}' detected for {day}.") 103 + 104 + 105 + @app.command("attach") 106 + def attach_entity( 107 + facet: str = typer.Argument(help="Facet name."), 108 + type_: str = typer.Argument(metavar="TYPE", help="Entity type."), 109 + entity: str = typer.Argument(help="Entity name."), 110 + description: str = typer.Argument(help="Description."), 111 + ) -> None: 112 + """Attach an entity permanently to a facet.""" 113 + if not is_valid_entity_type(type_): 114 + typer.echo(f"Error: Invalid entity type '{type_}'.", err=True) 115 + raise typer.Exit(1) 116 + 117 + resolved, _ = resolve_entity( 118 + facet, entity, include_detached=True, include_blocked=True 119 + ) 120 + 121 + if resolved and resolved.get("blocked"): 122 + name = resolved.get("name", entity) 123 + typer.echo(f"Error: Entity '{name}' is blocked.", err=True) 124 + raise typer.Exit(1) 125 + 126 + if resolved and resolved.get("detached"): 127 + name = resolved.get("name", entity) 128 + typer.echo( 129 + f"Error: Entity '{name}' was previously removed by the user.", err=True 130 + ) 131 + raise typer.Exit(1) 132 + 133 + if resolved: 134 + typer.echo(f"Entity '{resolved.get('name')}' already attached.") 135 + return 136 + 137 + name = entity 138 + existing = load_entities( 139 + facet, day=None, include_detached=True, include_blocked=True 140 + ) 141 + now = now_ms() 142 + existing.append( 143 + { 144 + "type": type_, 145 + "name": name, 146 + "description": description, 147 + "attached_at": now, 148 + "updated_at": now, 149 + } 150 + ) 151 + save_entities(facet, existing, day=None) 152 + 153 + typer.echo(f"Entity '{name}' attached.") 154 + 155 + 156 + @app.command("update") 157 + def update_entity( 158 + facet: str = typer.Argument(help="Facet name."), 159 + entity: str = typer.Argument(help="Entity name or identifier."), 160 + description: str = typer.Argument(help="New description."), 161 + day: str | None = typer.Option( 162 + None, "--day", "-d", help="Day for detected entities." 163 + ), 164 + ) -> None: 165 + """Update an entity description.""" 166 + if day is None: 167 + resolved = _resolve_or_exit(facet, entity) 168 + resolved_name = resolved.get("name", entity) 169 + entities = load_entities( 170 + facet, day=None, include_detached=True, include_blocked=True 171 + ) 172 + 173 + target = None 174 + for e in entities: 175 + if not e.get("detached") and e.get("name") == resolved_name: 176 + target = e 177 + break 178 + 179 + if target is None: 180 + typer.echo(f"Error: Entity '{resolved_name}' not found.", err=True) 181 + raise typer.Exit(1) 182 + 183 + target["description"] = description 184 + target["updated_at"] = now_ms() 185 + save_entities(facet, entities, day=None) 186 + typer.echo(f"Entity '{resolved_name}' updated.") 187 + return 188 + 189 + entities = load_entities(facet, day) 190 + target = None 191 + for e in entities: 192 + if e.get("name") == entity: 193 + target = e 194 + break 195 + 196 + if target is None: 197 + typer.echo(f"Error: Entity '{entity}' not found for {day}.", err=True) 198 + raise typer.Exit(1) 199 + 200 + target["description"] = description 201 + save_entities(facet, entities, day) 202 + typer.echo(f"Entity '{entity}' updated for {day}.") 203 + 204 + 205 + @app.command("aka") 206 + def add_aka( 207 + facet: str = typer.Argument(help="Facet name."), 208 + entity: str = typer.Argument(help="Entity name or identifier."), 209 + aka_value: str = typer.Argument(metavar="AKA", help="Alias to add."), 210 + ) -> None: 211 + """Add an alias to an attached entity.""" 212 + resolved = _resolve_or_exit(facet, entity) 213 + resolved_name = resolved.get("name", "") 214 + 215 + base_name = re.sub(r"\s*\([^)]+\)", "", resolved_name).strip() 216 + first_word = base_name.split()[0] if base_name else None 217 + if first_word and aka_value.lower() == first_word.lower(): 218 + typer.echo( 219 + f"Alias '{aka_value}' is the first word of '{resolved_name}' (skipped)." 220 + ) 221 + return 222 + 223 + aka_list = resolved.get("aka", []) 224 + if not isinstance(aka_list, list): 225 + aka_list = [] 226 + 227 + if aka_value in aka_list: 228 + typer.echo(f"Alias '{aka_value}' already exists for '{resolved_name}'.") 229 + return 230 + 231 + entities = load_entities( 232 + facet, day=None, include_detached=True, include_blocked=True 233 + ) 234 + conflict = validate_aka_uniqueness( 235 + aka_value, entities, exclude_entity_name=resolved_name 236 + ) 237 + if conflict: 238 + typer.echo( 239 + f"Error: Alias '{aka_value}' conflicts with entity '{conflict}'.", err=True 240 + ) 241 + raise typer.Exit(1) 242 + 243 + for e in entities: 244 + if e.get("name") == resolved_name: 245 + aka_list.append(aka_value) 246 + e["aka"] = aka_list 247 + e["updated_at"] = now_ms() 248 + break 249 + 250 + save_entities(facet, entities, day=None) 251 + typer.echo(f"Added alias '{aka_value}' to '{resolved_name}'.") 252 + 253 + 254 + @app.command("observations") 255 + def list_observations( 256 + facet: str = typer.Argument(help="Facet name."), 257 + entity: str = typer.Argument(help="Entity name or identifier."), 258 + ) -> None: 259 + """List observations for an attached entity.""" 260 + resolved = _resolve_or_exit(facet, entity) 261 + resolved_name = resolved.get("name", "") 262 + obs = load_observations(facet, resolved_name) 263 + 264 + if not obs: 265 + typer.echo(f"No observations for '{resolved_name}'.") 266 + return 267 + 268 + typer.echo(f"{len(obs)} observations for '{resolved_name}':") 269 + for i, o in enumerate(obs, 1): 270 + typer.echo(f" {i}. {o.get('content', '')}") 271 + 272 + 273 + @app.command("observe") 274 + def observe_entity( 275 + facet: str = typer.Argument(help="Facet name."), 276 + entity: str = typer.Argument(help="Entity name or identifier."), 277 + content: str = typer.Argument(help="Observation content."), 278 + source_day: str | None = typer.Option(None, "--source-day", help="Day (YYYYMMDD)."), 279 + ) -> None: 280 + """Add an observation to an attached entity.""" 281 + resolved = _resolve_or_exit(facet, entity) 282 + resolved_name = resolved.get("name", "") 283 + obs = load_observations(facet, resolved_name) 284 + observation_number = len(obs) + 1 285 + 286 + try: 287 + add_observation(facet, resolved_name, content, observation_number, source_day) 288 + except ValueError as exc: 289 + typer.echo(f"Error: {exc}", err=True) 290 + raise typer.Exit(1) 291 + 292 + typer.echo(f"Observation added to '{resolved_name}'.")
+44
apps/entities/tests/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Self-contained fixtures for entities app tests.""" 5 + 6 + from __future__ import annotations 7 + 8 + import pytest 9 + 10 + from think.entities.observations import add_observation 11 + from think.entities.saving import save_entities 12 + 13 + 14 + @pytest.fixture 15 + def entity_env(tmp_path, monkeypatch): 16 + """Create a temporary journal with entity data. 17 + 18 + Usage: 19 + def test_example(entity_env): 20 + entity_env(attached=[ 21 + {"type": "Person", "name": "Alice", "description": "Friend"} 22 + ]) 23 + # JOURNAL_PATH is set, entity files exist 24 + """ 25 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 26 + 27 + def _create( 28 + attached: list[dict] | None = None, 29 + detected: list[dict] | None = None, 30 + day: str | None = None, 31 + facet: str = "personal", 32 + observations: list[str] | None = None, 33 + observation_entity: str | None = None, 34 + ): 35 + if attached: 36 + save_entities(facet, attached, day=None) 37 + if detected and day: 38 + save_entities(facet, detected, day=day) 39 + if observations and observation_entity: 40 + for i, content in enumerate(observations, 1): 41 + add_observation(facet, observation_entity, content, i) 42 + return tmp_path 43 + 44 + return _create
+388
apps/entities/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for entities CLI commands (sol call entities ...).""" 5 + 6 + from typer.testing import CliRunner 7 + 8 + from think.call import call_app 9 + 10 + runner = CliRunner() 11 + 12 + 13 + class TestEntitiesList: 14 + def test_list_attached(self, entity_env): 15 + entity_env( 16 + attached=[ 17 + { 18 + "type": "Person", 19 + "name": "Alice Johnson", 20 + "description": "Friend", 21 + "attached_at": 1000, 22 + "updated_at": 1000, 23 + }, 24 + { 25 + "type": "Company", 26 + "name": "Acme Corp", 27 + "description": "Client", 28 + "attached_at": 1001, 29 + "updated_at": 1001, 30 + }, 31 + ] 32 + ) 33 + 34 + result = runner.invoke(call_app, ["entities", "list", "personal"]) 35 + 36 + assert result.exit_code == 0 37 + assert "2 attached entities" in result.output 38 + assert "Alice Johnson" in result.output 39 + assert "Acme Corp" in result.output 40 + 41 + def test_list_detected(self, entity_env): 42 + entity_env( 43 + detected=[ 44 + { 45 + "type": "Person", 46 + "name": "Alice", 47 + "description": "Met at conference", 48 + }, 49 + { 50 + "type": "Tool", 51 + "name": "pytest", 52 + "description": "Testing tool", 53 + }, 54 + ], 55 + day="20240101", 56 + ) 57 + 58 + result = runner.invoke( 59 + call_app, ["entities", "list", "personal", "--day", "20240101"] 60 + ) 61 + 62 + assert result.exit_code == 0 63 + assert "Alice" in result.output 64 + assert "pytest" in result.output 65 + 66 + def test_list_empty(self, entity_env): 67 + entity_env() 68 + 69 + result = runner.invoke(call_app, ["entities", "list", "personal"]) 70 + 71 + assert result.exit_code == 0 72 + assert "No entities found" in result.output 73 + 74 + 75 + class TestEntitiesDetect: 76 + def test_detect_new(self, entity_env): 77 + entity_env() 78 + 79 + result = runner.invoke( 80 + call_app, 81 + [ 82 + "entities", 83 + "detect", 84 + "20240101", 85 + "personal", 86 + "Person", 87 + "Alice", 88 + "Met at conference", 89 + ], 90 + ) 91 + 92 + assert result.exit_code == 0 93 + assert "detected" in result.output 94 + 95 + def test_detect_duplicate(self, entity_env): 96 + entity_env( 97 + detected=[ 98 + {"type": "Person", "name": "Alice", "description": "First"}, 99 + ], 100 + day="20240101", 101 + ) 102 + 103 + result = runner.invoke( 104 + call_app, 105 + [ 106 + "entities", 107 + "detect", 108 + "20240101", 109 + "personal", 110 + "Person", 111 + "Alice", 112 + "Second", 113 + ], 114 + ) 115 + 116 + assert result.exit_code == 1 117 + assert "already detected" in result.output 118 + 119 + def test_detect_invalid_type(self, entity_env): 120 + entity_env() 121 + 122 + result = runner.invoke( 123 + call_app, 124 + [ 125 + "entities", 126 + "detect", 127 + "20240101", 128 + "personal", 129 + "AB", 130 + "Alice", 131 + "Met at conference", 132 + ], 133 + ) 134 + 135 + assert result.exit_code == 1 136 + assert "Invalid" in result.output 137 + 138 + 139 + class TestEntitiesAttach: 140 + def test_attach_new(self, entity_env): 141 + entity_env() 142 + 143 + result = runner.invoke( 144 + call_app, 145 + ["entities", "attach", "personal", "Person", "Alice Johnson", "Friend"], 146 + ) 147 + 148 + assert result.exit_code == 0 149 + assert "attached" in result.output 150 + 151 + def test_attach_existing(self, entity_env): 152 + entity_env( 153 + attached=[ 154 + { 155 + "type": "Person", 156 + "name": "Alice Johnson", 157 + "description": "Friend", 158 + "attached_at": 1000, 159 + "updated_at": 1000, 160 + } 161 + ] 162 + ) 163 + 164 + result = runner.invoke( 165 + call_app, 166 + ["entities", "attach", "personal", "Person", "Alice Johnson", "Friend"], 167 + ) 168 + 169 + assert result.exit_code == 0 170 + assert "already attached" in result.output 171 + 172 + def test_attach_invalid_type(self, entity_env): 173 + entity_env() 174 + 175 + result = runner.invoke( 176 + call_app, 177 + ["entities", "attach", "personal", "AB", "Alice Johnson", "Friend"], 178 + ) 179 + 180 + assert result.exit_code == 1 181 + assert "Invalid" in result.output 182 + 183 + 184 + class TestEntitiesUpdate: 185 + def test_update_attached(self, entity_env): 186 + entity_env( 187 + attached=[ 188 + { 189 + "type": "Person", 190 + "name": "Alice Johnson", 191 + "description": "Old", 192 + "attached_at": 1000, 193 + "updated_at": 1000, 194 + } 195 + ] 196 + ) 197 + 198 + result = runner.invoke( 199 + call_app, 200 + ["entities", "update", "personal", "Alice Johnson", "New description"], 201 + ) 202 + verify = runner.invoke(call_app, ["entities", "list", "personal"]) 203 + 204 + assert result.exit_code == 0 205 + assert "updated" in result.output 206 + assert "New description" in verify.output 207 + 208 + def test_update_detected(self, entity_env): 209 + entity_env( 210 + detected=[ 211 + {"type": "Person", "name": "Alice", "description": "Old"}, 212 + ], 213 + day="20240101", 214 + ) 215 + 216 + result = runner.invoke( 217 + call_app, 218 + [ 219 + "entities", 220 + "update", 221 + "personal", 222 + "Alice", 223 + "New desc", 224 + "--day", 225 + "20240101", 226 + ], 227 + ) 228 + 229 + assert result.exit_code == 0 230 + assert "updated" in result.output 231 + 232 + def test_update_not_found(self, entity_env): 233 + entity_env() 234 + 235 + result = runner.invoke( 236 + call_app, 237 + ["entities", "update", "personal", "Missing", "New description"], 238 + ) 239 + 240 + assert result.exit_code == 1 241 + assert "not found" in result.output 242 + 243 + 244 + class TestEntitiesAka: 245 + def test_add_aka(self, entity_env): 246 + entity_env( 247 + attached=[ 248 + { 249 + "type": "Person", 250 + "name": "Alice Johnson", 251 + "description": "Friend", 252 + "attached_at": 1000, 253 + "updated_at": 1000, 254 + } 255 + ] 256 + ) 257 + 258 + result = runner.invoke( 259 + call_app, 260 + ["entities", "aka", "personal", "Alice Johnson", "Ali"], 261 + ) 262 + 263 + assert result.exit_code == 0 264 + assert "Added alias" in result.output 265 + 266 + def test_aka_duplicate(self, entity_env): 267 + entity_env( 268 + attached=[ 269 + { 270 + "type": "Person", 271 + "name": "Alice Johnson", 272 + "description": "Friend", 273 + "attached_at": 1000, 274 + "updated_at": 1000, 275 + "aka": ["Ali"], 276 + } 277 + ] 278 + ) 279 + 280 + result = runner.invoke( 281 + call_app, 282 + ["entities", "aka", "personal", "Alice Johnson", "Ali"], 283 + ) 284 + 285 + assert result.exit_code == 0 286 + assert "already exists" in result.output 287 + 288 + def test_aka_first_word(self, entity_env): 289 + entity_env( 290 + attached=[ 291 + { 292 + "type": "Person", 293 + "name": "Alice Johnson", 294 + "description": "Friend", 295 + "attached_at": 1000, 296 + "updated_at": 1000, 297 + } 298 + ] 299 + ) 300 + 301 + result = runner.invoke( 302 + call_app, 303 + ["entities", "aka", "personal", "Alice Johnson", "Alice"], 304 + ) 305 + 306 + assert result.exit_code == 0 307 + assert "first word" in result.output 308 + 309 + 310 + class TestEntitiesObservations: 311 + def test_observations_empty(self, entity_env): 312 + entity_env( 313 + attached=[ 314 + { 315 + "type": "Person", 316 + "name": "Alice Johnson", 317 + "description": "Friend", 318 + "attached_at": 1000, 319 + "updated_at": 1000, 320 + } 321 + ] 322 + ) 323 + 324 + result = runner.invoke( 325 + call_app, 326 + ["entities", "observations", "personal", "Alice Johnson"], 327 + ) 328 + 329 + assert result.exit_code == 0 330 + assert "No observations" in result.output 331 + 332 + def test_observations_with_data(self, entity_env): 333 + entity_env( 334 + attached=[ 335 + { 336 + "type": "Person", 337 + "name": "Alice Johnson", 338 + "description": "Friend", 339 + "attached_at": 1000, 340 + "updated_at": 1000, 341 + } 342 + ], 343 + observations=["Likes coffee", "Expert in Python"], 344 + observation_entity="Alice Johnson", 345 + ) 346 + 347 + result = runner.invoke( 348 + call_app, 349 + ["entities", "observations", "personal", "Alice Johnson"], 350 + ) 351 + 352 + assert result.exit_code == 0 353 + assert "Likes coffee" in result.output 354 + assert "Expert in Python" in result.output 355 + 356 + 357 + class TestEntitiesObserve: 358 + def test_observe_new(self, entity_env): 359 + entity_env( 360 + attached=[ 361 + { 362 + "type": "Person", 363 + "name": "Alice Johnson", 364 + "description": "Friend", 365 + "attached_at": 1000, 366 + "updated_at": 1000, 367 + } 368 + ] 369 + ) 370 + 371 + result = runner.invoke( 372 + call_app, 373 + ["entities", "observe", "personal", "Alice Johnson", "Likes coffee"], 374 + ) 375 + 376 + assert result.exit_code == 0 377 + assert "Observation added" in result.output 378 + 379 + def test_observe_not_found(self, entity_env): 380 + entity_env() 381 + 382 + result = runner.invoke( 383 + call_app, 384 + ["entities", "observe", "personal", "Missing", "Likes coffee"], 385 + ) 386 + 387 + assert result.exit_code == 1 388 + assert "not found" in result.output