personal memory agent
0

Configure Feed

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

fix(think): gate level:low facets out of activity creation; per-facet descriptions

ActivityStateMachine.update treated every sense facet as activity-worthy and
stamped the segment-level activity_summary onto every record. A code-signing
segment with five facets produced five activity records — two of them
peripheral (level:low) — all sharing the dominant work summary.

Two surgical changes in update():
- Exclude any facet whose raw level is exactly "low" when building
current_facets, so it is treated identically to an absent facet: it never
creates or continues an activity, and a previously-active facet that drops
to low winds down through the existing 2-segment facet-gone hysteresis
(no new/abrupt end path). Missing/invalid level still normalizes to medium
and stays engaged.
- Source each record's description from that facet's own facets[i].activity,
falling back to activity_summary only when the per-facet text is
missing/empty.

Forward-behavior only; already-written records are unchanged. level_avg,
active_entities, idle/gap/type-change handling, and completed-record shape are
untouched for high/medium facets.

Tests: new TestLevelGate covers AC1-AC5 (the founder's example facet set,
per-facet vs distinct descriptions, summary fallback, drop-to-low hysteresis,
all-low wind-down). Reconciled six low->medium fixtures whose intent was
multi-facet/pipeline behavior, and three description assertions plus a
facet_activity test-helper param so the per-facet description source is proven.

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

+218 -22
+12 -5
solstone/think/activity_state_machine.py
··· 170 170 171 171 facet_map = {} 172 172 for facet in raw_facets: 173 - if isinstance(facet, dict) and facet.get("facet"): 174 - facet_map[facet["facet"]] = facet 173 + if not (isinstance(facet, dict) and facet.get("facet")): 174 + continue 175 + # An explicit level:"low" facet is treated exactly like an absent 176 + # facet: it never creates/continues an activity and any prior active 177 + # activity winds down through the facet-gone hysteresis loop below. 178 + if facet.get("level") == "low": 179 + continue 180 + facet_map[facet["facet"]] = facet 175 181 current_facets = set(facet_map.keys()) 176 182 177 183 # Hysteresis invariant: pending segments bridge into the prior activity's segments[]; ··· 207 213 level = facet_data.get("level", "medium") 208 214 if level not in ("high", "medium", "low"): 209 215 level = "medium" 216 + description = facet_data.get("activity") or activity_summary 210 217 211 218 if facet in self.state: 212 219 prior = self.state[facet] ··· 238 245 "activity": content_type, 239 246 "state": "active", 240 247 "since": segment_key, 241 - "description": activity_summary, 248 + "description": description, 242 249 "level": level, 243 250 "active_entities": entity_names, 244 251 "_change": "new", ··· 260 267 prior["segments"].append(segment_key) 261 268 changes.append(dict(prior)) 262 269 else: 263 - prior["description"] = activity_summary 270 + prior["description"] = description 264 271 prior["level"] = level 265 272 prior["active_entities"] = entity_names 266 273 prior["_pending_facet_misses"] = 0 ··· 278 285 "activity": content_type, 279 286 "state": "active", 280 287 "since": segment_key, 281 - "description": activity_summary, 288 + "description": description, 282 289 "level": level, 283 290 "active_entities": entity_names, 284 291 "_change": "new",
+195 -7
tests/test_activity_state_machine.py
··· 9 9 density="active", 10 10 facets=None, 11 11 summary="Working on code.", 12 + facet_activity="Reviewing the open diff.", 12 13 entities=None, 13 14 meeting=False, 14 15 speakers=None, 15 16 ): 16 17 """Build a Sense output payload for testing.""" 17 18 if facets is None: 18 - facets = [{"facet": "work", "activity": content_type, "level": "high"}] 19 + facets = [{"facet": "work", "activity": facet_activity, "level": "high"}] 19 20 return { 20 21 "density": density, 21 22 "content_type": content_type, ··· 49 50 50 51 sm = ActivityStateMachine() 51 52 sm.update(_sense(), "090000_300", "20260304") 52 - changes = sm.update(_sense(summary="Still coding."), "090500_300", "20260304") 53 + changes = sm.update( 54 + _sense(facet_activity="Still reviewing the diff."), 55 + "090500_300", 56 + "20260304", 57 + ) 53 58 54 59 assert len(changes) == 1 55 60 assert changes[0]["_change"] == "continuing" 56 61 assert changes[0]["since"] == "090000_300" 57 - assert changes[0]["description"] == "Still coding." 62 + assert changes[0]["description"] == "Still reviewing the diff." 58 63 59 64 60 65 class TestContentTypeChange: ··· 135 140 sm = ActivityStateMachine() 136 141 facets = [ 137 142 {"facet": "work", "activity": "coding", "level": "high"}, 138 - {"facet": "personal", "activity": "browsing", "level": "low"}, 143 + {"facet": "personal", "activity": "browsing", "level": "medium"}, 139 144 ] 140 145 changes = sm.update(_sense(facets=facets), "090000_300", "20260304") 141 146 ··· 144 149 assert facet_names == {"work", "personal"} 145 150 146 151 152 + class TestLevelGate: 153 + def test_explicit_low_facets_are_excluded_from_active_set(self): 154 + from solstone.think.activity_state_machine import ActivityStateMachine 155 + 156 + sm = ActivityStateMachine() 157 + facets = [ 158 + {"facet": "solpbc", "activity": "Planning launch work.", "level": "high"}, 159 + { 160 + "facet": "personal", 161 + "activity": "Coordinating a personal errand.", 162 + "level": "medium", 163 + }, 164 + { 165 + "facet": "bluesky", 166 + "activity": "Checking a social thread.", 167 + "level": "low", 168 + }, 169 + { 170 + "facet": "kognova", 171 + "activity": "Skimming background notes.", 172 + "level": "low", 173 + }, 174 + ] 175 + 176 + changes = sm.update( 177 + _sense(facets=facets, summary="Mixed activity summary."), 178 + "090000_300", 179 + "20260304", 180 + ) 181 + 182 + assert {change["facet"] for change in changes} == {"solpbc", "personal"} 183 + assert set(sm.state) == {"solpbc", "personal"} 184 + assert not any( 185 + change.get("facet") in {"bluesky", "kognova"} for change in changes 186 + ) 187 + 188 + def test_description_uses_each_engaged_facets_activity(self): 189 + from solstone.think.activity_state_machine import ActivityStateMachine 190 + 191 + sm = ActivityStateMachine() 192 + summary = "Segment-level summary for multiple domains." 193 + facets = [ 194 + { 195 + "facet": "solpbc", 196 + "activity": "Reviewing release blockers.", 197 + "level": "high", 198 + }, 199 + { 200 + "facet": "personal", 201 + "activity": "Sorting out afternoon plans.", 202 + "level": "medium", 203 + }, 204 + ] 205 + 206 + changes = sm.update( 207 + _sense(facets=facets, summary=summary), 208 + "090000_300", 209 + "20260304", 210 + ) 211 + 212 + by_facet = {change["facet"]: change for change in changes} 213 + assert by_facet["solpbc"]["description"] == "Reviewing release blockers." 214 + assert by_facet["personal"]["description"] == "Sorting out afternoon plans." 215 + assert all(change["description"] != summary for change in changes) 216 + 217 + def test_description_falls_back_to_summary_when_facet_activity_missing(self): 218 + from solstone.think.activity_state_machine import ActivityStateMachine 219 + 220 + sm = ActivityStateMachine() 221 + summary = "Segment summary fills missing facet activity." 222 + facets = [ 223 + {"facet": "work", "activity": "", "level": "high"}, 224 + {"facet": "personal", "level": "medium"}, 225 + ] 226 + 227 + changes = sm.update( 228 + _sense(facets=facets, summary=summary), 229 + "090000_300", 230 + "20260304", 231 + ) 232 + 233 + assert {change["facet"] for change in changes} == {"work", "personal"} 234 + assert all(change["description"] == summary for change in changes) 235 + assert all(change["description"] for change in changes) 236 + 237 + def test_one_low_segment_does_not_end_prior_high_facet(self): 238 + from solstone.think.activity_state_machine import ActivityStateMachine 239 + 240 + sm = ActivityStateMachine() 241 + high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] 242 + low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] 243 + 244 + sm.update(_sense(facets=high), "090000_300", "20260304") 245 + pending = sm.update(_sense(facets=low), "090500_300", "20260304") 246 + 247 + assert len(pending) == 1 248 + assert pending[0]["_change"] == "facet_gone_pending" 249 + assert pending[0]["facet"] == "work" 250 + assert sm.state["work"]["_pending_facet_misses"] == 1 251 + assert not any( 252 + change.get("_change") == "ended_facet_gone" for change in pending 253 + ) 254 + 255 + returned = sm.update(_sense(facets=high), "091000_300", "20260304") 256 + 257 + assert len(returned) == 1 258 + assert returned[0]["_change"] == "continuing" 259 + assert returned[0]["since"] == "090000_300" 260 + assert sm.state["work"]["_pending_facet_misses"] == 0 261 + assert sm.get_completed_activities() == [] 262 + 263 + def test_two_low_segments_end_prior_high_facet(self): 264 + from solstone.think.activity_state_machine import ActivityStateMachine 265 + 266 + sm = ActivityStateMachine() 267 + high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] 268 + low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] 269 + 270 + sm.update(_sense(facets=high), "090000_300", "20260304") 271 + sm.update(_sense(facets=low), "090500_300", "20260304") 272 + changes = sm.update(_sense(facets=low), "091000_300", "20260304") 273 + 274 + ended = [ 275 + change for change in changes if change.get("_change") == "ended_facet_gone" 276 + ] 277 + assert len(ended) == 1 278 + assert ended[0]["state"] == "ended" 279 + assert ended[0]["facet"] == "work" 280 + assert sm.state == {} 281 + assert len(sm.get_completed_activities()) == 1 282 + 283 + def test_all_low_fresh_segment_creates_no_activity(self): 284 + from solstone.think.activity_state_machine import ActivityStateMachine 285 + 286 + sm = ActivityStateMachine() 287 + facets = [ 288 + {"facet": "work", "activity": "Light inbox scan.", "level": "low"}, 289 + {"facet": "personal", "activity": "Background reading.", "level": "low"}, 290 + ] 291 + 292 + changes = sm.update( 293 + _sense(facets=facets, summary="Low-attention activity."), 294 + "090000_300", 295 + "20260304", 296 + ) 297 + 298 + assert changes == [] 299 + assert sm.state == {} 300 + assert sm.get_completed_activities() == [] 301 + 302 + def test_all_low_segments_wind_down_prior_active_facet(self): 303 + from solstone.think.activity_state_machine import ActivityStateMachine 304 + 305 + sm = ActivityStateMachine() 306 + high = [ 307 + {"facet": "work", "activity": "Implementing the parser.", "level": "high"} 308 + ] 309 + all_low = [ 310 + {"facet": "work", "activity": "Glancing at parser logs.", "level": "low"}, 311 + { 312 + "facet": "personal", 313 + "activity": "Skimming personal email.", 314 + "level": "low", 315 + }, 316 + ] 317 + 318 + sm.update(_sense(facets=high), "090000_300", "20260304") 319 + pending = sm.update(_sense(facets=all_low), "090500_300", "20260304") 320 + ended_changes = sm.update(_sense(facets=all_low), "091000_300", "20260304") 321 + 322 + assert len(pending) == 1 323 + assert pending[0]["_change"] == "facet_gone_pending" 324 + assert pending[0]["_pending_facet_misses"] == 1 325 + ended = [ 326 + change 327 + for change in ended_changes 328 + if change.get("_change") == "ended_facet_gone" 329 + ] 330 + assert len(ended) == 1 331 + assert ended[0]["facet"] == "work" 332 + assert sm.state == {} 333 + 334 + 147 335 class TestFacetDisappearing: 148 336 def test_facet_gone_emits_ended(self): 149 337 from solstone.think.activity_state_machine import ActivityStateMachine ··· 151 339 sm = ActivityStateMachine() 152 340 two_facets = [ 153 341 {"facet": "work", "activity": "coding", "level": "high"}, 154 - {"facet": "personal", "activity": "browsing", "level": "low"}, 342 + {"facet": "personal", "activity": "browsing", "level": "medium"}, 155 343 ] 156 344 sm.update(_sense(facets=two_facets), "090000_300", "20260304") 157 345 one_facet = [{"facet": "work", "activity": "coding", "level": "high"}] ··· 317 505 sm = ActivityStateMachine() 318 506 two = [ 319 507 {"facet": "work", "activity": "coding", "level": "high"}, 320 - {"facet": "personal", "activity": "browsing", "level": "low"}, 508 + {"facet": "personal", "activity": "browsing", "level": "medium"}, 321 509 ] 322 510 one = [{"facet": "work", "activity": "coding", "level": "high"}] 323 511 sm.update(_sense(facets=two), "090000_300", "20260304") ··· 408 596 sm = ActivityStateMachine() 409 597 two = [ 410 598 {"facet": "work", "activity": "coding", "level": "high"}, 411 - {"facet": "personal", "activity": "browsing", "level": "low"}, 599 + {"facet": "personal", "activity": "browsing", "level": "medium"}, 412 600 ] 413 601 sm.update(_sense(facets=two), "090000_300", "20260304") 414 602 sm.update(_sense(facets=two), "090500_300", "20260304")
+9 -8
tests/test_activity_state_machine_hysteresis.py
··· 17 17 density: str = "active", 18 18 facets: list[dict] | None = None, 19 19 summary: str = "Working on code.", 20 + facet_activity: str = "Reviewing the open diff.", 20 21 entities: list[dict] | None = None, 21 22 ) -> dict: 22 23 if facets is None: 23 - facets = [{"facet": "work", "activity": content_type, "level": "high"}] 24 + facets = [{"facet": "work", "activity": facet_activity, "level": "high"}] 24 25 return { 25 26 "density": density, 26 27 "content_type": content_type, ··· 86 87 87 88 def test_single_segment_type_wobble_does_not_end(): 88 89 sm = ActivityStateMachine() 89 - sm.update(_sense(summary="Writing code."), "090000_300", DAY) 90 + sm.update(_sense(facet_activity="Drafting the parser."), "090000_300", DAY) 90 91 91 - wobble = sm.update( 92 - _sense(content_type="meeting", summary="Stand-up."), "090500_300", DAY 93 - ) 92 + wobble = sm.update(_sense(content_type="meeting"), "090500_300", DAY) 94 93 assert not any(change.get("_change") == "ended_type_change" for change in wobble) 95 94 assert wobble[0]["_change"] == "type_change_pending" 96 - assert sm.state["work"]["description"] == "Writing code." 95 + assert sm.state["work"]["description"] == "Drafting the parser." 97 96 assert sm.state["work"]["segments"] == ["090000_300", "090500_300"] 98 97 99 - returned = sm.update(_sense(summary="Still coding."), "091000_300", DAY) 98 + returned = sm.update( 99 + _sense(facet_activity="Refining the parser."), "091000_300", DAY 100 + ) 100 101 assert not any(change.get("_change") == "ended_type_change" for change in returned) 101 102 assert sm.get_completed_activities() == [] 102 103 assert sm.state["work"]["activity"] == "coding" 103 - assert sm.state["work"]["description"] == "Still coding." 104 + assert sm.state["work"]["description"] == "Refining the parser." 104 105 assert sm.state["work"]["_pending_type"] is None 105 106 assert sm.state["work"]["_pending_type_count"] == 0 106 107 assert sm.state["work"]["segments"] == [
+1 -1
tests/test_pipeline_smoke.py
··· 136 136 "density": "active", 137 137 "content_type": "coding", 138 138 "activity_summary": "Reviewing PR feedback", 139 - "facets": [{"facet": "work", "activity": "coding", "level": "low"}], 139 + "facets": [{"facet": "work", "activity": "coding", "level": "medium"}], 140 140 "meeting_detected": False, 141 141 "speakers": [], 142 142 "recommend": {
+1 -1
tests/test_think_activity.py
··· 1011 1011 1012 1012 two = [ 1013 1013 {"facet": "work", "activity": "coding", "level": "high"}, 1014 - {"facet": "personal", "activity": "browsing", "level": "low"}, 1014 + {"facet": "personal", "activity": "browsing", "level": "medium"}, 1015 1015 ] 1016 1016 sm = ActivityStateMachine() 1017 1017 sm.update(self._sense(facets=two), "090000_300", "20260304")