personal memory agent
0

Configure Feed

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

feat(push): backend chat-fold push trigger (content-free spn)

When a folded async-dispatch synthesis sol_message (B1's `origin`) is broadcast
on callosum, emit a content-free spn push via the dedup path so the answer finds
an owner who has left chat. Backend trigger only, a sibling to the two existing
push triggers. No iOS routing/badge (I3b), no on-device delivery (founder's spn
gate), no chat-engine change (B1 shipped the fold).

handle_chat_fold (triggers.py) fires only on event=="sol_message" with a
non-empty `origin` dict and falsy `requested_target` (the fold discriminator);
no-ops on dispatch acks, direct answers, and non-sol_message events. It
dispatches via dispatch_dedup_via_portal (id+action only) and never via
dispatch_via_portal (the content/summary leak path). Registered as the third
handler in runtime._on_callosum_message; the three handlers are mutually
exclusive on event.

Design decisions:
- Dedup/routing id = origin["logical_use_id"], NOT the fold's top-level use_id.
This corrects the scope's assumption that they are equal: the synthesis turn
carrying a folded answer gets a freshly-minted top-level use_id, while
origin["logical_use_id"] (the original dispatch turn) is stable across both
the live fold and a recovery-rebuilt fold of the same answer. Relay-side dedup
only works on the stable key, so the wire/dedup request_id is
origin["logical_use_id"]. This is the I3a->I3b contract.
- Fold push action = "chat_answer_ready" (module-level FOLD_PUSH_ACTION in
triggers.py, deliberately kept out of sol_initiated/copy.py's locked-literal
and JS-mirror machinery). This is the wire `action` value I3b/iOS keys on.
- Staleness-bounded viewing suppression: an unmatched owner_chat_open within
_VIEWING_STALENESS_MS (15 min) of the fold suppresses the push
(owner_viewing_chat); older unmatched opens are treated as not-viewing so a
lost owner_chat_dismissed (force-quit/crash/dropped SSE) never suppresses
forever. 15 min covers a continuous reading session that emits no fresh open
while self-healing quickly after a lost dismissed.
- Suppression is owner-global for v1 (presence reduces from the single journal's
chat stream; load_devices is owner-global) and reads only the fold's day (the
15-min window is short relative to a day; cross-midnight is an accepted v1
edge).

Nudge log: kind="chat_fold_push", dedupe_key=<logical_use_id>,
category=FOLD_PUSH_ACTION, seconds ts; skip reasons owner_viewing_chat,
no_relay_token, no_devices, portal_unavailable; dispatched/via="portal" on
success.

Tests: 10-case fold matrix in test_push_triggers.py (fires-when-not-viewing,
content-free recovery shape, ack/direct/non-sol_message no-ops, viewing
suppression, stale-open dispatch, dismissed-clears-suppression, origin-id dedup
invariant, gate skips); test_push_runtime.py updated to assert all three
handlers fire in order.

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

+428 -2
+1
solstone/think/push/runtime.py
··· 40 40 try: 41 41 triggers.handle_sol_chat_request(message) 42 42 triggers.handle_chat_lifecycle(message) 43 + triggers.handle_chat_fold(message) 43 44 except Exception: 44 45 logger.exception("push callosum handler failed") 45 46
+108
solstone/think/push/triggers.py
··· 11 11 from pathlib import Path 12 12 from typing import Any 13 13 14 + from solstone.convey.chat_stream import day_for_ts, read_chat_events 14 15 from solstone.convey.sol_initiated.copy import ( 15 16 KIND_OWNER_CHAT_DISMISSED, 16 17 KIND_OWNER_CHAT_OPEN, ··· 26 27 27 28 logger = logging.getLogger("solstone.push.triggers") 28 29 30 + FOLD_PUSH_ACTION = "chat_answer_ready" 31 + _VIEWING_STALENESS_MS = 15 * 60 * 1000 32 + 29 33 30 34 def _nudge_log_path() -> Path: 31 35 return Path(get_journal()) / "push" / "nudge_log.jsonl" ··· 36 40 path.parent.mkdir(parents=True, exist_ok=True) 37 41 with path.open("a", encoding="utf-8") as handle: 38 42 handle.write(json.dumps(line, ensure_ascii=False) + "\n") 43 + 44 + 45 + def _owner_viewing_chat(fold_ts_ms: int) -> bool: 46 + open_ts: dict[str, int] = {} 47 + for event in read_chat_events(day_for_ts(fold_ts_ms)): 48 + kind = event.get("kind") 49 + request_id = str(event.get("request_id") or "").strip() 50 + if not request_id: 51 + continue 52 + if kind == KIND_OWNER_CHAT_OPEN: 53 + open_ts[request_id] = int(event.get("ts", 0) or 0) 54 + continue 55 + if kind == KIND_OWNER_CHAT_DISMISSED: 56 + open_ts.pop(request_id, None) 57 + 58 + return any( 59 + fold_ts_ms - opened_ts <= _VIEWING_STALENESS_MS 60 + for opened_ts in open_ts.values() 61 + ) 39 62 40 63 41 64 def handle_sol_chat_request(message: dict[str, Any]) -> None: ··· 108 131 ) 109 132 110 133 134 + def handle_chat_fold(message: dict[str, Any]) -> None: 135 + if message.get("tract") != "chat" or message.get("event") != "sol_message": 136 + return 137 + origin = message.get("origin") 138 + if not isinstance(origin, dict) or not origin or message.get("requested_target"): 139 + return 140 + route_id = str(origin.get("logical_use_id") or "").strip() 141 + if not route_id: 142 + return 143 + fold_ts_ms = int(message["ts"]) 144 + kind = "chat_fold_push" 145 + 146 + if _owner_viewing_chat(fold_ts_ms): 147 + _append_nudge_log( 148 + { 149 + "ts": int(time.time()), 150 + "kind": kind, 151 + "dedupe_key": route_id, 152 + "category": FOLD_PUSH_ACTION, 153 + "outcome": "skipped", 154 + "reason": "owner_viewing_chat", 155 + } 156 + ) 157 + return 158 + 159 + if not push_relay_token(): 160 + _append_nudge_log( 161 + { 162 + "ts": int(time.time()), 163 + "kind": kind, 164 + "dedupe_key": route_id, 165 + "category": FOLD_PUSH_ACTION, 166 + "outcome": "skipped", 167 + "reason": "no_relay_token", 168 + } 169 + ) 170 + return 171 + 172 + if not load_devices(): 173 + _append_nudge_log( 174 + { 175 + "ts": int(time.time()), 176 + "kind": kind, 177 + "dedupe_key": route_id, 178 + "category": FOLD_PUSH_ACTION, 179 + "outcome": "skipped", 180 + "reason": "no_devices", 181 + } 182 + ) 183 + return 184 + 185 + portal_result = dispatch_dedup_via_portal( 186 + request_id=route_id, 187 + action=FOLD_PUSH_ACTION, 188 + ) 189 + if portal_result is not None: 190 + _append_nudge_log( 191 + { 192 + "ts": int(time.time()), 193 + "kind": kind, 194 + "dedupe_key": route_id, 195 + "category": FOLD_PUSH_ACTION, 196 + "outcome": "dispatched", 197 + "via": "portal", 198 + } 199 + ) 200 + return 201 + 202 + logger.warning( 203 + "chat fold push skipped: relay dispatch failed request_id=%s", 204 + route_id, 205 + ) 206 + _append_nudge_log( 207 + { 208 + "ts": int(time.time()), 209 + "kind": kind, 210 + "dedupe_key": route_id, 211 + "category": FOLD_PUSH_ACTION, 212 + "outcome": "skipped", 213 + "reason": "portal_unavailable", 214 + } 215 + ) 216 + 217 + 111 218 def handle_chat_lifecycle(message: dict[str, Any]) -> None: 112 219 if message.get("tract") != "chat": 113 220 return ··· 178 285 179 286 180 287 __all__ = [ 288 + "handle_chat_fold", 181 289 "handle_chat_lifecycle", 182 290 "handle_sol_chat_" + "request", 183 291 ]
+11 -2
tests/test_push_runtime.py
··· 106 106 assert get_runtime_state() is None 107 107 108 108 109 - def test_on_callosum_message_calls_both_handlers(monkeypatch): 109 + def test_on_callosum_message_calls_all_handlers(monkeypatch): 110 110 calls: list[tuple[str, dict[str, str]]] = [] 111 111 request_handler = "handle_sol_chat_" + "request" 112 112 monkeypatch.setattr( ··· 118 118 runtime.triggers, 119 119 "handle_chat_lifecycle", 120 120 lambda message: calls.append(("chat_lifecycle", message)), 121 + ) 122 + monkeypatch.setattr( 123 + runtime.triggers, 124 + "handle_chat_fold", 125 + lambda message: calls.append(("chat_fold", message)), 121 126 ) 122 127 message = {"tract": "chat", "event": KIND_SOL_CHAT_REQUEST, "request_id": "req-1"} 123 128 124 129 runtime._on_callosum_message(message) 125 130 126 - assert calls == [("request_handler", message), ("chat_lifecycle", message)] 131 + assert calls == [ 132 + ("request_handler", message), 133 + ("chat_lifecycle", message), 134 + ("chat_fold", message), 135 + ]
+308
tests/test_push_triggers.py
··· 4 4 from __future__ import annotations 5 5 6 6 import json 7 + from datetime import datetime 7 8 from pathlib import Path 8 9 9 10 import pytest 10 11 12 + from solstone.convey.chat_stream import append_chat_event 11 13 from solstone.convey.sol_initiated.copy import ( 12 14 KIND_OWNER_CHAT_DISMISSED, 13 15 KIND_OWNER_CHAT_OPEN, 14 16 KIND_SOL_CHAT_REQUEST, 15 17 ) 16 18 from solstone.think.push import triggers 19 + 20 + _FOLD_TS_MS = int(datetime(2026, 3, 31, 12, 0, 0).timestamp() * 1000) 17 21 18 22 19 23 def _log_path(tmp_path: Path) -> Path: ··· 36 40 "platform": "ios", 37 41 "registered_at": 1, 38 42 } 43 + 44 + 45 + def _fold_message( 46 + *, 47 + use_id: str = "fold-synth-1", 48 + route_id: str = "dispatch-1", 49 + ask: str = "what happened?", 50 + ts: int = _FOLD_TS_MS, 51 + ) -> dict[str, object]: 52 + return { 53 + "tract": "chat", 54 + "event": "sol_message", 55 + "use_id": use_id, 56 + "origin": {"logical_use_id": route_id, "ask": ask}, 57 + "requested_target": None, 58 + "ts": ts, 59 + } 60 + 61 + 62 + def _fail_dispatch_via_portal(**kwargs): 63 + raise AssertionError("dispatch_via_portal should not be called") 64 + 65 + 66 + def _fail_dedup_dispatch(**kwargs): 67 + raise AssertionError("dispatch_dedup_via_portal should not be called") 68 + 69 + 70 + def _install_fold_success(monkeypatch, calls: list[dict[str, str]]) -> None: 71 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 72 + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) 73 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 74 + monkeypatch.setattr( 75 + triggers, 76 + "dispatch_dedup_via_portal", 77 + lambda **kwargs: calls.append(kwargs) or {"ok": True}, 78 + ) 79 + 80 + 81 + def _install_chat_seed(monkeypatch) -> None: 82 + monkeypatch.setattr( 83 + "solstone.think.indexer.journal.index_file", 84 + lambda *_args: True, 85 + ) 39 86 40 87 41 88 def test_handle_sol_chat_request_routes_via_portal(monkeypatch, tmp_path): ··· 241 288 ] 242 289 243 290 291 + def test_handle_chat_fold_routes_content_free_via_dedup(monkeypatch, tmp_path): 292 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 293 + calls: list[dict[str, str]] = [] 294 + _install_fold_success(monkeypatch, calls) 295 + 296 + triggers.handle_chat_fold( 297 + _fold_message(use_id="fold-synth-1", route_id="dispatch-1") 298 + ) 299 + 300 + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] 301 + assert _read_log(tmp_path) == [ 302 + { 303 + "ts": _read_log(tmp_path)[0]["ts"], 304 + "kind": "chat_fold_push", 305 + "dedupe_key": "dispatch-1", 306 + "category": triggers.FOLD_PUSH_ACTION, 307 + "outcome": "dispatched", 308 + "via": "portal", 309 + } 310 + ] 311 + 312 + 313 + def test_handle_chat_fold_recovery_shape_stays_content_free(monkeypatch, tmp_path): 314 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 315 + calls: list[dict[str, str]] = [] 316 + _install_fold_success(monkeypatch, calls) 317 + message = _fold_message( 318 + use_id="recovered-fold-synth", 319 + route_id="dispatch-1", 320 + ask="private owner question", 321 + ) 322 + message.update( 323 + { 324 + "text": "private answer", 325 + "sources": [{"kind": "journal", "title": "private source"}], 326 + "notes": "private notes", 327 + } 328 + ) 329 + 330 + triggers.handle_chat_fold(message) 331 + 332 + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] 333 + row = _read_log(tmp_path)[0] 334 + assert set(row) == {"ts", "kind", "dedupe_key", "category", "outcome", "via"} 335 + assert row == { 336 + "ts": row["ts"], 337 + "kind": "chat_fold_push", 338 + "dedupe_key": "dispatch-1", 339 + "category": triggers.FOLD_PUSH_ACTION, 340 + "outcome": "dispatched", 341 + "via": "portal", 342 + } 343 + 344 + 345 + @pytest.mark.parametrize( 346 + "message", 347 + [ 348 + { 349 + "tract": "chat", 350 + "event": "sol_message", 351 + "use_id": "ack-1", 352 + "requested_target": "exec", 353 + "ts": _FOLD_TS_MS, 354 + }, 355 + { 356 + "tract": "chat", 357 + "event": "sol_message", 358 + "use_id": "direct-1", 359 + "requested_target": None, 360 + "ts": _FOLD_TS_MS, 361 + }, 362 + { 363 + "tract": "chat", 364 + "event": "other", 365 + "use_id": "fold-synth-1", 366 + "origin": {"logical_use_id": "dispatch-1", "ask": "what happened?"}, 367 + "requested_target": None, 368 + "ts": _FOLD_TS_MS, 369 + }, 370 + { 371 + "tract": "chat", 372 + "event": "sol_message", 373 + "use_id": "fold-synth-1", 374 + "origin": {}, 375 + "requested_target": None, 376 + "ts": _FOLD_TS_MS, 377 + }, 378 + { 379 + "tract": "chat", 380 + "event": "sol_message", 381 + "use_id": "fold-synth-1", 382 + "origin": {"logical_use_id": " ", "ask": "what happened?"}, 383 + "requested_target": None, 384 + "ts": _FOLD_TS_MS, 385 + }, 386 + ], 387 + ) 388 + def test_handle_chat_fold_noop_shapes_do_not_dispatch(monkeypatch, tmp_path, message): 389 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 390 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 391 + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) 392 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 393 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) 394 + 395 + triggers.handle_chat_fold(message) 396 + 397 + assert _read_log(tmp_path) == [] 398 + 399 + 400 + def test_handle_chat_fold_suppresses_when_owner_viewing(monkeypatch, tmp_path): 401 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 402 + _install_chat_seed(monkeypatch) 403 + append_chat_event( 404 + KIND_OWNER_CHAT_OPEN, 405 + request_id="visible-request", 406 + surface="convey", 407 + ts=_FOLD_TS_MS - 5 * 60 * 1000, 408 + ) 409 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 410 + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) 411 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 412 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) 413 + 414 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 415 + 416 + assert _read_log(tmp_path) == [ 417 + { 418 + "ts": _read_log(tmp_path)[0]["ts"], 419 + "kind": "chat_fold_push", 420 + "dedupe_key": "dispatch-1", 421 + "category": triggers.FOLD_PUSH_ACTION, 422 + "outcome": "skipped", 423 + "reason": "owner_viewing_chat", 424 + } 425 + ] 426 + 427 + 428 + def test_handle_chat_fold_dispatches_after_stale_open(monkeypatch, tmp_path): 429 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 430 + _install_chat_seed(monkeypatch) 431 + append_chat_event( 432 + KIND_OWNER_CHAT_OPEN, 433 + request_id="stale-request", 434 + surface="convey", 435 + ts=_FOLD_TS_MS - 30 * 60 * 1000, 436 + ) 437 + calls: list[dict[str, str]] = [] 438 + _install_fold_success(monkeypatch, calls) 439 + 440 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 441 + 442 + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] 443 + 444 + 445 + def test_handle_chat_fold_dispatches_after_dismissed_open(monkeypatch, tmp_path): 446 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 447 + _install_chat_seed(monkeypatch) 448 + append_chat_event( 449 + KIND_OWNER_CHAT_OPEN, 450 + request_id="dismissed-request", 451 + surface="convey", 452 + ts=_FOLD_TS_MS - 5 * 60 * 1000, 453 + ) 454 + append_chat_event( 455 + KIND_OWNER_CHAT_DISMISSED, 456 + request_id="dismissed-request", 457 + surface="convey", 458 + reason=None, 459 + ts=_FOLD_TS_MS - 60 * 1000, 460 + ) 461 + calls: list[dict[str, str]] = [] 462 + _install_fold_success(monkeypatch, calls) 463 + 464 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 465 + 466 + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] 467 + 468 + 469 + def test_handle_chat_fold_uses_origin_logical_id_for_dedup(monkeypatch, tmp_path): 470 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 471 + calls: list[dict[str, str]] = [] 472 + _install_fold_success(monkeypatch, calls) 473 + 474 + triggers.handle_chat_fold( 475 + _fold_message(use_id="fold-synth-1", route_id="dispatch-1") 476 + ) 477 + triggers.handle_chat_fold( 478 + _fold_message(use_id="fold-synth-2", route_id="dispatch-1") 479 + ) 480 + 481 + assert calls == [ 482 + {"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}, 483 + {"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}, 484 + ] 485 + 486 + 487 + def test_handle_chat_fold_no_token_skips_without_dispatch(monkeypatch, tmp_path): 488 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 489 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "") 490 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 491 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) 492 + 493 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 494 + 495 + assert _read_log(tmp_path) == [ 496 + { 497 + "ts": _read_log(tmp_path)[0]["ts"], 498 + "kind": "chat_fold_push", 499 + "dedupe_key": "dispatch-1", 500 + "category": triggers.FOLD_PUSH_ACTION, 501 + "outcome": "skipped", 502 + "reason": "no_relay_token", 503 + } 504 + ] 505 + 506 + 507 + def test_handle_chat_fold_no_devices_skips_without_dispatch(monkeypatch, tmp_path): 508 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 509 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 510 + monkeypatch.setattr(triggers, "load_devices", lambda: []) 511 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 512 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) 513 + 514 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 515 + 516 + assert _read_log(tmp_path) == [ 517 + { 518 + "ts": _read_log(tmp_path)[0]["ts"], 519 + "kind": "chat_fold_push", 520 + "dedupe_key": "dispatch-1", 521 + "category": triggers.FOLD_PUSH_ACTION, 522 + "outcome": "skipped", 523 + "reason": "no_devices", 524 + } 525 + ] 526 + 527 + 528 + def test_handle_chat_fold_portal_unavailable_logs_skip(monkeypatch, tmp_path): 529 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 530 + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 531 + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) 532 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 533 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) 534 + 535 + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) 536 + 537 + assert _read_log(tmp_path) == [ 538 + { 539 + "ts": _read_log(tmp_path)[0]["ts"], 540 + "kind": "chat_fold_push", 541 + "dedupe_key": "dispatch-1", 542 + "category": triggers.FOLD_PUSH_ACTION, 543 + "outcome": "skipped", 544 + "reason": "portal_unavailable", 545 + } 546 + ] 547 + 548 + 244 549 @pytest.mark.parametrize( 245 550 "message", 246 551 [ ··· 252 557 def test_non_chat_or_wrong_event_messages_are_noops(monkeypatch, tmp_path, message): 253 558 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 254 559 monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") 560 + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) 561 + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) 255 562 256 563 triggers.handle_sol_chat_request(message) 257 564 triggers.handle_chat_lifecycle(message) 565 + triggers.handle_chat_fold(message) 258 566 259 567 assert _read_log(tmp_path) == [] 260 568