personal memory agent
0
fork

Configure Feed

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

think/surfaces: add profile consumer surface (cadence + ledger composition)

Second of three V1 consumer surfaces after Ledger. Profile is an
entity-centric, read-time, no-cache library: cadence math from
participation records composed with per-counterparty Ledger state.
Exposes full/brief/cadence/list_active via library and sol call profile.

+1298
+795
tests/test_surfaces_profile.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + import re 6 + from datetime import UTC, datetime 7 + 8 + from typer.testing import CliRunner 9 + 10 + from think.surfaces.types import Cadence 11 + 12 + _RUNNER = CliRunner() 13 + 14 + 15 + def _configure_env(tmp_path, monkeypatch) -> None: 16 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 17 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 18 + 19 + import think.utils 20 + from think.entities.journal import clear_journal_entity_cache 21 + from think.entities.loading import clear_entity_loading_cache 22 + from think.entities.relationships import clear_relationship_caches 23 + 24 + think.utils._journal_path_cache = None 25 + clear_journal_entity_cache() 26 + clear_entity_loading_cache() 27 + clear_relationship_caches() 28 + 29 + 30 + def _write_journal_entity( 31 + tmp_path, 32 + entity_id: str, 33 + name: str, 34 + *, 35 + entity_type: str = "Person", 36 + aka: list[str] | None = None, 37 + is_principal: bool = False, 38 + ) -> None: 39 + entity_dir = tmp_path / "entities" / entity_id 40 + entity_dir.mkdir(parents=True, exist_ok=True) 41 + payload: dict[str, object] = {"id": entity_id, "name": name, "type": entity_type} 42 + if aka: 43 + payload["aka"] = aka 44 + if is_principal: 45 + payload["is_principal"] = True 46 + (entity_dir / "entity.json").write_text(json.dumps(payload), encoding="utf-8") 47 + 48 + 49 + def _write_facet_relationship( 50 + tmp_path, facet: str, entity_id: str, *, description: str = "" 51 + ) -> None: 52 + relationship_dir = tmp_path / "facets" / facet / "entities" / entity_id 53 + relationship_dir.mkdir(parents=True, exist_ok=True) 54 + (relationship_dir / "entity.json").write_text( 55 + json.dumps({"entity_id": entity_id, "description": description}), 56 + encoding="utf-8", 57 + ) 58 + 59 + 60 + def _minimal_facet_tree( 61 + tmp_path, 62 + facets=("work",), 63 + *, 64 + muted_facets=(), 65 + journal_entities: tuple[dict[str, object], ...] = (), 66 + ) -> None: 67 + for facet in facets: 68 + facet_dir = tmp_path / "facets" / facet 69 + facet_dir.mkdir(parents=True, exist_ok=True) 70 + (facet_dir / "activities").mkdir(exist_ok=True) 71 + (facet_dir / "facet.json").write_text( 72 + json.dumps( 73 + { 74 + "title": facet.title(), 75 + "description": "", 76 + "color": "", 77 + "emoji": "", 78 + "muted": facet in set(muted_facets), 79 + } 80 + ), 81 + encoding="utf-8", 82 + ) 83 + 84 + for entity in journal_entities: 85 + _write_journal_entity( 86 + tmp_path, 87 + str(entity["id"]), 88 + str(entity["name"]), 89 + entity_type=str(entity.get("type", "Person")), 90 + aka=list(entity.get("aka", [])) 91 + if isinstance(entity.get("aka"), list) 92 + else None, 93 + is_principal=bool(entity.get("is_principal", False)), 94 + ) 95 + 96 + 97 + def _utc_ms(day: str, hour: int = 12) -> int: 98 + parsed = datetime.strptime(f"{day} {hour:02d}:00:00", "%Y%m%d %H:%M:%S") 99 + return int(parsed.replace(tzinfo=UTC).timestamp() * 1000) 100 + 101 + 102 + def _participant( 103 + entity_id: str | None, 104 + *, 105 + name: str | None = None, 106 + role: str = "attendee", 107 + source: str = "screen", 108 + confidence: float = 0.9, 109 + context: str = "test participant", 110 + ) -> dict[str, object]: 111 + display_name = name or ( 112 + entity_id.replace("_", " ").title() if entity_id else "Unknown" 113 + ) 114 + return { 115 + "name": display_name, 116 + "role": role, 117 + "source": source, 118 + "confidence": confidence, 119 + "context": context, 120 + "entity_id": entity_id, 121 + } 122 + 123 + 124 + def _activity_record( 125 + day: str, participation: list[dict[str, object]], **kwargs 126 + ) -> dict: 127 + activity = str(kwargs.pop("activity", "meeting")) 128 + record_id = str(kwargs.pop("record_id", f"{activity}_{day}_120000")) 129 + title = str(kwargs.pop("title", f"{record_id} title")) 130 + record = { 131 + "id": record_id, 132 + "activity": activity, 133 + "title": title, 134 + "description": str(kwargs.pop("description", title)), 135 + "details": str(kwargs.pop("details", "")), 136 + "participation": participation, 137 + "segments": list(kwargs.pop("segments", [])), 138 + "active_entities": list(kwargs.pop("active_entities", [])), 139 + "created_at": int(kwargs.pop("created_at", _utc_ms(day))), 140 + "source": str(kwargs.pop("source", "user")), 141 + "hidden": bool(kwargs.pop("hidden", False)), 142 + "edits": list(kwargs.pop("edits", [])), 143 + } 144 + record.update(kwargs) 145 + return record 146 + 147 + 148 + def _append_activity(facet: str, day: str, record: dict) -> None: 149 + from think.activities import append_activity_record 150 + 151 + append_activity_record(facet, day, record) 152 + 153 + 154 + def _commitment( 155 + *, 156 + owner: str = "Mina", 157 + owner_entity_id: str | None = "mina", 158 + action: str = "send proposal", 159 + counterparty: str = "Ravi", 160 + counterparty_entity_id: str | None = "ravi", 161 + when: str = "tomorrow", 162 + context: str = "Commitment context.", 163 + ) -> dict[str, object]: 164 + return { 165 + "owner": owner, 166 + "owner_entity_id": owner_entity_id, 167 + "action": action, 168 + "counterparty": counterparty, 169 + "counterparty_entity_id": counterparty_entity_id, 170 + "when": when, 171 + "context": context, 172 + } 173 + 174 + 175 + def _decision( 176 + *, 177 + owner: str = "Ravi", 178 + owner_entity_id: str | None = "ravi", 179 + action: str = "move launch review", 180 + context: str = "Decision context.", 181 + ) -> dict[str, object]: 182 + return { 183 + "owner": owner, 184 + "owner_entity_id": owner_entity_id, 185 + "action": action, 186 + "context": context, 187 + } 188 + 189 + 190 + def _write_story_activity( 191 + facet: str, 192 + day: str, 193 + record_id: str, 194 + created_at: int, 195 + *, 196 + commitments: list[dict[str, object]] | None = None, 197 + closures: list[dict[str, object]] | None = None, 198 + decisions: list[dict[str, object]] | None = None, 199 + ) -> None: 200 + from think.activities import append_activity_record, merge_story_fields 201 + 202 + append_activity_record( 203 + facet, 204 + day, 205 + _activity_record( 206 + day, 207 + [], 208 + record_id=record_id, 209 + created_at=created_at, 210 + title=f"{record_id} title", 211 + ), 212 + ) 213 + merge_story_fields( 214 + facet, 215 + day, 216 + record_id, 217 + story={ 218 + "talent": "story", 219 + "body": f"{record_id} summary", 220 + "topics": ["profile"], 221 + "confidence": 0.9, 222 + }, 223 + commitments=commitments or [], 224 + closures=closures or [], 225 + decisions=decisions or [], 226 + actor="story", 227 + ) 228 + 229 + 230 + def test_cadence_zero_interactions(tmp_path, monkeypatch): 231 + from think.surfaces import profile as profile_surface 232 + 233 + _configure_env(tmp_path, monkeypatch) 234 + _minimal_facet_tree( 235 + tmp_path, 236 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 237 + ) 238 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 239 + 240 + assert profile_surface.cadence("Ravi") == Cadence(0, None, None, None) 241 + 242 + 243 + def test_cadence_single_day(tmp_path, monkeypatch): 244 + from think.surfaces import profile as profile_surface 245 + 246 + _configure_env(tmp_path, monkeypatch) 247 + _minimal_facet_tree( 248 + tmp_path, 249 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 250 + ) 251 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 252 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 253 + 254 + _append_activity( 255 + "work", 256 + "20260418", 257 + _activity_record( 258 + "20260418", 259 + [_participant("ravi", name="Ravi")], 260 + record_id="meeting_20260418_a", 261 + ), 262 + ) 263 + 264 + cadence = profile_surface.cadence("Ravi") 265 + assert cadence == Cadence(1, "20260418", None, None) 266 + 267 + 268 + def test_cadence_multi_day(tmp_path, monkeypatch): 269 + from think.surfaces import profile as profile_surface 270 + 271 + _configure_env(tmp_path, monkeypatch) 272 + _minimal_facet_tree( 273 + tmp_path, 274 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 275 + ) 276 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 277 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 278 + 279 + for index, day in enumerate(["20260410", "20260414", "20260418"], start=1): 280 + _append_activity( 281 + "work", 282 + day, 283 + _activity_record( 284 + day, 285 + [_participant("ravi", name="Ravi")], 286 + record_id=f"meeting_{index}", 287 + ), 288 + ) 289 + 290 + cadence = profile_surface.cadence("Ravi") 291 + assert cadence is not None 292 + assert cadence.interactions_90d == 3 293 + assert cadence.last_seen == "20260418" 294 + assert cadence.avg_interval_days == 4.0 295 + assert cadence.gone_quiet_since is None 296 + 297 + 298 + def test_cadence_gone_quiet_threshold(tmp_path, monkeypatch): 299 + from think.surfaces import profile as profile_surface 300 + 301 + _configure_env(tmp_path, monkeypatch) 302 + _minimal_facet_tree( 303 + tmp_path, 304 + journal_entities=( 305 + {"id": "quiet_person", "name": "Quiet Person", "type": "Person"}, 306 + {"id": "boundary_person", "name": "Boundary Person", "type": "Person"}, 307 + ), 308 + ) 309 + _write_facet_relationship(tmp_path, "work", "quiet_person", description="Quiet") 310 + _write_facet_relationship( 311 + tmp_path, "work", "boundary_person", description="Boundary" 312 + ) 313 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 314 + 315 + for day in ["20260401", "20260406"]: 316 + _append_activity( 317 + "work", 318 + day, 319 + _activity_record( 320 + day, 321 + [_participant("quiet_person", name="Quiet Person")], 322 + record_id=f"quiet_{day}", 323 + ), 324 + ) 325 + for day in ["20260405", "20260410"]: 326 + _append_activity( 327 + "work", 328 + day, 329 + _activity_record( 330 + day, 331 + [_participant("boundary_person", name="Boundary Person")], 332 + record_id=f"boundary_{day}", 333 + ), 334 + ) 335 + 336 + quiet = profile_surface.cadence("Quiet Person") 337 + boundary = profile_surface.cadence("Boundary Person") 338 + 339 + assert quiet is not None 340 + assert quiet.gone_quiet_since == "20260406" 341 + assert boundary is not None 342 + assert boundary.gone_quiet_since is None 343 + 344 + 345 + def test_cadence_distinct_days_vs_record_count(tmp_path, monkeypatch): 346 + from think.surfaces import profile as profile_surface 347 + 348 + _configure_env(tmp_path, monkeypatch) 349 + _minimal_facet_tree( 350 + tmp_path, 351 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 352 + ) 353 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 354 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 355 + 356 + for suffix in ("a", "b", "c"): 357 + _append_activity( 358 + "work", 359 + "20260418", 360 + _activity_record( 361 + "20260418", 362 + [_participant("ravi", name="Ravi")], 363 + record_id=f"meeting_20260418_{suffix}", 364 + ), 365 + ) 366 + 367 + cadence = profile_surface.cadence("Ravi") 368 + assert cadence == Cadence(3, "20260418", None, None) 369 + 370 + 371 + def test_resolve_exact_and_slug_and_aka_and_fuzzy(tmp_path, monkeypatch): 372 + from think.surfaces import profile as profile_surface 373 + 374 + _configure_env(tmp_path, monkeypatch) 375 + _minimal_facet_tree( 376 + tmp_path, 377 + journal_entities=( 378 + { 379 + "id": "john_borthwick", 380 + "name": "John Borthwick", 381 + "type": "Person", 382 + "aka": ["JB"], 383 + }, 384 + ), 385 + ) 386 + _write_facet_relationship( 387 + tmp_path, "work", "john_borthwick", description="Investor" 388 + ) 389 + 390 + queries = ["John Borthwick", "john_borthwick", "JB", "John Borthwik"] 391 + resolved_ids = { 392 + profile_surface._resolve_target(query).entity_id # noqa: SLF001 393 + for query in queries 394 + } 395 + 396 + assert resolved_ids == {"john_borthwick"} 397 + 398 + 399 + def test_facets_filter_narrows_display_not_cadence(tmp_path, monkeypatch): 400 + from think.surfaces import profile as profile_surface 401 + 402 + _configure_env(tmp_path, monkeypatch) 403 + _minimal_facet_tree( 404 + tmp_path, 405 + facets=("personal", "work"), 406 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 407 + ) 408 + _write_facet_relationship(tmp_path, "work", "ravi", description="Work contact") 409 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 410 + _append_activity( 411 + "work", 412 + "20260418", 413 + _activity_record( 414 + "20260418", 415 + [_participant("ravi", name="Ravi")], 416 + record_id="meeting_20260418", 417 + ), 418 + ) 419 + 420 + profile = profile_surface.full("Ravi", facets=["personal"]) 421 + 422 + assert profile is not None 423 + assert profile.facets == () 424 + assert profile.description is None 425 + assert profile.cadence.interactions_90d == 1 426 + 427 + 428 + def test_include_mentions_toggle(tmp_path, monkeypatch): 429 + from think.surfaces import profile as profile_surface 430 + 431 + _configure_env(tmp_path, monkeypatch) 432 + _minimal_facet_tree( 433 + tmp_path, 434 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 435 + ) 436 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 437 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 438 + _append_activity( 439 + "work", 440 + "20260418", 441 + _activity_record( 442 + "20260418", 443 + [_participant("ravi", name="Ravi", role="mentioned")], 444 + record_id="meeting_20260418", 445 + ), 446 + ) 447 + 448 + assert profile_surface.cadence("Ravi") == Cadence(0, None, None, None) 449 + assert profile_surface.cadence("Ravi", include_mentions=True) == Cadence( 450 + 1, "20260418", None, None 451 + ) 452 + 453 + 454 + def test_self_view_returns_is_self_true(tmp_path, monkeypatch): 455 + from think.surfaces import profile as profile_surface 456 + 457 + _configure_env(tmp_path, monkeypatch) 458 + _minimal_facet_tree( 459 + tmp_path, 460 + journal_entities=( 461 + { 462 + "id": "romeo_montague", 463 + "name": "Romeo Montague", 464 + "type": "Person", 465 + "aka": ["RM"], 466 + "is_principal": True, 467 + }, 468 + ), 469 + ) 470 + _write_facet_relationship(tmp_path, "work", "romeo_montague", description="Founder") 471 + 472 + profile = profile_surface.full("RM") 473 + 474 + assert profile is not None 475 + assert profile.is_self is True 476 + 477 + 478 + def test_full_composes_ledger_open_loops(tmp_path, monkeypatch): 479 + from think.surfaces import ledger as ledger_surface 480 + from think.surfaces import profile as profile_surface 481 + 482 + _configure_env(tmp_path, monkeypatch) 483 + _minimal_facet_tree( 484 + tmp_path, 485 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 486 + ) 487 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 488 + _write_story_activity( 489 + "work", 490 + "20260418", 491 + "meeting_090000_300", 492 + _utc_ms("20260418", 9), 493 + commitments=[_commitment(counterparty="Ravi", counterparty_entity_id="ravi")], 494 + ) 495 + 496 + expected = tuple(ledger_surface.list(state="open", counterparty="ravi")) 497 + profile = profile_surface.full("Ravi") 498 + 499 + assert profile is not None 500 + assert profile.open_with_them == expected 501 + 502 + 503 + def test_full_composes_ledger_closed_30d(tmp_path, monkeypatch): 504 + from think.surfaces import ledger as ledger_surface 505 + from think.surfaces import profile as profile_surface 506 + 507 + _configure_env(tmp_path, monkeypatch) 508 + _minimal_facet_tree( 509 + tmp_path, 510 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 511 + ) 512 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 513 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 514 + _write_story_activity( 515 + "work", 516 + "20260410", 517 + "meeting_090000_300", 518 + _utc_ms("20260410", 9), 519 + commitments=[_commitment(counterparty="Ravi", counterparty_entity_id="ravi")], 520 + ) 521 + _write_story_activity( 522 + "work", 523 + "20260415", 524 + "meeting_100000_300", 525 + _utc_ms("20260415", 10), 526 + closures=[ 527 + { 528 + "owner": "Mina", 529 + "owner_entity_id": "mina", 530 + "action": "send proposal", 531 + "counterparty": "Ravi", 532 + "counterparty_entity_id": "ravi", 533 + "resolution": "sent", 534 + "context": "Closure context.", 535 + } 536 + ], 537 + ) 538 + _write_story_activity( 539 + "work", 540 + "20260301", 541 + "meeting_080000_300", 542 + _utc_ms("20260301", 8), 543 + commitments=[ 544 + _commitment( 545 + action="archive contract", 546 + counterparty="Ravi", 547 + counterparty_entity_id="ravi", 548 + ) 549 + ], 550 + ) 551 + _write_story_activity( 552 + "work", 553 + "20260305", 554 + "meeting_083000_300", 555 + _utc_ms("20260305", 8), 556 + closures=[ 557 + { 558 + "owner": "Mina", 559 + "owner_entity_id": "mina", 560 + "action": "archive contract", 561 + "counterparty": "Ravi", 562 + "counterparty_entity_id": "ravi", 563 + "resolution": "archived", 564 + "context": "Old closure context.", 565 + } 566 + ], 567 + ) 568 + 569 + expected = tuple( 570 + ledger_surface.list( 571 + state="closed", 572 + closed_since=profile_surface._day_minus(30), # noqa: SLF001 573 + counterparty="ravi", 574 + ) 575 + ) 576 + profile = profile_surface.full("Ravi") 577 + 578 + assert profile is not None 579 + assert profile.closed_with_them_30d == expected 580 + assert len(profile.closed_with_them_30d) >= 1 581 + assert all( 582 + item.action != "archive contract" for item in profile.closed_with_them_30d 583 + ) 584 + 585 + 586 + def test_full_composes_ledger_decisions(tmp_path, monkeypatch): 587 + from think.surfaces import ledger as ledger_surface 588 + from think.surfaces import profile as profile_surface 589 + 590 + _configure_env(tmp_path, monkeypatch) 591 + _minimal_facet_tree( 592 + tmp_path, 593 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 594 + ) 595 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 596 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 597 + _write_story_activity( 598 + "work", 599 + "20260419", 600 + "meeting_090000_300", 601 + _utc_ms("20260419", 9), 602 + decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], 603 + ) 604 + 605 + expected = tuple( 606 + ledger_surface.decisions( 607 + involving="ravi", 608 + since=profile_surface._day_minus(30), # noqa: SLF001 609 + ) 610 + ) 611 + profile = profile_surface.full("Ravi") 612 + 613 + assert profile is not None 614 + assert profile.decisions_involving_them == expected 615 + assert len(profile.decisions_involving_them) >= 1 616 + 617 + 618 + def test_not_found_returns_none(tmp_path, monkeypatch): 619 + from think.surfaces import profile as profile_surface 620 + from think.tools.profile import app 621 + 622 + _configure_env(tmp_path, monkeypatch) 623 + _minimal_facet_tree(tmp_path) 624 + 625 + assert profile_surface.full("missing") is None 626 + 627 + result = _RUNNER.invoke(app, ["full", "missing"]) 628 + 629 + assert result.exit_code == 1 630 + assert "profile not found: missing" in result.stderr 631 + 632 + 633 + def test_brief_shape_and_counts(tmp_path, monkeypatch): 634 + from think.surfaces import ledger as ledger_surface 635 + from think.surfaces import profile as profile_surface 636 + 637 + _configure_env(tmp_path, monkeypatch) 638 + _minimal_facet_tree( 639 + tmp_path, 640 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 641 + ) 642 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 643 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 644 + _write_story_activity( 645 + "work", 646 + "20260418", 647 + "meeting_090000_300", 648 + _utc_ms("20260418", 9), 649 + commitments=[_commitment(counterparty="Ravi", counterparty_entity_id="ravi")], 650 + ) 651 + _write_story_activity( 652 + "work", 653 + "20260419", 654 + "meeting_100000_300", 655 + _utc_ms("20260419", 10), 656 + decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], 657 + ) 658 + 659 + brief = profile_surface.brief("Ravi") 660 + 661 + assert brief is not None 662 + assert brief.open_loop_count == len( 663 + ledger_surface.list(state="open", counterparty="ravi") 664 + ) 665 + assert brief.decisions_count_30d == len( 666 + ledger_surface.decisions(involving="ravi", since="20260321") 667 + ) 668 + assert brief.last_seen is None 669 + 670 + 671 + def test_list_active_sort_dedup_window(tmp_path, monkeypatch): 672 + from think.surfaces import profile as profile_surface 673 + 674 + _configure_env(tmp_path, monkeypatch) 675 + _minimal_facet_tree(tmp_path, facets=("personal", "work")) 676 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 677 + 678 + _append_activity( 679 + "work", 680 + "20260418", 681 + _activity_record( 682 + "20260418", 683 + [ 684 + _participant("zoe", name="Zoe"), 685 + _participant("anna", name="Anna"), 686 + _participant(None, name="Unknown"), 687 + ], 688 + record_id="meeting_work", 689 + ), 690 + ) 691 + _append_activity( 692 + "personal", 693 + "20260419", 694 + _activity_record( 695 + "20260419", 696 + [_participant("anna", name="Anna")], 697 + record_id="meeting_personal", 698 + ), 699 + ) 700 + _append_activity( 701 + "work", 702 + "20260301", 703 + _activity_record( 704 + "20260301", 705 + [_participant("outside_window", name="Outside")], 706 + record_id="meeting_old", 707 + ), 708 + ) 709 + 710 + assert profile_surface.list_active(window_days=30) == ["anna", "zoe"] 711 + 712 + 713 + def test_list_active_excludes_mentioned(tmp_path, monkeypatch): 714 + from think.surfaces import profile as profile_surface 715 + 716 + _configure_env(tmp_path, monkeypatch) 717 + _minimal_facet_tree(tmp_path) 718 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 719 + _append_activity( 720 + "work", 721 + "20260418", 722 + _activity_record( 723 + "20260418", 724 + [_participant("ravi", name="Ravi", role="mentioned")], 725 + record_id="meeting_mentioned", 726 + ), 727 + ) 728 + 729 + assert profile_surface.list_active(window_days=30) == [] 730 + 731 + 732 + def test_muted_facet_included_in_cadence(tmp_path, monkeypatch): 733 + from think.surfaces import profile as profile_surface 734 + 735 + _configure_env(tmp_path, monkeypatch) 736 + _minimal_facet_tree( 737 + tmp_path, 738 + facets=("quiet",), 739 + muted_facets=("quiet",), 740 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 741 + ) 742 + _write_facet_relationship(tmp_path, "quiet", "ravi", description="Muted facet") 743 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 744 + _append_activity( 745 + "quiet", 746 + "20260418", 747 + _activity_record( 748 + "20260418", 749 + [_participant("ravi", name="Ravi")], 750 + record_id="meeting_quiet", 751 + ), 752 + ) 753 + 754 + cadence = profile_surface.cadence("Ravi") 755 + assert cadence == Cadence(1, "20260418", None, None) 756 + 757 + 758 + def test_utc_day_math(tmp_path, monkeypatch): 759 + from think.surfaces import profile as profile_surface 760 + 761 + _configure_env(tmp_path, monkeypatch) 762 + 763 + assert re.fullmatch(r"\d{8}", profile_surface._today_day()) is not None # noqa: SLF001 764 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 765 + assert profile_surface._day_minus(30) == "20260321" # noqa: SLF001 766 + 767 + 768 + def test_cli_full_json_and_plain(tmp_path, monkeypatch): 769 + from think.tools.profile import app 770 + 771 + _configure_env(tmp_path, monkeypatch) 772 + _minimal_facet_tree( 773 + tmp_path, 774 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 775 + ) 776 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 777 + _append_activity( 778 + "work", 779 + "20260418", 780 + _activity_record( 781 + "20260418", 782 + [_participant("ravi", name="Ravi")], 783 + record_id="meeting_cli", 784 + ), 785 + ) 786 + 787 + plain = _RUNNER.invoke(app, ["full", "Ravi"]) 788 + json_result = _RUNNER.invoke(app, ["full", "Ravi", "--json"]) 789 + 790 + assert plain.exit_code == 0 791 + assert "Cadence:" in plain.stdout 792 + assert json_result.exit_code == 0 793 + payload = json.loads(json_result.stdout) 794 + assert payload["entity_id"] == "ravi" 795 + assert "cadence" in payload
+2
think/call.py
··· 75 75 from think.tools.call import app as journal_app 76 76 from think.tools.ledger import app as ledger_app 77 77 from think.tools.navigate import app as navigate_app 78 + from think.tools.profile import app as profile_app 78 79 from think.tools.routines import app as routines_app 79 80 from think.tools.sol import app as sol_app 80 81 81 82 call_app.add_typer(journal_app, name="journal") 82 83 call_app.add_typer(ledger_app, name="ledger") 84 + call_app.add_typer(profile_app, name="profile") 83 85 call_app.add_typer(navigate_app, name="navigate") 84 86 call_app.add_typer(routines_app, name="routines") 85 87 call_app.add_typer(sol_app, name="identity")
+275
think/surfaces/profile.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Profile consumer surface. 5 + 6 + Cadence math uses distinct interaction days for ``avg_interval_days`` rather 7 + than raw record count. ``gone_quiet_since`` uses a strict ``> avg * 2`` 8 + threshold, not ``>=``. When fewer than two distinct days exist, cadence still 9 + returns ``last_seen`` when available but leaves ``avg_interval_days`` and 10 + ``gone_quiet_since`` unset. Known failure: if an entity is renamed mid-journal, 11 + matching stays on the resolved ``entity_id`` while historical 12 + ``participation[].entity_id`` values keep the old slug, so cadence silently 13 + splits across the rename boundary. 14 + """ 15 + 16 + from __future__ import annotations 17 + 18 + from dataclasses import dataclass 19 + from datetime import UTC, datetime, timedelta 20 + from typing import Iterator 21 + 22 + from think.activities import load_activity_records 23 + from think.entities.journal import load_all_journal_entities 24 + from think.entities.matching import find_matching_entity 25 + from think.entities.relationships import ( 26 + enrich_relationship_with_journal, 27 + load_facet_relationship, 28 + ) 29 + from think.facets import get_facets 30 + from think.surfaces import ledger 31 + from think.surfaces.types import ActivitySourceRef, Cadence, Profile, ProfileBrief 32 + 33 + 34 + @dataclass(frozen=True) 35 + class _ResolvedTarget: 36 + entity_id: str 37 + name: str 38 + type: str 39 + aka: tuple[str, ...] 40 + is_principal: bool 41 + facets_with_desc: dict[str, str] 42 + 43 + 44 + def _today_day() -> str: 45 + return datetime.now(UTC).strftime("%Y%m%d") 46 + 47 + 48 + def _day_minus(days: int) -> str: 49 + today = datetime.strptime(_today_day(), "%Y%m%d").replace(tzinfo=UTC) 50 + return (today - timedelta(days=days)).strftime("%Y%m%d") 51 + 52 + 53 + def _day_to_ordinal(day: str) -> int: 54 + return datetime.strptime(day, "%Y%m%d").toordinal() 55 + 56 + 57 + def _iter_activity_days_window(window_days: int) -> Iterator[tuple[str, str]]: 58 + if window_days <= 0: 59 + return 60 + 61 + facets = tuple(get_facets().keys()) 62 + today = datetime.strptime(_today_day(), "%Y%m%d").replace(tzinfo=UTC) 63 + for offset in range(window_days - 1, -1, -1): 64 + day = (today - timedelta(days=offset)).strftime("%Y%m%d") 65 + for facet in facets: 66 + yield facet, day 67 + 68 + 69 + def _scan_attendee_entity_ids(window_days: int, roles: frozenset[str]) -> set[str]: 70 + entity_ids: set[str] = set() 71 + for facet, day in _iter_activity_days_window(window_days): 72 + for record in load_activity_records(facet, day): 73 + participation = record.get("participation") 74 + if not isinstance(participation, list): 75 + continue 76 + for entry in participation: 77 + if not isinstance(entry, dict) or entry.get("role") not in roles: 78 + continue 79 + entity_id = entry.get("entity_id") 80 + if isinstance(entity_id, str) and entity_id: 81 + entity_ids.add(entity_id) 82 + return entity_ids 83 + 84 + 85 + def _resolve_target(name: str) -> _ResolvedTarget | None: 86 + journal_entities = load_all_journal_entities() 87 + if not journal_entities: 88 + return None 89 + 90 + match = find_matching_entity(name, list(journal_entities.values())) 91 + if match is None: 92 + return None 93 + 94 + entity_id = str(match.get("id") or "").strip() 95 + if not entity_id: 96 + return None 97 + 98 + journal_entity = journal_entities.get(entity_id) or dict(match) 99 + aka = journal_entity.get("aka") 100 + facets_with_desc: dict[str, str] = {} 101 + for facet in get_facets().keys(): 102 + relationship = load_facet_relationship(facet, entity_id) 103 + if relationship is None: 104 + continue 105 + enriched = enrich_relationship_with_journal(relationship, journal_entity) 106 + description = enriched.get("description") 107 + facets_with_desc[facet] = description if isinstance(description, str) else "" 108 + 109 + return _ResolvedTarget( 110 + entity_id=entity_id, 111 + name=str(journal_entity.get("name") or match.get("name") or entity_id), 112 + type=str(journal_entity.get("type") or match.get("type") or ""), 113 + aka=tuple(item for item in aka if isinstance(item, str)) 114 + if isinstance(aka, list) 115 + else (), 116 + is_principal=bool( 117 + journal_entity.get("is_principal") or match.get("is_principal") 118 + ), 119 + facets_with_desc=facets_with_desc, 120 + ) 121 + 122 + 123 + def _source_sort_key(source: ActivitySourceRef) -> tuple[str, str, str]: 124 + return source.day, source.facet, source.activity_id 125 + 126 + 127 + def _compute_cadence( 128 + entity_id: str, *, include_mentions: bool 129 + ) -> tuple[Cadence, tuple[ActivitySourceRef, ...]]: 130 + active_roles = ( 131 + frozenset({"attendee", "mentioned"}) 132 + if include_mentions 133 + else frozenset({"attendee"}) 134 + ) 135 + interaction_days: list[str] = [] 136 + sources: list[ActivitySourceRef] = [] 137 + 138 + for facet, day in _iter_activity_days_window(90): 139 + for record in load_activity_records(facet, day): 140 + record_id = str(record.get("id") or "").strip() 141 + if not record_id: 142 + continue 143 + participation = record.get("participation") 144 + if not isinstance(participation, list): 145 + continue 146 + 147 + matched = False 148 + for entry in participation: 149 + if not isinstance(entry, dict): 150 + continue 151 + if entry.get("entity_id") != entity_id: 152 + continue 153 + if entry.get("role") not in active_roles: 154 + continue 155 + matched = True 156 + break 157 + 158 + if not matched: 159 + continue 160 + 161 + interaction_days.append(day) 162 + sources.append( 163 + ActivitySourceRef( 164 + facet=facet, 165 + day=day, 166 + activity_id=record_id, 167 + field="participation", 168 + created_at=int(record.get("created_at", 0) or 0), 169 + ) 170 + ) 171 + 172 + if not interaction_days: 173 + return Cadence(0, None, None, None), () 174 + 175 + distinct_days = sorted(set(interaction_days)) 176 + last_seen = distinct_days[-1] 177 + avg_interval_days: float | None = None 178 + gone_quiet_since: str | None = None 179 + 180 + if len(distinct_days) >= 2: 181 + first_ordinal = _day_to_ordinal(distinct_days[0]) 182 + last_ordinal = _day_to_ordinal(last_seen) 183 + avg_interval_days = (last_ordinal - first_ordinal) / (len(distinct_days) - 1) 184 + quiet_gap_days = _day_to_ordinal(_today_day()) - last_ordinal 185 + if quiet_gap_days > avg_interval_days * 2: 186 + gone_quiet_since = last_seen 187 + 188 + cadence = Cadence( 189 + interactions_90d=len(interaction_days), 190 + last_seen=last_seen, 191 + avg_interval_days=avg_interval_days, 192 + gone_quiet_since=gone_quiet_since, 193 + ) 194 + return cadence, tuple(sorted(sources, key=_source_sort_key)) 195 + 196 + 197 + def full( 198 + name: str, *, facets: list[str] | None = None, include_mentions: bool = False 199 + ) -> Profile | None: 200 + target = _resolve_target(name) 201 + if target is None: 202 + return None 203 + 204 + cadence, sources = _compute_cadence( 205 + target.entity_id, include_mentions=include_mentions 206 + ) 207 + if facets is None: 208 + selected_facets = tuple(target.facets_with_desc.keys()) 209 + else: 210 + selected_facets = tuple( 211 + facet for facet in facets if facet in target.facets_with_desc 212 + ) 213 + 214 + descriptions = [ 215 + target.facets_with_desc[facet] 216 + for facet in selected_facets 217 + if target.facets_with_desc[facet] 218 + ] 219 + description = " | ".join(descriptions) if descriptions else None 220 + closed_since = _day_minus(30) 221 + 222 + return Profile( 223 + entity_id=target.entity_id, 224 + name=target.name, 225 + type=target.type, 226 + aka=target.aka, 227 + is_self=target.is_principal, 228 + facets=selected_facets, 229 + description=description, 230 + cadence=cadence, 231 + open_with_them=tuple(ledger.list(state="open", counterparty=target.entity_id)), 232 + closed_with_them_30d=tuple( 233 + ledger.list( 234 + state="closed", 235 + closed_since=closed_since, 236 + counterparty=target.entity_id, 237 + ) 238 + ), 239 + decisions_involving_them=tuple( 240 + ledger.decisions(involving=target.entity_id, since=closed_since) 241 + ), 242 + sources=sources, 243 + generated_at=datetime.now(UTC).isoformat(), 244 + ) 245 + 246 + 247 + def brief(name: str) -> ProfileBrief | None: 248 + target = _resolve_target(name) 249 + if target is None: 250 + return None 251 + 252 + cadence, _ = _compute_cadence(target.entity_id, include_mentions=False) 253 + closed_since = _day_minus(30) 254 + return ProfileBrief( 255 + entity_id=target.entity_id, 256 + name=target.name, 257 + is_self=target.is_principal, 258 + open_loop_count=len(ledger.list(state="open", counterparty=target.entity_id)), 259 + decisions_count_30d=len( 260 + ledger.decisions(involving=target.entity_id, since=closed_since) 261 + ), 262 + last_seen=cadence.last_seen, 263 + generated_at=datetime.now(UTC).isoformat(), 264 + ) 265 + 266 + 267 + def cadence(name: str, *, include_mentions: bool = False) -> Cadence | None: 268 + target = _resolve_target(name) 269 + if target is None: 270 + return None 271 + return _compute_cadence(target.entity_id, include_mentions=include_mentions)[0] 272 + 273 + 274 + def list_active(*, window_days: int = 30) -> list[str]: 275 + return sorted(_scan_attendee_entity_ids(window_days, roles=frozenset({"attendee"})))
+36
think/surfaces/types.py
··· 41 41 day: str 42 42 created_at: int 43 43 source: ActivitySourceRef 44 + 45 + 46 + @dataclass(frozen=True) 47 + class Cadence: 48 + interactions_90d: int 49 + last_seen: str | None 50 + avg_interval_days: float | None 51 + gone_quiet_since: str | None 52 + 53 + 54 + @dataclass(frozen=True) 55 + class ProfileBrief: 56 + entity_id: str 57 + name: str 58 + is_self: bool 59 + open_loop_count: int 60 + decisions_count_30d: int 61 + last_seen: str | None 62 + generated_at: str 63 + 64 + 65 + @dataclass(frozen=True) 66 + class Profile: 67 + entity_id: str 68 + name: str 69 + type: str 70 + aka: tuple[str, ...] 71 + is_self: bool 72 + facets: tuple[str, ...] 73 + description: str | None 74 + cadence: Cadence 75 + open_with_them: tuple[LedgerItem, ...] 76 + closed_with_them_30d: tuple[LedgerItem, ...] 77 + decisions_involving_them: tuple[Decision, ...] 78 + sources: tuple[ActivitySourceRef, ...] 79 + generated_at: str
+190
think/tools/profile.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from dataclasses import asdict 6 + from typing import Any, Callable 7 + 8 + import typer 9 + 10 + from think.surfaces import profile as profile_surface 11 + from think.surfaces.types import LedgerItem, Profile 12 + from think.utils import require_solstone 13 + 14 + app = typer.Typer(help="Profile consumer surface.", no_args_is_help=True) 15 + 16 + 17 + @app.callback() 18 + def callback() -> None: 19 + require_solstone() 20 + 21 + 22 + def _parse_facets_csv(raw: str | None) -> list[str] | None: 23 + if raw is None: 24 + return None 25 + return [part.strip() for part in raw.split(",") if part.strip()] 26 + 27 + 28 + def _render_table( 29 + items: list[Any], columns: list[tuple[str, Callable[[Any], str]]] 30 + ) -> None: 31 + if not items: 32 + return 33 + headers = [header for header, _ in columns] 34 + rows = [[getter(item) for _, getter in columns] for item in items] 35 + widths = [ 36 + max(len(header), *(len(row[index]) for row in rows)) 37 + for index, header in enumerate(headers) 38 + ] 39 + typer.echo( 40 + " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) 41 + ) 42 + typer.echo(" ".join("-" * width for width in widths)) 43 + for row in rows: 44 + typer.echo( 45 + " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) 46 + ) 47 + 48 + 49 + def _item_summary(item: LedgerItem) -> str: 50 + if item.counterparty: 51 + return f"{item.owner}: {item.summary} -> {item.counterparty}" 52 + return f"{item.owner}: {item.summary}" 53 + 54 + 55 + def _render_full(profile: Profile) -> None: 56 + facets_label = ",".join(profile.facets) if profile.facets else "-" 57 + typer.echo( 58 + f"{profile.name} · {profile.type} · facets={facets_label} · self={profile.is_self}" 59 + ) 60 + typer.echo("") 61 + typer.echo("Cadence:") 62 + typer.echo(f" last_seen: {profile.cadence.last_seen}") 63 + typer.echo(f" interactions_90d: {profile.cadence.interactions_90d}") 64 + typer.echo(f" avg_interval_days: {profile.cadence.avg_interval_days}") 65 + typer.echo(f" gone_quiet_since: {profile.cadence.gone_quiet_since}") 66 + typer.echo("") 67 + 68 + typer.echo("Open loops") 69 + if profile.open_with_them: 70 + _render_table( 71 + list(profile.open_with_them), 72 + [ 73 + ("id", lambda item: item.id), 74 + ("state", lambda item: item.state), 75 + ("age_days", lambda item: str(item.age_days)), 76 + ("summary", _item_summary), 77 + ("when", lambda item: item.when or ""), 78 + ], 79 + ) 80 + else: 81 + typer.echo("No open loops.") 82 + typer.echo("") 83 + 84 + typer.echo("Closed 30d") 85 + if profile.closed_with_them_30d: 86 + _render_table( 87 + list(profile.closed_with_them_30d), 88 + [ 89 + ("id", lambda item: item.id), 90 + ("closed_at", lambda item: str(item.closed_at or "")), 91 + ("summary", _item_summary), 92 + ], 93 + ) 94 + else: 95 + typer.echo("No closed items.") 96 + typer.echo("") 97 + 98 + typer.echo("Decisions") 99 + if profile.decisions_involving_them: 100 + _render_table( 101 + list(profile.decisions_involving_them), 102 + [ 103 + ("id", lambda item: item.id), 104 + ("day", lambda item: item.day), 105 + ("owner", lambda item: item.owner), 106 + ("action", lambda item: item.action), 107 + ("context", lambda item: item.context), 108 + ], 109 + ) 110 + else: 111 + typer.echo("No decisions.") 112 + 113 + 114 + def _render_brief(profile_brief: profile_surface.ProfileBrief) -> None: 115 + typer.echo(f"entity_id: {profile_brief.entity_id}") 116 + typer.echo(f"name: {profile_brief.name}") 117 + typer.echo(f"is_self: {profile_brief.is_self}") 118 + typer.echo(f"open_loop_count: {profile_brief.open_loop_count}") 119 + typer.echo(f"decisions_count_30d: {profile_brief.decisions_count_30d}") 120 + typer.echo(f"last_seen: {profile_brief.last_seen}") 121 + typer.echo(f"generated_at: {profile_brief.generated_at}") 122 + 123 + 124 + def _render_cadence(cadence: profile_surface.Cadence) -> None: 125 + typer.echo(f"interactions_90d: {cadence.interactions_90d}") 126 + typer.echo(f"last_seen: {cadence.last_seen}") 127 + typer.echo(f"avg_interval_days: {cadence.avg_interval_days}") 128 + typer.echo(f"gone_quiet_since: {cadence.gone_quiet_since}") 129 + 130 + 131 + @app.command("full") 132 + def full_cmd( 133 + name: str, 134 + facets: str | None = typer.Option(None, "--facets"), 135 + include_mentions: bool = typer.Option(False, "--include-mentions"), 136 + json_out: bool = typer.Option(False, "--json"), 137 + ) -> None: 138 + profile = profile_surface.full( 139 + name, 140 + facets=_parse_facets_csv(facets), 141 + include_mentions=include_mentions, 142 + ) 143 + if profile is None: 144 + typer.echo(f"profile not found: {name}", err=True) 145 + raise typer.Exit(1) 146 + if json_out: 147 + typer.echo(json.dumps(asdict(profile), default=str)) 148 + return 149 + _render_full(profile) 150 + 151 + 152 + @app.command("brief") 153 + def brief_cmd(name: str, json_out: bool = typer.Option(False, "--json")) -> None: 154 + profile_brief = profile_surface.brief(name) 155 + if profile_brief is None: 156 + typer.echo(f"profile not found: {name}", err=True) 157 + raise typer.Exit(1) 158 + if json_out: 159 + typer.echo(json.dumps(asdict(profile_brief), default=str)) 160 + return 161 + _render_brief(profile_brief) 162 + 163 + 164 + @app.command("cadence") 165 + def cadence_cmd( 166 + name: str, 167 + include_mentions: bool = typer.Option(False, "--include-mentions"), 168 + json_out: bool = typer.Option(False, "--json"), 169 + ) -> None: 170 + cadence = profile_surface.cadence(name, include_mentions=include_mentions) 171 + if cadence is None: 172 + typer.echo(f"profile not found: {name}", err=True) 173 + raise typer.Exit(1) 174 + if json_out: 175 + typer.echo(json.dumps(asdict(cadence), default=str)) 176 + return 177 + _render_cadence(cadence) 178 + 179 + 180 + @app.command("list-active") 181 + def list_active( 182 + window_days: int = typer.Option(30, "--window-days"), 183 + json_out: bool = typer.Option(False, "--json"), 184 + ) -> None: 185 + ids = profile_surface.list_active(window_days=window_days) 186 + if json_out: 187 + typer.echo(json.dumps(ids)) 188 + return 189 + for entity_id in ids: 190 + typer.echo(entity_id)