personal memory agent
0
fork

Configure Feed

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

feat(activity_sm): K=2 hysteresis on facet-gone and type-change ends + change reason in events

Apply K=2 hysteresis to facet-gone and type-change activity ends while leaving idle and gap endings immediate.

Motivated by the 4/16 backfill investigation: about 50-60% of ends came from ended_facet_gone and about 30% from ended_type_change, mostly single-segment wobbles.

Persist pending counters through the existing awareness snapshot and add change to activity.detected/activity.persisted JSONL events for end-reason observability.

+484 -70
+32 -7
tests/test_activity_state_machine.py
··· 63 63 64 64 sm = ActivityStateMachine() 65 65 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 66 - changes = sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 66 + pending = sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 67 + changes = sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 67 68 69 + assert all(c["state"] != "ended" for c in pending) 70 + assert pending[0]["_change"] == "type_change_pending" 68 71 assert len(changes) == 2 69 72 ended = [c for c in changes if c["state"] == "ended"] 70 73 started = [c for c in changes if c["state"] == "active"] ··· 152 155 ] 153 156 sm.update(_sense(facets=two_facets), "090000_300", "20260304") 154 157 one_facet = [{"facet": "work", "activity": "coding", "level": "high"}] 155 - changes = sm.update(_sense(facets=one_facet), "090500_300", "20260304") 158 + pending = sm.update(_sense(facets=one_facet), "090500_300", "20260304") 159 + changes = sm.update(_sense(facets=one_facet), "091000_300", "20260304") 156 160 161 + assert pending[0]["_change"] == "facet_gone_pending" 157 162 ended = [c for c in changes if c["_change"] == "ended_facet_gone"] 158 163 assert len(ended) == 1 159 164 assert ended[0]["facet"] == "personal" ··· 190 195 sm = ActivityStateMachine() 191 196 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 192 197 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 198 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 193 199 completed = sm.get_completed_activities() 194 200 195 201 assert len(completed) == 1 ··· 197 203 assert "id" in rec 198 204 assert "activity" in rec and rec["activity"] == "coding" 199 205 assert "segments" in rec and isinstance(rec["segments"], list) 206 + assert rec["segments"] == ["090000_300", "090500_300"] 200 207 assert "level_avg" in rec and isinstance(rec["level_avg"], float) 201 208 assert "description" in rec 202 209 assert "active_entities" in rec ··· 214 221 sm.update(_sense(content_type="coding"), "091000_300", "20260304") 215 222 # End by type change 216 223 sm.update(_sense(content_type="meeting"), "091500_300", "20260304") 224 + sm.update(_sense(content_type="meeting"), "092000_300", "20260304") 217 225 218 226 completed = sm.get_completed_activities() 219 227 assert len(completed) == 1 220 228 rec = completed[0] 221 - assert rec["segments"] == ["090000_300", "090500_300", "091000_300"] 229 + assert rec["segments"] == [ 230 + "090000_300", 231 + "090500_300", 232 + "091000_300", 233 + "091500_300", 234 + ] 222 235 223 236 def test_ten_segments_produces_ten_keys(self): 224 237 from think.activity_state_machine import ActivityStateMachine ··· 312 325 sm.update(_sense(facets=two), "091000_300", "20260304") 313 326 # personal disappears 314 327 sm.update(_sense(facets=one), "091500_300", "20260304") 328 + sm.update(_sense(facets=one), "092000_300", "20260304") 315 329 316 330 completed = sm.get_completed_activities() 317 331 assert len(completed) == 1 ··· 321 335 "090000_300", 322 336 "090500_300", 323 337 "091000_300", 338 + "091500_300", 324 339 ] 325 340 326 341 def test_gap_ending_preserves_accumulated_segments(self): ··· 347 362 sm.update(_sense(content_type="coding"), "091000_300", "20260304") 348 363 sm.update(_sense(content_type="coding"), "091500_300", "20260304") 349 364 sm.update(_sense(content_type="meeting"), "092000_300", "20260304") 365 + sm.update(_sense(content_type="meeting"), "092500_300", "20260304") 350 366 351 367 completed = sm.get_completed_activities() 352 368 assert len(completed) == 1 ··· 355 371 "090500_300", 356 372 "091000_300", 357 373 "091500_300", 374 + "092000_300", 358 375 ] 359 376 360 377 def test_single_segment_activity_has_one_segment(self): ··· 363 380 364 381 sm = ActivityStateMachine() 365 382 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 366 - sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 383 + sm.update(_sense(density="idle"), "090500_300", "20260304") 367 384 368 385 completed = sm.get_completed_activities() 369 386 assert len(completed) == 1 ··· 378 395 # Same segment key again (shouldn't happen in practice, but defensive) 379 396 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 380 397 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 398 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 381 399 382 400 completed = sm.get_completed_activities() 383 401 assert len(completed) == 1 384 - assert completed[0]["segments"] == ["090000_300"] 402 + assert completed[0]["segments"] == ["090000_300", "090500_300"] 385 403 386 404 def test_multi_facet_simultaneous_ending_all_have_segments(self): 387 405 """Multiple facets ending simultaneously each have their own segments.""" ··· 421 439 sm = ActivityStateMachine() 422 440 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 423 441 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 442 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 424 443 after = int(time.time() * 1000) 425 444 426 445 rec = sm.get_completed_activities()[0] ··· 436 455 sm = ActivityStateMachine() 437 456 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 438 457 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 458 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 439 459 440 460 rec = sm.get_completed_activities()[0] 441 461 # Simulate routes.py cutoff calculation ··· 459 479 "20260304", 460 480 ) 461 481 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 482 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 462 483 463 484 rec = sm.get_completed_activities()[0] 464 485 assert isinstance(rec["level_avg"], float) ··· 471 492 sm = ActivityStateMachine() 472 493 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 473 494 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 495 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 474 496 475 497 rec = sm.get_completed_activities()[0] 476 498 required = { ··· 497 519 sm.update( 498 520 _sense(content_type="coding", entities=entities), "090000_300", "20260304" 499 521 ) 500 - sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 522 + sm.update(_sense(density="idle"), "090500_300", "20260304") 501 523 502 524 rec = sm.get_completed_activities()[0] 503 525 assert rec["active_entities"] == ["Alice", "VSCode"] ··· 513 535 # Activity 1: coding 514 536 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 515 537 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 538 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 516 539 # Activity 2: meeting 517 - sm.update(_sense(content_type="coding"), "091000_300", "20260304") 540 + sm.update(_sense(content_type="coding"), "091500_300", "20260304") 541 + sm.update(_sense(content_type="coding"), "092000_300", "20260304") 518 542 519 543 completed = sm.get_completed_activities() 520 544 assert len(completed) == 2 ··· 528 552 sm = ActivityStateMachine() 529 553 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 530 554 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 555 + sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 531 556 532 557 list1 = sm.get_completed_activities() 533 558 list2 = sm.get_completed_activities()
+8 -3
tests/test_activity_state_machine_durability.py
··· 224 224 think, "_drain_priority_batch", lambda *args, **kwargs: (1, 0, []) 225 225 ) 226 226 monkeypatch.setattr( 227 - think, "_jsonl_log", lambda event, **fields: events.append(event) 227 + think, "_jsonl_log", lambda event, **fields: events.append((event, fields)) 228 228 ) 229 229 monkeypatch.setattr(think, "run_activity_prompts", lambda **kwargs: True) 230 230 monkeypatch.setattr(think, "_callosum", None) ··· 239 239 ) 240 240 241 241 assert (success, failed, failed_names) == (1, 0, []) 242 - assert "activity.detected" in events 243 - assert "activity.persisted" in events 242 + event_names = [event for event, _fields in events] 243 + assert "activity.detected" in event_names 244 + assert "activity.persisted" in event_names 245 + detected = [fields for event, fields in events if event == "activity.detected"] 246 + persisted = [fields for event, fields in events if event == "activity.persisted"] 247 + assert detected[0]["change"] == "ended_idle" 248 + assert persisted[0]["change"] == "ended_idle"
+266
tests/test_activity_state_machine_hysteresis.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Hysteresis tests for the deterministic activity state machine.""" 5 + 6 + import json 7 + from pathlib import Path 8 + 9 + from think.activity_state_machine import ActivityStateMachine 10 + from think.thinking import _write_json_atomic 11 + 12 + DAY = "20260304" 13 + 14 + 15 + def _sense( 16 + content_type: str = "coding", 17 + density: str = "active", 18 + facets: list[dict] | None = None, 19 + summary: str = "Working on code.", 20 + entities: list[dict] | None = None, 21 + ) -> dict: 22 + if facets is None: 23 + facets = [{"facet": "work", "activity": content_type, "level": "high"}] 24 + return { 25 + "density": density, 26 + "content_type": content_type, 27 + "activity_summary": summary, 28 + "entities": entities or [], 29 + "facets": facets, 30 + "meeting_detected": content_type == "meeting", 31 + "speakers": [], 32 + "recommend": {}, 33 + } 34 + 35 + 36 + def _persist_snapshot(journal_root: Path, state_machine: ActivityStateMachine) -> None: 37 + snapshot = { 38 + "last_segment_key": state_machine.last_segment_key, 39 + "last_segment_day": state_machine.last_segment_day, 40 + "active": { 41 + facet: {k: v for k, v in entry.items() if k != "_change"} 42 + for facet, entry in state_machine.state.items() 43 + }, 44 + } 45 + _write_json_atomic(journal_root / "awareness" / "activity_state.json", snapshot) 46 + 47 + 48 + def test_single_segment_facet_wobble_does_not_end(): 49 + sm = ActivityStateMachine() 50 + sm.update(_sense(), "090000_300", DAY) 51 + 52 + missing = sm.update(_sense(facets=[]), "090500_300", DAY) 53 + assert not any(change.get("_change") == "ended_facet_gone" for change in missing) 54 + assert missing[0]["_change"] == "facet_gone_pending" 55 + assert sm.state["work"]["segments"] == ["090000_300", "090500_300"] 56 + 57 + returned = sm.update(_sense(), "091000_300", DAY) 58 + assert not any(change.get("_change") == "ended_facet_gone" for change in returned) 59 + assert sm.state["work"]["state"] == "active" 60 + assert sm.state["work"]["segments"] == [ 61 + "090000_300", 62 + "090500_300", 63 + "091000_300", 64 + ] 65 + assert sm.state["work"]["_pending_facet_misses"] == 0 66 + 67 + 68 + def test_two_segment_facet_gone_ends_at_k(): 69 + sm = ActivityStateMachine() 70 + sm.update(_sense(), "090000_300", DAY) 71 + sm.update(_sense(facets=[]), "090500_300", DAY) 72 + 73 + changes = sm.update(_sense(facets=[]), "091000_300", DAY) 74 + ended = [ 75 + change for change in changes if change.get("_change") == "ended_facet_gone" 76 + ] 77 + 78 + assert len(ended) == 1 79 + assert ended[0]["state"] == "ended" 80 + assert sm.state == {} 81 + completed = sm.get_completed_activities() 82 + assert completed[0]["segments"] == ["090000_300", "090500_300"] 83 + 84 + 85 + def test_single_segment_type_wobble_does_not_end(): 86 + sm = ActivityStateMachine() 87 + sm.update(_sense(summary="Writing code."), "090000_300", DAY) 88 + 89 + wobble = sm.update( 90 + _sense(content_type="meeting", summary="Stand-up."), "090500_300", DAY 91 + ) 92 + assert not any(change.get("_change") == "ended_type_change" for change in wobble) 93 + assert wobble[0]["_change"] == "type_change_pending" 94 + assert sm.state["work"]["description"] == "Writing code." 95 + assert sm.state["work"]["segments"] == ["090000_300", "090500_300"] 96 + 97 + returned = sm.update(_sense(summary="Still coding."), "091000_300", DAY) 98 + assert not any(change.get("_change") == "ended_type_change" for change in returned) 99 + assert sm.get_completed_activities() == [] 100 + assert sm.state["work"]["activity"] == "coding" 101 + assert sm.state["work"]["description"] == "Still coding." 102 + assert sm.state["work"]["_pending_type"] is None 103 + assert sm.state["work"]["_pending_type_count"] == 0 104 + assert sm.state["work"]["segments"] == [ 105 + "090000_300", 106 + "090500_300", 107 + "091000_300", 108 + ] 109 + 110 + 111 + def test_two_segment_type_change_ends_at_k(): 112 + sm = ActivityStateMachine() 113 + sm.update(_sense(content_type="coding"), "090000_300", DAY) 114 + sm.update(_sense(content_type="meeting"), "090500_300", DAY) 115 + 116 + changes = sm.update(_sense(content_type="meeting"), "091000_300", DAY) 117 + ended = [ 118 + change for change in changes if change.get("_change") == "ended_type_change" 119 + ] 120 + active = [change for change in changes if change.get("_change") == "new"] 121 + 122 + assert len(ended) == 1 123 + assert ended[0]["activity"] == "coding" 124 + assert len(active) == 1 125 + assert active[0]["activity"] == "meeting" 126 + assert active[0]["since"] == "091000_300" 127 + assert sm.state["work"]["segments"] == ["091000_300"] 128 + completed = sm.get_completed_activities() 129 + assert completed[0]["segments"] == ["090000_300", "090500_300"] 130 + 131 + 132 + def test_idle_still_ends_immediately(): 133 + sm = ActivityStateMachine() 134 + sm.update(_sense(content_type="coding"), "090000_300", DAY) 135 + sm.update(_sense(content_type="meeting"), "090500_300", DAY) 136 + 137 + changes = sm.update(_sense(density="idle"), "091000_300", DAY) 138 + ended = [change for change in changes if change.get("_change") == "ended_idle"] 139 + 140 + assert len(ended) == 1 141 + assert sm.state == {} 142 + assert sm.get_completed_activities()[0]["segments"] == [ 143 + "090000_300", 144 + "090500_300", 145 + ] 146 + 147 + 148 + def test_gap_still_ends_immediately(): 149 + sm = ActivityStateMachine() 150 + sm.update(_sense(content_type="coding"), "090000_300", DAY) 151 + sm.update(_sense(content_type="meeting"), "090500_300", DAY) 152 + 153 + changes = sm.update(_sense(content_type="coding"), "100600_300", DAY) 154 + ended_gap = [change for change in changes if change.get("_change") == "ended_gap"] 155 + new = [change for change in changes if change.get("_change") == "new"] 156 + 157 + assert len(ended_gap) == 1 158 + assert len(new) == 1 159 + assert sm.get_completed_activities()[0]["segments"] == [ 160 + "090000_300", 161 + "090500_300", 162 + ] 163 + 164 + 165 + def test_pending_counters_reset_on_continuing_segment(): 166 + sm = ActivityStateMachine() 167 + sm.update(_sense(content_type="coding"), "090000_300", DAY) 168 + sm.update(_sense(content_type="meeting"), "090500_300", DAY) 169 + 170 + sm.update(_sense(content_type="coding"), "091000_300", DAY) 171 + entry = sm.state["work"] 172 + 173 + assert entry["_pending_facet_misses"] == 0 174 + assert entry["_pending_type"] is None 175 + assert entry["_pending_type_count"] == 0 176 + 177 + 178 + def test_pending_counters_round_trip_via_awareness_json(tmp_path: Path, monkeypatch): 179 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 180 + 181 + sm1 = ActivityStateMachine(journal_root=tmp_path) 182 + sm1.update(_sense(), "090000_300", DAY) 183 + sm1.update(_sense(facets=[]), "090500_300", DAY) 184 + _persist_snapshot(tmp_path, sm1) 185 + 186 + state_path = tmp_path / "awareness" / "activity_state.json" 187 + data = json.loads(state_path.read_text(encoding="utf-8")) 188 + assert data["active"]["work"]["_pending_facet_misses"] == 1 189 + 190 + sm2 = ActivityStateMachine(journal_root=tmp_path) 191 + changes = sm2.update(_sense(facets=[]), "091000_300", DAY) 192 + ended = [ 193 + change for change in changes if change.get("_change") == "ended_facet_gone" 194 + ] 195 + 196 + assert len(ended) == 1 197 + assert sm2.state == {} 198 + assert sm2.get_completed_activities()[0]["segments"] == [ 199 + "090000_300", 200 + "090500_300", 201 + ] 202 + 203 + 204 + def test_pending_type_counters_round_trip_via_awareness_json( 205 + tmp_path: Path, monkeypatch 206 + ): 207 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 208 + 209 + sm1 = ActivityStateMachine(journal_root=tmp_path) 210 + sm1.update(_sense(content_type="coding"), "090000_300", DAY) 211 + sm1.update(_sense(content_type="meeting"), "090500_300", DAY) 212 + _persist_snapshot(tmp_path, sm1) 213 + 214 + state_path = tmp_path / "awareness" / "activity_state.json" 215 + data = json.loads(state_path.read_text(encoding="utf-8")) 216 + assert data["active"]["work"]["_pending_type"] == "meeting" 217 + assert data["active"]["work"]["_pending_type_count"] == 1 218 + 219 + sm2 = ActivityStateMachine(journal_root=tmp_path) 220 + changes = sm2.update(_sense(content_type="meeting"), "091000_300", DAY) 221 + ended = [ 222 + change for change in changes if change.get("_change") == "ended_type_change" 223 + ] 224 + 225 + assert len(ended) == 1 226 + assert ended[0]["state"] == "ended" 227 + 228 + 229 + def test_facet_recovery_resets_pending_counter(): 230 + sm = ActivityStateMachine() 231 + sm.update(_sense(), "090000_300", DAY) 232 + sm.update(_sense(facets=[]), "090500_300", DAY) 233 + sm.update(_sense(), "091000_300", DAY) 234 + 235 + changes = sm.update(_sense(facets=[]), "091500_300", DAY) 236 + 237 + assert not any(change.get("_change") == "ended_facet_gone" for change in changes) 238 + assert sm.state["work"]["_pending_facet_misses"] == 1 239 + assert sm.state["work"]["segments"] == [ 240 + "090000_300", 241 + "090500_300", 242 + "091000_300", 243 + "091500_300", 244 + ] 245 + 246 + 247 + def test_alternating_type_wobble_does_not_accumulate(): 248 + sm = ActivityStateMachine() 249 + sm.update(_sense(content_type="coding"), "090000_300", DAY) 250 + sm.update(_sense(content_type="meeting"), "090500_300", DAY) 251 + sm.update(_sense(content_type="coding"), "091000_300", DAY) 252 + sm.update(_sense(content_type="meeting"), "091500_300", DAY) 253 + changes = sm.update(_sense(content_type="coding"), "092000_300", DAY) 254 + 255 + assert not any(change.get("_change") == "ended_type_change" for change in changes) 256 + assert sm.get_completed_activities() == [] 257 + assert sm.state["work"]["activity"] == "coding" 258 + assert sm.state["work"]["_pending_type"] is None 259 + assert sm.state["work"]["_pending_type_count"] == 0 260 + assert sm.state["work"]["segments"] == [ 261 + "090000_300", 262 + "090500_300", 263 + "091000_300", 264 + "091500_300", 265 + "092000_300", 266 + ]
+6 -6
tests/test_pipeline_smoke.py
··· 288 288 289 289 coding_rec = records[0] 290 290 assert coding_rec["activity"] == "coding" 291 - assert coding_rec["segments"] == ["090000_300", "090500_300"] 291 + assert coding_rec["segments"] == ["090000_300", "090500_300", "091000_300"] 292 292 assert coding_rec["id"] == make_activity_id("coding", "090000_300") 293 293 assert isinstance(coding_rec["created_at"], int) 294 294 assert isinstance(coding_rec["level_avg"], float) 295 - assert coding_rec["level_avg"] == 1.0 295 + assert coding_rec["level_avg"] == 0.5 296 296 assert isinstance(coding_rec["active_entities"], list) 297 297 assert isinstance(coding_rec["description"], str) 298 298 assert len(coding_rec["description"]) > 0 299 299 300 300 meeting_rec = records[1] 301 301 assert meeting_rec["activity"] == "meeting" 302 - assert meeting_rec["segments"] == ["091000_300", "091500_300"] 303 - assert meeting_rec["id"] == make_activity_id("meeting", "091000_300") 302 + assert meeting_rec["segments"] == ["091500_300"] 303 + assert meeting_rec["id"] == make_activity_id("meeting", "091500_300") 304 304 assert isinstance(meeting_rec["created_at"], int) 305 305 assert isinstance(meeting_rec["level_avg"], float) 306 306 assert meeting_rec["level_avg"] == 0.5 ··· 309 309 assert len(meeting_rec["description"]) > 0 310 310 311 311 # Activity agents fire for BOTH endings: 312 - # 1. Coding ended by type change (segment 3, active path) 312 + # 1. Coding ended by type change (segment 4, active path) 313 313 # 2. Meeting ended by idle (segment 5, idle path) 314 314 assert len(activity_calls) == 2 315 315 ··· 321 321 322 322 assert activity_calls[1]["facet"] == "work" 323 323 assert activity_calls[1]["activity_id"] == make_activity_id( 324 - "meeting", "091000_300" 324 + "meeting", "091500_300" 325 325 ) 326 326 assert activity_calls[1]["day"] == DAY 327 327
+80 -7
tests/test_think_activity.py
··· 694 694 "090000_300", 695 695 "20260304", 696 696 ) 697 - changes = sm.update( 697 + sm.update( 698 698 { 699 699 "density": "active", 700 700 "content_type": "meeting", ··· 708 708 "recommend": {}, 709 709 }, 710 710 "090500_300", 711 + "20260304", 712 + ) 713 + changes = sm.update( 714 + { 715 + "density": "active", 716 + "content_type": "meeting", 717 + "activity_summary": "Stand-up", 718 + "entities": [], 719 + "facets": [ 720 + {"facet": "work", "activity": "meeting", "level": "medium"} 721 + ], 722 + "meeting_detected": True, 723 + "speakers": [], 724 + "recommend": {}, 725 + }, 726 + "091500_300", 711 727 "20260304", 712 728 ) 713 729 ··· 768 784 sm.update(self._sense(content_type="coding"), "090500_300", "20260304") 769 785 sm.update(self._sense(content_type="coding"), "091000_300", "20260304") 770 786 # End via type change 787 + sm.update(self._sense(content_type="meeting"), "091500_300", "20260304") 771 788 changes = sm.update( 772 - self._sense(content_type="meeting"), "091500_300", "20260304" 789 + self._sense(content_type="meeting"), "092000_300", "20260304" 773 790 ) 774 791 775 792 ended = [c for c in changes if c.get("state") == "ended"] ··· 787 804 records = load_activity_records("work", "20260304") 788 805 assert len(records) == 1 789 806 r = records[0] 790 - assert r["segments"] == ["090000_300", "090500_300", "091000_300"] 807 + assert r["segments"] == [ 808 + "090000_300", 809 + "090500_300", 810 + "091000_300", 811 + "091500_300", 812 + ] 791 813 assert r["activity"] == "coding" 792 814 assert isinstance(r["created_at"], int) 793 815 assert r["created_at"] > 0 ··· 827 849 sm = ActivityStateMachine() 828 850 sm.update(self._sense(content_type="coding"), "090000_300", "20260304") 829 851 sm.update(self._sense(content_type="meeting"), "090500_300", "20260304") 852 + sm.update(self._sense(content_type="meeting"), "091000_300", "20260304") 830 853 831 854 rec = sm.get_completed_activities()[0] 832 855 ··· 851 874 sm = ActivityStateMachine() 852 875 # Activity 1 ends 853 876 sm.update(self._sense(content_type="coding"), "090000_300", "20260304") 877 + sm.update(self._sense(content_type="meeting"), "090500_300", "20260304") 854 878 changes1 = sm.update( 855 - self._sense(content_type="meeting"), "090500_300", "20260304" 879 + self._sense(content_type="meeting"), "091000_300", "20260304" 856 880 ) 857 881 facet_by_id = { 858 882 c["id"]: c["facet"] for c in changes1 if c.get("state") == "ended" ··· 863 887 864 888 # Activity 2 continues (no ending) 865 889 changes2 = sm.update( 866 - self._sense(content_type="meeting"), "091000_300", "20260304" 890 + self._sense(content_type="meeting"), "091500_300", "20260304" 867 891 ) 868 892 # No ended changes in this update 869 893 facet_by_id2 = { ··· 901 925 "20260304", 902 926 ) 903 927 sm.update(self._sense(content_type="meeting"), "090500_300", "20260304") 928 + sm.update(self._sense(content_type="meeting"), "091000_300", "20260304") 904 929 905 930 rec = sm.get_completed_activities()[0] 906 931 append_activity_record("work", "20260304", rec) ··· 1008 1033 "090500_300", 1009 1034 "20260304", 1010 1035 ) 1036 + sm.update( 1037 + { 1038 + "density": "active", 1039 + "content_type": "meeting", 1040 + "activity_summary": "standup", 1041 + "entities": [], 1042 + "facets": [ 1043 + {"facet": "work", "activity": "meeting", "level": "medium"} 1044 + ], 1045 + "meeting_detected": True, 1046 + "speakers": [], 1047 + "recommend": {}, 1048 + }, 1049 + "091000_300", 1050 + "20260304", 1051 + ) 1011 1052 rec = sm.get_completed_activities()[0] 1012 1053 append_activity_record("work", "20260304", rec) 1013 1054 ··· 1055 1096 "090000_300", 1056 1097 "20260304", 1057 1098 ) 1099 + sm.update( 1100 + { 1101 + "density": "active", 1102 + "content_type": "meeting", 1103 + "activity_summary": "second", 1104 + "entities": [], 1105 + "facets": [ 1106 + {"facet": "work", "activity": "meeting", "level": "medium"} 1107 + ], 1108 + "meeting_detected": True, 1109 + "speakers": [], 1110 + "recommend": {}, 1111 + }, 1112 + "090500_300", 1113 + "20260304", 1114 + ) 1058 1115 changes1 = sm.update( 1059 1116 { 1060 1117 "density": "active", ··· 1068 1125 "speakers": [], 1069 1126 "recommend": {}, 1070 1127 }, 1071 - "090500_300", 1128 + "091000_300", 1072 1129 "20260304", 1073 1130 ) 1074 1131 # Persist first completed ··· 1082 1139 # Small delay so created_at differs 1083 1140 time.sleep(0.01) 1084 1141 1142 + sm.update( 1143 + { 1144 + "density": "active", 1145 + "content_type": "coding", 1146 + "activity_summary": "third", 1147 + "entities": [], 1148 + "facets": [ 1149 + {"facet": "work", "activity": "coding", "level": "high"} 1150 + ], 1151 + "meeting_detected": False, 1152 + "speakers": [], 1153 + "recommend": {}, 1154 + }, 1155 + "091000_300", 1156 + "20260304", 1157 + ) 1085 1158 changes2 = sm.update( 1086 1159 { 1087 1160 "density": "active", ··· 1095 1168 "speakers": [], 1096 1169 "recommend": {}, 1097 1170 }, 1098 - "091000_300", 1171 + "092000_300", 1099 1172 "20260304", 1100 1173 ) 1101 1174 facet_by_id2 = {
+77 -40
think/activity_state_machine.py
··· 13 13 14 14 # 10 min; R&D default. Existing LLM hook uses 3600. 15 15 GAP_THRESHOLD_SECONDS = 600 16 + END_HYSTERESIS_SEGMENTS = 2 16 17 17 18 18 19 class ActivityStateMachine: ··· 163 164 facet_map[facet["facet"]] = facet 164 165 current_facets = set(facet_map.keys()) 165 166 167 + # Hysteresis invariant: pending segments bridge into the prior activity's segments[]; 168 + # the segment that commits the K-th condition does NOT. 166 169 for facet in sorted(set(self.state.keys()) - current_facets): 167 - prior = self.state.pop(facet) 168 - entry = { 169 - "id": prior["id"], 170 - "activity": prior["activity"], 171 - "state": "ended", 172 - "since": prior["since"], 173 - "description": prior["description"], 174 - "_change": "ended_facet_gone", 175 - "facet": facet, 176 - "segment": segment_key, 177 - } 178 - changes.append(entry) 179 - self._completed.append(self._make_completed_record(prior)) 170 + prior = self.state[facet] 171 + misses = prior.get("_pending_facet_misses", 0) + 1 172 + if misses >= END_HYSTERESIS_SEGMENTS: 173 + prior = self.state.pop(facet) 174 + entry = { 175 + "id": prior["id"], 176 + "activity": prior["activity"], 177 + "state": "ended", 178 + "since": prior["since"], 179 + "description": prior["description"], 180 + "_change": "ended_facet_gone", 181 + "facet": facet, 182 + "segment": segment_key, 183 + } 184 + changes.append(entry) 185 + self._completed.append(self._make_completed_record(prior)) 186 + else: 187 + prior["_pending_facet_misses"] = misses 188 + prior["_change"] = "facet_gone_pending" 189 + prior["segment"] = segment_key 190 + prior.setdefault("segments", [prior["since"]]) 191 + if segment_key not in prior["segments"]: 192 + prior["segments"].append(segment_key) 193 + changes.append(dict(prior)) 180 194 181 195 for facet in sorted(current_facets): 182 196 facet_data = facet_map.get(facet, {}) ··· 186 200 187 201 if facet in self.state: 188 202 prior = self.state[facet] 203 + prior["_pending_facet_misses"] = 0 189 204 if prior["activity"] != content_type: 190 - ended = { 191 - "id": prior["id"], 192 - "activity": prior["activity"], 193 - "state": "ended", 194 - "since": prior["since"], 195 - "description": prior["description"], 196 - "_change": "ended_type_change", 197 - "facet": facet, 198 - "segment": segment_key, 199 - } 200 - changes.append(ended) 201 - self._completed.append(self._make_completed_record(prior)) 205 + pending_type = prior.get("_pending_type") 206 + if pending_type == content_type: 207 + pending_count = prior.get("_pending_type_count", 0) + 1 208 + else: 209 + pending_type = content_type 210 + pending_count = 1 202 211 203 - new_entry = { 204 - "id": make_activity_id(content_type, segment_key), 205 - "activity": content_type, 206 - "state": "active", 207 - "since": segment_key, 208 - "description": activity_summary, 209 - "level": level, 210 - "active_entities": entity_names, 211 - "_change": "new", 212 - "facet": facet, 213 - "segment": segment_key, 214 - "segments": [segment_key], 215 - } 216 - self.state[facet] = new_entry 217 - changes.append(dict(new_entry)) 212 + if pending_count >= END_HYSTERESIS_SEGMENTS: 213 + ended = { 214 + "id": prior["id"], 215 + "activity": prior["activity"], 216 + "state": "ended", 217 + "since": prior["since"], 218 + "description": prior["description"], 219 + "_change": "ended_type_change", 220 + "facet": facet, 221 + "segment": segment_key, 222 + } 223 + changes.append(ended) 224 + self._completed.append(self._make_completed_record(prior)) 225 + 226 + new_entry = { 227 + "id": make_activity_id(content_type, segment_key), 228 + "activity": content_type, 229 + "state": "active", 230 + "since": segment_key, 231 + "description": activity_summary, 232 + "level": level, 233 + "active_entities": entity_names, 234 + "_change": "new", 235 + "facet": facet, 236 + "segment": segment_key, 237 + "segments": [segment_key], 238 + } 239 + self.state[facet] = new_entry 240 + changes.append(dict(new_entry)) 241 + else: 242 + prior["_pending_type"] = pending_type 243 + prior["_pending_type_count"] = pending_count 244 + prior["_change"] = "type_change_pending" 245 + prior["segment"] = segment_key 246 + prior["level"] = level 247 + prior["active_entities"] = entity_names 248 + prior.setdefault("segments", [prior["since"]]) 249 + if segment_key not in prior["segments"]: 250 + prior["segments"].append(segment_key) 251 + changes.append(dict(prior)) 218 252 else: 219 253 prior["description"] = activity_summary 220 254 prior["level"] = level 221 255 prior["active_entities"] = entity_names 256 + prior["_pending_facet_misses"] = 0 257 + prior["_pending_type"] = None 258 + prior["_pending_type_count"] = 0 222 259 prior["_change"] = "continuing" 223 260 prior["segment"] = segment_key 224 261 prior.setdefault("segments", [prior["since"]])
+15 -7
think/thinking.py
··· 684 684 routing_day = state_machine.last_segment_day or day 685 685 idle_changes = state_machine.update(sense_json, segment, day) 686 686 # Persist completed activity records from idle transitions 687 - ended_pairs = [ 688 - (c["id"], c["facet"]) for c in idle_changes if c.get("state") == "ended" 687 + ended_triples = [ 688 + (c["id"], c["facet"], c.get("_change")) 689 + for c in idle_changes 690 + if c.get("state") == "ended" 689 691 ] 690 692 completed_lookup = {} 691 693 for rec in state_machine.get_completed_activities(): 692 694 completed_lookup.setdefault(rec["id"], rec) 693 - for activity_id, facet in ended_pairs: 695 + for activity_id, facet, change in ended_triples: 694 696 _jsonl_log( 695 697 "activity.detected", 696 698 mode=target_schedule, ··· 699 701 activity=str(activity_id), 700 702 facet=str(facet), 701 703 state="ended", 704 + change=change, 702 705 ) 703 706 rec = completed_lookup.get(activity_id) 704 707 if rec: ··· 710 713 segment=segment, 711 714 activity=str(activity_id), 712 715 facet=str(facet), 716 + change=change, 713 717 ) 714 718 # Run activity agents for completed activities 715 - for activity_id, facet in ended_pairs: 719 + for activity_id, facet, _change in ended_triples: 716 720 logging.info( 717 721 "Activity completed (idle): %s facet=%s, running activity agents", 718 722 activity_id, ··· 940 944 routing_day = state_machine.last_segment_day or day 941 945 changes = state_machine.update(sense_json, segment, day) 942 946 # Persist completed activity records before running activity agents 943 - ended_pairs = [ 944 - (c["id"], c["facet"]) for c in changes if c.get("state") == "ended" 947 + ended_triples = [ 948 + (c["id"], c["facet"], c.get("_change")) 949 + for c in changes 950 + if c.get("state") == "ended" 945 951 ] 946 952 completed_lookup = {} 947 953 for rec in state_machine.get_completed_activities(): 948 954 completed_lookup.setdefault(rec["id"], rec) 949 - for activity_id, facet in ended_pairs: 955 + for activity_id, facet, change in ended_triples: 950 956 _jsonl_log( 951 957 "activity.detected", 952 958 mode=target_schedule, ··· 955 961 activity=str(activity_id), 956 962 facet=str(facet), 957 963 state="ended", 964 + change=change, 958 965 ) 959 966 rec = completed_lookup.get(activity_id) 960 967 if rec: ··· 966 973 segment=segment, 967 974 activity=str(activity_id), 968 975 facet=str(facet), 976 + change=change, 969 977 ) 970 978 if state_machine.journal_root is not None: 971 979 try: