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:
@@ -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.
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user