personal memory agent
0
fork

Configure Feed

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

think/surfaces/profile: align with consumer-surface-profile spec

Five field-shape corrections to match cpo/specs/in-flight/consumer-surface-profile.md:

- Cadence.interactions_90d renamed to recent_interactions_count_30d; count
now filters the existing 90-day scan to the 30-day window (same anchor as
closed_since in full()). Interval math, last_seen, and quiet_gap_days still
derive from the full 90-day history.
- Cadence.gone_quiet_since now returns int days-since-last-seen (was the
YYYYMMDD string).
- Profile.generated_at is now an int UTC-ms timestamp (was an ISO string).
- Profile.full()'s decisions_involving_them drops the since= filter so
older-than-30-day decisions surface (previously suppressed).
- ProfileBrief reshaped to the spec's 7-field form: drops is_self and
generated_at; adds type and description (via new _ResolvedTarget.
description_for() helper shared with full()).

No backwards-compat shims; every caller updated in place. CLI renderers
and tests updated to match.

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

+136 -46
+94 -10
tests/test_surfaces_profile.py
··· 289 289 290 290 cadence = profile_surface.cadence("Ravi") 291 291 assert cadence is not None 292 - assert cadence.interactions_90d == 3 292 + assert cadence.recent_interactions_count_30d == 3 293 293 assert cadence.last_seen == "20260418" 294 294 assert cadence.avg_interval_days == 4.0 295 295 assert cadence.gone_quiet_since is None ··· 337 337 boundary = profile_surface.cadence("Boundary Person") 338 338 339 339 assert quiet is not None 340 - assert quiet.gone_quiet_since == "20260406" 340 + assert quiet.gone_quiet_since == 14 341 341 assert boundary is not None 342 342 assert boundary.gone_quiet_since is None 343 + 344 + 345 + def test_cadence_gone_quiet_returns_int_days(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=( 352 + {"id": "far_person", "name": "Far Person", "type": "Person"}, 353 + {"id": "equal_person", "name": "Equal Person", "type": "Person"}, 354 + {"id": "recent_person", "name": "Recent Person", "type": "Person"}, 355 + ), 356 + ) 357 + for entity_id, description in ( 358 + ("far_person", "Far"), 359 + ("equal_person", "Equal"), 360 + ("recent_person", "Recent"), 361 + ): 362 + _write_facet_relationship(tmp_path, "work", entity_id, description=description) 363 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 364 + 365 + for entity_id, days in ( 366 + ("far_person", ("20260330", "20260404")), 367 + ("equal_person", ("20260405", "20260410")), 368 + ("recent_person", ("20260406", "20260411")), 369 + ): 370 + for day in days: 371 + _append_activity( 372 + "work", 373 + day, 374 + _activity_record( 375 + day, 376 + [_participant(entity_id, name=entity_id.replace("_", " ").title())], 377 + record_id=f"{entity_id}_{day}", 378 + ), 379 + ) 380 + 381 + far = profile_surface.cadence("Far Person") 382 + equal = profile_surface.cadence("Equal Person") 383 + recent = profile_surface.cadence("Recent Person") 384 + 385 + assert far is not None 386 + assert far.gone_quiet_since == 16 387 + assert equal is not None 388 + assert equal.gone_quiet_since is None 389 + assert recent is not None 390 + assert recent.gone_quiet_since is None 343 391 344 392 345 393 def test_cadence_distinct_days_vs_record_count(tmp_path, monkeypatch): ··· 422 470 assert profile is not None 423 471 assert profile.facets == () 424 472 assert profile.description is None 425 - assert profile.cadence.interactions_90d == 1 473 + assert profile.cadence.recent_interactions_count_30d == 1 426 474 427 475 428 476 def test_include_mentions_toggle(tmp_path, monkeypatch): ··· 602 650 decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], 603 651 ) 604 652 605 - expected = tuple( 606 - ledger_surface.decisions( 607 - involving="ravi", 608 - since=profile_surface._day_minus(30), # noqa: SLF001 609 - ) 610 - ) 653 + expected = tuple(ledger_surface.decisions(involving="ravi")) 611 654 profile = profile_surface.full("Ravi") 612 655 613 656 assert profile is not None ··· 615 658 assert len(profile.decisions_involving_them) >= 1 616 659 617 660 661 + def test_full_decisions_involving_them_includes_old(tmp_path, monkeypatch): 662 + from think.surfaces import profile as profile_surface 663 + 664 + _configure_env(tmp_path, monkeypatch) 665 + _minimal_facet_tree( 666 + tmp_path, 667 + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), 668 + ) 669 + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") 670 + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") 671 + _write_story_activity( 672 + "work", 673 + "20260301", 674 + "meeting_090000_300", 675 + _utc_ms("20260301", 9), 676 + decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], 677 + ) 678 + 679 + profile = profile_surface.full("Ravi") 680 + 681 + assert profile is not None 682 + assert any( 683 + decision.day == "20260301" for decision in profile.decisions_involving_them 684 + ) 685 + 686 + 618 687 def test_not_found_returns_none(tmp_path, monkeypatch): 619 688 from think.surfaces import profile as profile_surface 620 689 from think.tools.profile import app ··· 659 728 brief = profile_surface.brief("Ravi") 660 729 661 730 assert brief is not None 731 + assert tuple(brief.__dataclass_fields__) == ( 732 + "entity_id", 733 + "name", 734 + "type", 735 + "description", 736 + "last_seen", 737 + "open_loop_count", 738 + "decisions_count_30d", 739 + ) 740 + assert brief.entity_id == "ravi" 741 + assert brief.name == "Ravi" 742 + assert brief.type == "Person" 743 + assert brief.description == "Customer" 744 + assert brief.last_seen is None 662 745 assert brief.open_loop_count == len( 663 746 ledger_surface.list(state="open", counterparty="ravi") 664 747 ) 665 748 assert brief.decisions_count_30d == len( 666 749 ledger_surface.decisions(involving="ravi", since="20260321") 667 750 ) 668 - assert brief.last_seen is None 751 + assert not hasattr(brief, "is_self") 752 + assert not hasattr(brief, "generated_at") 669 753 670 754 671 755 def test_list_active_sort_dedup_window(tmp_path, monkeypatch):
+26 -25
think/surfaces/profile.py
··· 40 40 is_principal: bool 41 41 facets_with_desc: dict[str, str] 42 42 43 + def description_for(self, facets: tuple[str, ...] | None) -> str | None: 44 + selected_facets = ( 45 + tuple(self.facets_with_desc.keys()) 46 + if facets is None 47 + else tuple(facet for facet in facets if facet in self.facets_with_desc) 48 + ) 49 + descriptions = [ 50 + self.facets_with_desc[facet] 51 + for facet in selected_facets 52 + if self.facets_with_desc[facet] 53 + ] 54 + return " | ".join(descriptions) if descriptions else None 55 + 43 56 44 57 def _today_day() -> str: 45 58 return datetime.now(UTC).strftime("%Y%m%d") ··· 175 188 distinct_days = sorted(set(interaction_days)) 176 189 last_seen = distinct_days[-1] 177 190 avg_interval_days: float | None = None 178 - gone_quiet_since: str | None = None 191 + gone_quiet_since: int | None = None 192 + recent_since = _day_minus(30) 193 + recent_count = sum(1 for day in interaction_days if day >= recent_since) 179 194 180 195 if len(distinct_days) >= 2: 181 196 first_ordinal = _day_to_ordinal(distinct_days[0]) ··· 183 198 avg_interval_days = (last_ordinal - first_ordinal) / (len(distinct_days) - 1) 184 199 quiet_gap_days = _day_to_ordinal(_today_day()) - last_ordinal 185 200 if quiet_gap_days > avg_interval_days * 2: 186 - gone_quiet_since = last_seen 201 + gone_quiet_since = quiet_gap_days 187 202 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 - ) 203 + cadence = Cadence(recent_count, last_seen, avg_interval_days, gone_quiet_since) 194 204 return cadence, tuple(sorted(sources, key=_source_sort_key)) 195 205 196 206 ··· 210 220 selected_facets = tuple( 211 221 facet for facet in facets if facet in target.facets_with_desc 212 222 ) 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 223 closed_since = _day_minus(30) 221 224 222 225 return Profile( ··· 226 229 aka=target.aka, 227 230 is_self=target.is_principal, 228 231 facets=selected_facets, 229 - description=description, 232 + description=target.description_for(None if facets is None else tuple(facets)), 230 233 cadence=cadence, 231 234 open_with_them=tuple(ledger.list(state="open", counterparty=target.entity_id)), 232 235 closed_with_them_30d=tuple( ··· 236 239 counterparty=target.entity_id, 237 240 ) 238 241 ), 239 - decisions_involving_them=tuple( 240 - ledger.decisions(involving=target.entity_id, since=closed_since) 241 - ), 242 + decisions_involving_them=tuple(ledger.decisions(involving=target.entity_id)), 242 243 sources=sources, 243 - generated_at=datetime.now(UTC).isoformat(), 244 + generated_at=int(datetime.now(UTC).timestamp() * 1000), 244 245 ) 245 246 246 247 ··· 250 251 return None 251 252 252 253 cadence, _ = _compute_cadence(target.entity_id, include_mentions=False) 253 - closed_since = _day_minus(30) 254 + decision_since = _day_minus(30) 254 255 return ProfileBrief( 255 256 entity_id=target.entity_id, 256 257 name=target.name, 257 - is_self=target.is_principal, 258 + type=target.type, 259 + description=target.description_for(None), 260 + last_seen=cadence.last_seen, 258 261 open_loop_count=len(ledger.list(state="open", counterparty=target.entity_id)), 259 262 decisions_count_30d=len( 260 - ledger.decisions(involving=target.entity_id, since=closed_since) 263 + ledger.decisions(involving=target.entity_id, since=decision_since) 261 264 ), 262 - last_seen=cadence.last_seen, 263 - generated_at=datetime.now(UTC).isoformat(), 264 265 ) 265 266 266 267
+6 -6
think/surfaces/types.py
··· 45 45 46 46 @dataclass(frozen=True) 47 47 class Cadence: 48 - interactions_90d: int 48 + recent_interactions_count_30d: int 49 49 last_seen: str | None 50 50 avg_interval_days: float | None 51 - gone_quiet_since: str | None 51 + gone_quiet_since: int | None 52 52 53 53 54 54 @dataclass(frozen=True) 55 55 class ProfileBrief: 56 56 entity_id: str 57 57 name: str 58 - is_self: bool 58 + type: str 59 + description: str | None 60 + last_seen: str | None 59 61 open_loop_count: int 60 62 decisions_count_30d: int 61 - last_seen: str | None 62 - generated_at: str 63 63 64 64 65 65 @dataclass(frozen=True) ··· 76 76 closed_with_them_30d: tuple[LedgerItem, ...] 77 77 decisions_involving_them: tuple[Decision, ...] 78 78 sources: tuple[ActivitySourceRef, ...] 79 - generated_at: str 79 + generated_at: int
+10 -5
think/tools/profile.py
··· 60 60 typer.echo("") 61 61 typer.echo("Cadence:") 62 62 typer.echo(f" last_seen: {profile.cadence.last_seen}") 63 - typer.echo(f" interactions_90d: {profile.cadence.interactions_90d}") 63 + typer.echo( 64 + " recent_interactions_count_30d: " 65 + f"{profile.cadence.recent_interactions_count_30d}" 66 + ) 64 67 typer.echo(f" avg_interval_days: {profile.cadence.avg_interval_days}") 65 68 typer.echo(f" gone_quiet_since: {profile.cadence.gone_quiet_since}") 66 69 typer.echo("") ··· 114 117 def _render_brief(profile_brief: profile_surface.ProfileBrief) -> None: 115 118 typer.echo(f"entity_id: {profile_brief.entity_id}") 116 119 typer.echo(f"name: {profile_brief.name}") 117 - typer.echo(f"is_self: {profile_brief.is_self}") 120 + typer.echo(f"type: {profile_brief.type}") 121 + typer.echo(f"description: {profile_brief.description}") 122 + typer.echo(f"last_seen: {profile_brief.last_seen}") 118 123 typer.echo(f"open_loop_count: {profile_brief.open_loop_count}") 119 124 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 125 123 126 124 127 def _render_cadence(cadence: profile_surface.Cadence) -> None: 125 - typer.echo(f"interactions_90d: {cadence.interactions_90d}") 128 + typer.echo( 129 + f"recent_interactions_count_30d: {cadence.recent_interactions_count_30d}" 130 + ) 126 131 typer.echo(f"last_seen: {cadence.last_seen}") 127 132 typer.echo(f"avg_interval_days: {cadence.avg_interval_days}") 128 133 typer.echo(f"gone_quiet_since: {cadence.gone_quiet_since}")