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
+1
View File
@@ -12,6 +12,7 @@ def lyra(tmp_path, monkeypatch):
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("SUMMARY_BACKEND", "local")
monkeypatch.setenv("LYRA_FEEDS", "") # dream cycle refreshes feeds; keep it offline
from lyra import llm
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
+72
View File
@@ -13,6 +13,7 @@ from lyra import clock
@pytest.fixture
def lyra(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
monkeypatch.delenv("NTFY_URL", raising=False) # baseline: pinging disabled (ignore .env)
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
@@ -20,12 +21,17 @@ def lyra(tmp_path, monkeypatch):
importlib.reload(memory)
import lyra.self_state as self_state
importlib.reload(self_state)
import lyra.feeds as feeds
importlib.reload(feeds)
import lyra.thoughts as thoughts
importlib.reload(thoughts)
# Canned LLM: tests set `box["next"]` to the dict think() should "generate".
box = {"next": {}}
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"]))
# Keep the loop offline + silent by default: no feed fetch, no push.
monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None)
monkeypatch.setattr(thoughts.notify, "push", lambda **k: False)
return memory, thoughts, box
@@ -179,3 +185,69 @@ def test_think_about_tool_seeds_a_thread(lyra):
assert len(threads) == 1 and threads[0]["title"] == "am I continuous?"
chain = th.thread_thoughts(threads[0]["id"])
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
# --- external feed -------------------------------------------------------
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
b'<item><title>Poker tip</title><link>http://x/1</link>'
b'<description>3-bet more in position</description><guid>g1</guid></item>'
b'<item><title>Second</title><link>http://x/2</link><description>d2</description></item>'
b'</channel></rss>')
ATOM = (b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>F</title>'
b'<entry><title>HN post</title><link href="http://y/1"/>'
b'<summary>something interesting</summary><id>a1</id></entry></feed>')
def test_feeds_parse_rss_and_atom():
from lyra import feeds
rss = feeds.parse(RSS)
assert len(rss) == 2
assert rss[0]["id"] == "g1" and rss[0]["title"] == "Poker tip" and rss[0]["link"] == "http://x/1"
assert rss[1]["id"] == "http://x/2" # falls back to link when no guid
atom = feeds.parse(ATOM)
assert len(atom) == 1 and atom[0]["id"] == "a1" and atom[0]["link"] == "http://y/1"
assert feeds.parse(b"not xml") == [] # garbage -> empty, no raise
def test_react_mode_makes_a_thread_about_a_feed_item(lyra, monkeypatch):
_, th, box = lyra
item = {"id": "x1", "title": "World Item", "link": "http://e", "summary": "stuff happened"}
monkeypatch.setattr(th.feeds, "next_item", lambda **k: item)
used = []
monkeypatch.setattr(th.feeds, "mark_used", lambda i: used.append(i))
box["next"] = {"kind": "observation", "content": "that makes me think...", "salience": 0.5, "status": "open"}
rep = th.think(force_mode="react")
assert rep["mode"] == "react"
assert th.list_threads()[0]["title"] == "World Item" # titled from the item
assert used == ["x1"] # item consumed
# --- proactive reach-out (ntfy) ------------------------------------------
def test_maybe_ping_gates_on_salience_and_records(lyra, monkeypatch):
_, th, box = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0") # disable quiet window for the test
sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
_gen(box, title="big one", content="this really tugs", salience=0.9)
r = th.think(force_mode="new") # high salience -> should ping
assert len(sent) == 1 and "big one" in sent[0]["title"]
assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced
assert th._meta_get("last_ping_at")
sent.clear()
assert th.maybe_ping(r["thread_id"], "x", "quiet musing", 0.4) is False # below bar
assert sent == []
def test_no_ping_without_ntfy(lyra, monkeypatch):
_, th, _ = lyra
sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
# no NTFY_URL in env -> disabled regardless of salience
assert th.maybe_ping(1, "t", "c", 0.99) is False
assert sent == []