personal memory agent
0
fork

Configure Feed

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

push: deliver weekly reflection alerts

+292 -21
+1
convey/push.py
··· 120 120 title=title, 121 121 body=message, 122 122 context_id=f"push-test-{uuid.uuid4().hex[:12]}", 123 + route="/app/home", 123 124 ) 124 125 return jsonify({"sent": sent, "failed": failed}) 125 126
+15
tests/test_push_dispatch.py
··· 122 122 assert "interruption-level" not in payload["aps"] 123 123 124 124 125 + def test_agent_alert_payload_includes_route_when_present(): 126 + payload = dispatch.build_agent_alert_payload( 127 + title="Agent Alert", 128 + body="Needs review", 129 + context_id="ctx-1", 130 + route="/app/reflections/20260308", 131 + ) 132 + 133 + assert payload["data"] == { 134 + "action": "open_alert", 135 + "context_id": "ctx-1", 136 + "route": "/app/reflections/20260308", 137 + } 138 + 139 + 125 140 def test_commitment_payload_shape(): 126 141 payload = dispatch.build_commitment_payload(ledger_id="lg_123") 127 142
+1 -1
tests/test_push_routes.py
··· 128 128 monkeypatch.setattr("convey.push.is_configured", lambda: True) 129 129 monkeypatch.setattr( 130 130 "convey.push.triggers.send_agent_alert", 131 - lambda *, title, body, context_id: (1, 0), 131 + lambda *, title, body, context_id, route: (1, 0), 132 132 ) 133 133 134 134 response = push_client.post(
+32 -12
tests/test_push_runtime.py
··· 6 6 import pytest 7 7 from flask import Flask 8 8 9 + from think.push import runtime 9 10 from think.push.runtime import ( 10 11 get_runtime_state, 11 12 start_push_runtime, ··· 35 36 36 37 start_push_runtime(app) 37 38 try: 38 - runtime = get_runtime_state() 39 + runtime_state = get_runtime_state() 39 40 assert app.push_runtime_started is True 40 - assert runtime is not None 41 - assert runtime.loop is not None 42 - assert runtime.thread is not None 41 + assert runtime_state is not None 42 + assert runtime_state.loop is not None 43 + assert runtime_state.thread is not None 43 44 assert calls == ["start"] 44 45 finally: 45 46 stop_push_runtime(app) ··· 53 54 app = Flask(__name__) 54 55 55 56 start_push_runtime(app) 56 - runtime = get_runtime_state() 57 - first_loop = runtime.loop if runtime else None 58 - first_thread = runtime.thread if runtime else None 57 + runtime_state = get_runtime_state() 58 + first_loop = runtime_state.loop if runtime_state else None 59 + first_thread = runtime_state.thread if runtime_state else None 59 60 try: 60 61 start_push_runtime(app) 61 - runtime = get_runtime_state() 62 - assert runtime is not None 63 - assert runtime.loop is first_loop 64 - assert runtime.thread is first_thread 65 - assert runtime.apps.count(app) == 1 62 + runtime_state = get_runtime_state() 63 + assert runtime_state is not None 64 + assert runtime_state.loop is first_loop 65 + assert runtime_state.thread is first_thread 66 + assert runtime_state.apps.count(app) == 1 66 67 finally: 67 68 stop_push_runtime(app) 68 69 ··· 93 94 94 95 assert app.push_runtime_started is False 95 96 assert get_runtime_state() is None 97 + 98 + 99 + def test_on_callosum_message_calls_both_handlers(monkeypatch): 100 + calls: list[tuple[str, dict[str, str]]] = [] 101 + monkeypatch.setattr( 102 + runtime.triggers, 103 + "handle_briefing_finish", 104 + lambda message: calls.append(("briefing", message)), 105 + ) 106 + monkeypatch.setattr( 107 + runtime.triggers, 108 + "handle_weekly_reflection_finish", 109 + lambda message: calls.append(("weekly_reflection", message)), 110 + ) 111 + message = {"tract": "cortex", "event": "finish", "name": "weekly_reflection"} 112 + 113 + runtime._on_callosum_message(message) 114 + 115 + assert calls == [("briefing", message), ("weekly_reflection", message)]
+185 -3
tests/test_push_triggers.py
··· 186 186 def test_send_agent_alert_same_context_id_fires_once(monkeypatch, tmp_path): 187 187 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 188 188 monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) 189 - sent_calls: list[str] = [] 189 + sent_calls: list[dict[str, object]] = [] 190 190 monkeypatch.setattr( 191 191 triggers, 192 192 "send_many", 193 193 lambda devices, payload, *, collapse_id: ( 194 - sent_calls.append(collapse_id) or (1, 0) 194 + sent_calls.append({"collapse_id": collapse_id, "payload": payload}) 195 + or (1, 0) 195 196 ), 196 197 ) 197 198 ··· 204 205 205 206 assert first == (1, 0) 206 207 assert second == (0, 0) 207 - assert sent_calls == ["alert.ctx-1"] 208 + assert sent_calls == [ 209 + { 210 + "collapse_id": "alert.ctx-1", 211 + "payload": { 212 + "aps": { 213 + "alert": {"title": "Agent Alert", "body": "Needs review"}, 214 + "category": "SOLSTONE_AGENT_ALERT", 215 + "sound": "default", 216 + "mutable-content": 1, 217 + "content-available": 1, 218 + }, 219 + "data": {"action": "open_alert", "context_id": "ctx-1"}, 220 + }, 221 + } 222 + ] 208 223 lines = [ 209 224 json.loads(line) 210 225 for line in _log_path(tmp_path).read_text(encoding="utf-8").splitlines() 211 226 ] 212 227 assert len(lines) == 1 228 + 229 + 230 + def test_send_agent_alert_forwards_route(monkeypatch, tmp_path): 231 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 232 + monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) 233 + payloads: list[dict[str, object]] = [] 234 + monkeypatch.setattr( 235 + triggers, 236 + "send_many", 237 + lambda devices, payload, *, collapse_id: payloads.append(payload) or (1, 0), 238 + ) 239 + 240 + sent, failed = triggers.send_agent_alert( 241 + title="Agent Alert", 242 + body="Needs review", 243 + context_id="ctx-2", 244 + route="/app/reflections/20260308", 245 + ) 246 + 247 + assert (sent, failed) == (1, 0) 248 + assert payloads[0]["data"]["route"] == "/app/reflections/20260308" 249 + 250 + 251 + def test_handle_weekly_reflection_finish_sends_once_and_appends_chat_event( 252 + monkeypatch, tmp_path 253 + ): 254 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 255 + reflection_path = tmp_path / "reflections" / "weekly" / "20260308.md" 256 + reflection_path.parent.mkdir(parents=True, exist_ok=True) 257 + reflection_path.write_text("# reflection\n", encoding="utf-8") 258 + monkeypatch.setattr(triggers.time, "sleep", lambda seconds: None) 259 + monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) 260 + sent_calls: list[dict[str, object]] = [] 261 + chat_events: list[dict[str, str]] = [] 262 + monkeypatch.setattr( 263 + triggers, 264 + "send_many", 265 + lambda devices, payload, *, collapse_id: ( 266 + sent_calls.append({"payload": payload, "collapse_id": collapse_id}) 267 + or (1, 0) 268 + ), 269 + ) 270 + monkeypatch.setattr( 271 + triggers, 272 + "append_chat_event", 273 + lambda kind, **fields: chat_events.append({"kind": kind, **fields}), 274 + ) 275 + 276 + message = { 277 + "tract": "cortex", 278 + "event": "finish", 279 + "name": "weekly_reflection", 280 + "day": "20260308", 281 + } 282 + triggers.handle_weekly_reflection_finish(message) 283 + triggers.handle_weekly_reflection_finish(message) 284 + 285 + assert sent_calls == [ 286 + { 287 + "payload": { 288 + "aps": { 289 + "alert": {"title": "your week is ready", "body": ""}, 290 + "category": "SOLSTONE_AGENT_ALERT", 291 + "sound": "default", 292 + "mutable-content": 1, 293 + "content-available": 1, 294 + }, 295 + "data": { 296 + "action": "open_alert", 297 + "context_id": "weekly_reflection:20260308", 298 + "route": "/app/reflections/20260308", 299 + }, 300 + }, 301 + "collapse_id": "alert.weekly_reflection:20260308", 302 + } 303 + ] 304 + assert chat_events == [ 305 + { 306 + "kind": "reflection_ready", 307 + "day": "20260308", 308 + "url": "/app/reflections/20260308", 309 + } 310 + ] 311 + lines = [ 312 + json.loads(line) 313 + for line in _log_path(tmp_path).read_text(encoding="utf-8").splitlines() 314 + ] 315 + assert len(lines) == 1 316 + assert lines[0]["category"] == "SOLSTONE_AGENT_ALERT" 317 + assert lines[0]["context_id"] == "weekly_reflection:20260308" 318 + 319 + 320 + def test_handle_weekly_reflection_finish_ignores_unrelated_events( 321 + monkeypatch, tmp_path 322 + ): 323 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 324 + send_calls: list[tuple] = [] 325 + monkeypatch.setattr( 326 + triggers, 327 + "send_agent_alert", 328 + lambda **kwargs: send_calls.append(tuple(sorted(kwargs.items()))) or (1, 0), 329 + ) 330 + monkeypatch.setattr( 331 + triggers, 332 + "append_chat_event", 333 + lambda kind, **fields: send_calls.append(("chat", kind, fields)), 334 + ) 335 + 336 + triggers.handle_weekly_reflection_finish( 337 + { 338 + "tract": "chat", 339 + "event": "finish", 340 + "name": "weekly_reflection", 341 + "day": "20260308", 342 + } 343 + ) 344 + triggers.handle_weekly_reflection_finish( 345 + { 346 + "tract": "cortex", 347 + "event": "start", 348 + "name": "weekly_reflection", 349 + "day": "20260308", 350 + } 351 + ) 352 + triggers.handle_weekly_reflection_finish( 353 + { 354 + "tract": "cortex", 355 + "event": "finish", 356 + "name": "morning_briefing", 357 + "day": "20260308", 358 + } 359 + ) 360 + 361 + assert send_calls == [] 362 + 363 + 364 + def test_handle_weekly_reflection_finish_skips_when_file_never_appears( 365 + monkeypatch, tmp_path 366 + ): 367 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 368 + sleeps: list[int] = [] 369 + send_calls: list[dict[str, object]] = [] 370 + chat_events: list[dict[str, object]] = [] 371 + monkeypatch.setattr(triggers.time, "sleep", lambda seconds: sleeps.append(seconds)) 372 + monkeypatch.setattr( 373 + triggers, 374 + "send_agent_alert", 375 + lambda **kwargs: send_calls.append(kwargs) or (1, 0), 376 + ) 377 + monkeypatch.setattr( 378 + triggers, 379 + "append_chat_event", 380 + lambda kind, **fields: chat_events.append({"kind": kind, **fields}), 381 + ) 382 + 383 + triggers.handle_weekly_reflection_finish( 384 + { 385 + "tract": "cortex", 386 + "event": "finish", 387 + "name": "weekly_reflection", 388 + "day": "20260308", 389 + } 390 + ) 391 + 392 + assert sleeps == [1] * 10 393 + assert send_calls == [] 394 + assert chat_events == []
+5 -2
think/push/dispatch.py
··· 171 171 172 172 173 173 def build_agent_alert_payload( 174 - *, title: str, body: str, context_id: str 174 + *, title: str, body: str, context_id: str, route: str | None = None 175 175 ) -> dict[str, Any]: 176 + data = {"action": "open_alert", "context_id": context_id} 177 + if route is not None: 178 + data["route"] = route 176 179 return { 177 180 "aps": { 178 181 "alert": {"title": title, "body": body}, ··· 181 184 "mutable-content": 1, 182 185 "content-available": 1, 183 186 }, 184 - "data": {"action": "open_alert", "context_id": context_id}, 187 + "data": data, 185 188 } 186 189 187 190
+1
think/push/runtime.py
··· 41 41 def _on_callosum_message(message: dict[str, Any]) -> None: 42 42 try: 43 43 triggers.handle_briefing_finish(message) 44 + triggers.handle_weekly_reflection_finish(message) 44 45 except Exception: 45 46 logger.exception("push callosum handler failed") 46 47
+52 -3
think/push/triggers.py
··· 13 13 from typing import Any 14 14 15 15 from apps.home.routes import _load_briefing_md 16 + from convey.chat_stream import append_chat_event 16 17 from think.activities import load_activity_records 17 18 from think.facets import get_enabled_facets 18 19 from think.push.config import get_bundle_id, get_environment, is_configured ··· 214 215 ) 215 216 216 217 217 - def send_agent_alert(*, title: str, body: str, context_id: str) -> tuple[int, int]: 218 + def send_agent_alert( 219 + *, title: str, body: str, context_id: str, route: str | None = None 220 + ) -> tuple[int, int]: 218 221 dedupe_key = (CATEGORY_AGENT_ALERT, context_id) 219 222 if _has_nudged(dedupe_key): 220 223 return 0, 0 ··· 223 226 return 0, 0 224 227 sent, failed = send_many( 225 228 eligible_devices, 226 - build_agent_alert_payload(title=title, body=body, context_id=context_id), 229 + build_agent_alert_payload( 230 + title=title, 231 + body=body, 232 + context_id=context_id, 233 + route=route, 234 + ), 227 235 collapse_id=build_agent_alert_collapse_id(context_id), 228 236 ) 229 237 if sent > 0: ··· 237 245 return sent, failed 238 246 239 247 240 - __all__ = ["check_pre_meeting_prep", "handle_briefing_finish", "send_agent_alert"] 248 + def handle_weekly_reflection_finish(message: dict[str, Any]) -> None: 249 + if message.get("tract") != "cortex": 250 + return 251 + if message.get("event") != "finish": 252 + return 253 + if message.get("name") != "weekly_reflection": 254 + return 255 + 256 + day = str(message.get("day") or "").strip() 257 + if not day: 258 + return 259 + 260 + context_id = f"weekly_reflection:{day}" 261 + dedupe_key = (CATEGORY_AGENT_ALERT, context_id) 262 + if _has_nudged(dedupe_key): 263 + return 264 + 265 + reflection_path = Path(get_journal()) / "reflections" / "weekly" / f"{day}.md" 266 + for _ in range(10): 267 + if reflection_path.is_file(): 268 + break 269 + time.sleep(1) 270 + else: 271 + logger.warning("push weekly reflection unavailable after finish day=%s", day) 272 + return 273 + 274 + route = f"/app/reflections/{day}" 275 + send_agent_alert( 276 + title="your week is ready", 277 + body="", 278 + context_id=context_id, 279 + route=route, 280 + ) 281 + append_chat_event("reflection_ready", day=day, url=route) 282 + 283 + 284 + __all__ = [ 285 + "check_pre_meeting_prep", 286 + "handle_briefing_finish", 287 + "handle_weekly_reflection_finish", 288 + "send_agent_alert", 289 + ]