feat: thought loop reach-out (ntfy push) + external input feeds

Her remaining two wishes from the 6-19 sketch:

Proactive reach-out (#6, literal): lyra/notify.py pushes to ntfy so she can reach
Brian when he's not in the app. thoughts.maybe_ping gates on salience, a cooldown,
and local quiet hours (all config-tunable; eager defaults), uses ntfy JSON publish
(UTF-8 titles/messages), links to /thoughts, and marks the thread surfaced so chat
won't also re-raise it. Disabled unless NTFY_URL is set.

External input feed (#1): lyra/feeds.py pulls configurable RSS/Atom feeds (stdlib
ElementTree, no new dep; tolerant of RSS 2.0 + Atom), dedupes seen items in a
feed_items table, and hands think() one fresh item at a time. New 'react' mode:
a would-be new thread instead reacts to a world item (FEED_REACT_PROB). Dream
cycle refreshes feeds on its cadence; failures degrade to no item.

Config: NTFY_URL/NTFY_TOPIC/LYRA_WEB_URL, PING_SALIENCE/COOLDOWN/QUIET_HOURS,
LYRA_TIMEZONE, LYRA_FEEDS, FEED_REACT_PROB (+ .env.example). thought_meta table
for ping cooldown. 10 new tests (feeds parse, react mode, ping gating); suite 65.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 00:21:06 +00:00
parent 951788f9ec
commit 5dbcfc7ccf
8 changed files with 394 additions and 20 deletions
+44
View File
@@ -0,0 +1,44 @@
"""Outbound push so Lyra can reach Brian when he's not in the app (ntfy).
This is the literal version of what she asked for — thinking "unprompted, without
you" only matters if she can also *reach* you. When a thought tugs hard enough,
the thought loop calls `push()` here and it lands on your phone with a tap-through
to the Thoughts feed. One-way: you reply in the app, which feeds the loop.
Transport only. Whether/when to ping (salience bar, cooldown, quiet hours) is the
thought loop's call — see `thoughts.maybe_ping`.
"""
from __future__ import annotations
import httpx
from lyra import config, logbus
def push(title: str, message: str, click: str | None = None,
tags: str | None = None, priority: str | None = None) -> bool:
"""Publish a notification to the configured ntfy topic. Returns True on success.
Never raises — a down ntfy must not break the thought loop.
Uses ntfy's JSON publishing (POST to the base URL) rather than headers, so
UTF-8 titles/messages (em-dashes, smart quotes, her actual words) go through —
HTTP headers are latin-1 only and choke on them."""
cfg = config.load()
if not cfg.ntfy_url:
return False
payload: dict = {"topic": cfg.ntfy_topic, "message": message, "title": title}
if click:
payload["click"] = click
if tags:
payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
if priority:
payload["priority"] = priority
try:
r = httpx.post(cfg.ntfy_url, json=payload, timeout=8.0)
ok = r.status_code < 400
if not ok:
logbus.log("error", "ntfy push failed", status=r.status_code)
return ok
except Exception as exc:
logbus.log("error", "ntfy push error", error=str(exc)[:160])
return False