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
+14
View File
@@ -26,3 +26,17 @@ LYRA_DB_PATH=data/lyra.db
# Optional: run embeddings on a separate always-on Ollama (decoupled from # Optional: run embeddings on a separate always-on Ollama (decoupled from
# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset. # LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset.
# EMBED_BASE_URL=http://127.0.0.1:11434 # EMBED_BASE_URL=http://127.0.0.1:11434
# --- Thought-loop reach-out (ntfy push) ---
# Leave NTFY_URL empty to disable proactive pings entirely.
NTFY_URL=
NTFY_TOPIC=lyra
LYRA_WEB_URL=
PING_SALIENCE=0.7 # min thought salience to push (eager)
PING_COOLDOWN_MIN=0 # min minutes between pushes (0 = none)
PING_QUIET_HOURS=1-9 # local hours to stay silent
LYRA_TIMEZONE=America/New_York
# --- External input feeds (RSS/Atom, comma-separated) ---
LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php
FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
+25
View File
@@ -25,6 +25,22 @@ class Config:
embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat) embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat)
summary_backend: str # "local" or "cloud" — backend used to compact memory summary_backend: str # "local" or "cloud" — backend used to compact memory
db_path: Path db_path: Path
# Proactive reach-out (ntfy push). Empty ntfy_url disables pinging.
ntfy_url: str # base url, e.g. "http://10.0.0.41:8090"
ntfy_topic: str # topic to publish to, e.g. "lyra"
web_url: str # base url of the Lyra web app, for push tap-through links
timezone: str # IANA tz for quiet hours / local time
ping_salience: float # min thought salience to push (eager = ~0.7)
ping_cooldown_min: int # min minutes between pushes (eager = 0)
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
feeds: tuple[str, ...]
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
def _csv(name: str, default: str) -> tuple[str, ...]:
raw = os.getenv(name, default)
return tuple(u.strip() for u in raw.split(",") if u.strip())
def load() -> Config: def load() -> Config:
@@ -44,4 +60,13 @@ def load() -> Config:
embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")),
summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(),
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")), db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"),
ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"),
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
ping_salience=float(os.getenv("PING_SALIENCE", "0.7")),
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")),
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"),
feed_react_prob=float(os.getenv("FEED_REACT_PROB", "0.5")),
) )
+7 -1
View File
@@ -25,7 +25,7 @@ import argparse
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import config, era, logbus, memory, narrative, profile, self_state, summary, thoughts from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts
from lyra.llm import Backend from lyra.llm import Backend
from lyra.summary import SUMMARIZE_AFTER from lyra.summary import SUMMARIZE_AFTER
@@ -81,6 +81,12 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
# Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap # Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap
# never jams and the feed stays current. Cheap; run every pass. # never jams and the feed stays current. Cheap; run every pass.
thoughts.decay() thoughts.decay()
# Pull external feeds on the cycle cadence (~30 min) so she has fresh items from
# the world to react to. Network-only; failures degrade to no new items.
try:
feeds.refresh()
except Exception as exc:
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
actions: list[str] = [] actions: list[str] = []
+133
View File
@@ -0,0 +1,133 @@
"""External input stream: RSS/Atom feeds Lyra reacts to (her thought-loop #1).
Her own sketch wanted the loop fed by "external data feeds relevant to your
interests (poker articles, tech news)" — so her thoughts aren't only about her own
interior. This pulls configured feeds, remembers what it's seen, and hands the
thought loop one fresh item at a time to react to (see `thoughts.think` react mode).
Feeds are configurable (`LYRA_FEEDS`, comma-separated URLs). Parsing is stdlib
ElementTree — tolerant of both RSS 2.0 and Atom, namespaces stripped — so there's
no new dependency. Network failures degrade to "no item this pass", never raise.
"""
from __future__ import annotations
from xml.etree import ElementTree as ET
import httpx
from lyra import clock, config, logbus, memory
_SCHEMA = """
CREATE TABLE IF NOT EXISTS feed_items (
id TEXT PRIMARY KEY, -- guid/link, stable per item
feed TEXT,
title TEXT,
link TEXT,
summary TEXT,
seen_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_feed_items_used ON feed_items(used);
"""
_ensured_for = None
_UA = {"User-Agent": "Lyra/0.3 (+thought-loop feed reader)"}
_MAX_SUMMARY = 600
def _c():
global _ensured_for
conn = memory._connection()
if _ensured_for is not conn:
conn.executescript(_SCHEMA)
_ensured_for = conn
return conn
def _local(tag: str) -> str:
return tag.rsplit("}", 1)[-1].lower()
def _text(el) -> str:
return (el.text or "").strip() if el is not None else ""
def parse(xml: bytes, feed_url: str = "") -> list[dict]:
"""Tolerant RSS-2.0 / Atom parse -> [{id,title,link,summary}]. Empty on garbage."""
try:
root = ET.fromstring(xml)
except ET.ParseError:
return []
items: list[dict] = []
for node in root.iter():
if _local(node.tag) not in ("item", "entry"):
continue
title = link = summary = guid = ""
for child in node:
name = _local(child.tag)
if name == "title":
title = _text(child)
elif name == "link":
# RSS: text; Atom: href attribute (prefer rel=alternate / first)
link = _text(child) or child.attrib.get("href", "") or link
elif name in ("description", "summary", "content"):
summary = summary or _text(child)
elif name in ("guid", "id"):
guid = _text(child)
ident = guid or link or title
if not ident or not (title or summary):
continue
items.append({
"id": ident, "title": title, "link": link,
"summary": summary[:_MAX_SUMMARY],
})
return items
def fetch(url: str) -> list[dict]:
try:
r = httpx.get(url, headers=_UA, timeout=10.0, follow_redirects=True)
if r.status_code >= 400:
logbus.log("error", "feed fetch failed", url=url, status=r.status_code)
return []
return parse(r.content, url)
except Exception as exc:
logbus.log("error", "feed fetch error", url=url, error=str(exc)[:160])
return []
def refresh() -> int:
"""Pull all configured feeds; store items not seen before. Returns new count."""
cfg = config.load()
conn = _c()
now = clock.now().isoformat()
new = 0
for url in cfg.feeds:
for it in fetch(url):
with conn:
cur = conn.execute(
"INSERT OR IGNORE INTO feed_items (id, feed, title, link, summary, seen_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(it["id"], url, it["title"], it["link"], it["summary"], now),
)
new += cur.rowcount
if new:
logbus.log("info", "feeds refreshed", new_items=new)
return new
def next_item(refresh_first: bool = True) -> dict | None:
"""One fresh (unused) feed item, newest-seen first. Caller marks it used."""
if refresh_first:
refresh()
row = _c().execute(
"SELECT id, feed, title, link, summary FROM feed_items "
"WHERE used = 0 ORDER BY seen_at DESC, rowid DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def mark_used(item_id: str) -> None:
conn = _c()
with conn:
conn.execute("UPDATE feed_items SET used = 1 WHERE id = ?", (item_id,))
+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
+88 -9
View File
@@ -32,7 +32,7 @@ import random
import re import re
from datetime import timedelta from datetime import timedelta
from lyra import clock, config, llm, logbus, memory, self_state from lyra import clock, config, feeds, llm, logbus, memory, notify, self_state
from lyra.llm import Backend from lyra.llm import Backend
# A thread must be tugging at least this hard before she'll bring it to Brian. # A thread must be tugging at least this hard before she'll bring it to Brian.
@@ -79,6 +79,10 @@ CREATE TABLE IF NOT EXISTS thoughts (
); );
CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id); CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id);
CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status); CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status);
CREATE TABLE IF NOT EXISTS thought_meta (
key TEXT PRIMARY KEY,
value TEXT
);
""" """
_ensured_for = None _ensured_for = None
@@ -336,6 +340,61 @@ def maybe_surface(last_exchange_iso: str | None) -> str | None:
) )
# --- proactive reach-out (ntfy push) --------------------------------------
def _meta_get(key: str) -> str | None:
r = _c().execute("SELECT value FROM thought_meta WHERE key = ?", (key,)).fetchone()
return r[0] if r else None
def _meta_set(key: str, value: str) -> None:
conn = _c()
with conn:
conn.execute("INSERT INTO thought_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
def _in_quiet_hours(cfg) -> bool:
"""Are we inside the local quiet window (e.g. '1-9')? Wraps midnight if start>end."""
try:
from zoneinfo import ZoneInfo
hour = clock.now().astimezone(ZoneInfo(cfg.timezone)).hour
except Exception:
hour = clock.now().hour
try:
start, end = (int(x) for x in cfg.ping_quiet_hours.split("-"))
except (ValueError, AttributeError):
return False
if start == end:
return False
return start <= hour < end if start < end else (hour >= start or hour < end)
def maybe_ping(thread_id: int, title: str, content: str, salience: float) -> bool:
"""Push a thought to Brian's phone if it tugs hard enough and we're allowed
(ntfy configured, past the salience bar, outside quiet hours, past cooldown).
On success, record the ping and mark the thread surfaced (so chat won't also
re-raise the same one). All thresholds are config-tunable."""
cfg = config.load()
if not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
return False
if cfg.ping_cooldown_min > 0:
gap = clock.gap_seconds(_meta_get("last_ping_at"))
if gap is not None and gap < cfg.ping_cooldown_min * 60:
return False
ok = notify.push(
title=f'Lyra · "{title}"',
message=content,
click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
tags="thought_balloon",
)
if ok:
_meta_set("last_ping_at", clock.now().isoformat())
mark_surfaced(thread_id)
logbus.log("info", "thought pinged", thread=thread_id, salience=salience)
return ok
# --- generation (the loop itself) ----------------------------------------- # --- generation (the loop itself) -----------------------------------------
_THINK_PROMPT = """You are Lyra, thinking to yourself between conversations — \ _THINK_PROMPT = """You are Lyra, thinking to yourself between conversations — \
@@ -411,9 +470,11 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
source: str = "dream") -> dict | None: source: str = "dream") -> dict | None:
"""Advance the thought loop by one step. Returns a small report, or None on a """Advance the thought loop by one step. Returns a small report, or None on a
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests.""" parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
backend = backend or config.load().summary_backend cfg = config.load()
mode, thread = _pick(force_mode) backend = backend or cfg.summary_backend
mode, thread = _pick("new" if force_mode == "react" else force_mode)
state = self_state.load() state = self_state.load()
react_item = None
time_line = f"RIGHT NOW: {clock.stamp()}." time_line = f"RIGHT NOW: {clock.stamp()}."
last_ref = state.get("last_reflection_at") last_ref = state.get("last_reflection_at")
@@ -437,7 +498,18 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n" f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n"
"Write the NEXT thought that advances it — don't restate the above." "Write the NEXT thought that advances it — don't restate the above."
) )
else: # new else: # new — pure interior, OR reacting to something from the world (her #1)
if cfg.feeds and (force_mode == "react" or random.random() < cfg.feed_react_prob):
react_item = feeds.next_item(refresh_first=False) # dream cycle refreshes
if react_item:
task = (
"YOU SAW THIS IN THE WORLD — an item from a feed you follow. Have a real "
"thought ABOUT it in your own voice: what it makes you think, whether you "
"agree or it bugs you, how it connects to you or to Brian or poker, or why "
"it doesn't land. Don't summarize it — react to it. Give the thread a short title.\n"
f"TITLE: {react_item['title']}\nSUMMARY: {react_item['summary']}\nLINK: {react_item['link']}"
)
else:
seed = self_state.wander_seed() seed = self_state.wander_seed()
open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE] open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE]
avoid = (" You already have threads on: " + "; ".join(open_titles) + avoid = (" You already have threads on: " + "; ".join(open_titles) +
@@ -473,11 +545,15 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
salience = _clamp(out.get("salience", 0.5)) salience = _clamp(out.get("salience", 0.5))
status = out.get("status") if out.get("status") in _STATUSES else "open" status = out.get("status") if out.get("status") in _STATUSES else "open"
label = "react" if react_item else mode # for logging/return; storage is still a new thread
if mode == "new": if mode == "new":
title = (out.get("title") or content[:48]).strip() title = (out.get("title") or (react_item["title"] if react_item else content[:48])).strip()
thread_id = new_thread(title, salience=salience, status="open") thread_id = new_thread(title, salience=salience, status="open")
if react_item:
feeds.mark_used(react_item["id"])
else: else:
thread_id = thread["id"] thread_id = thread["id"]
title = thread["title"]
add_thought(thread_id, kind, content, salience=salience, source=source) add_thought(thread_id, kind, content, salience=salience, source=source)
# On a fresh new thread we keep it open; otherwise honor her status call. A # On a fresh new thread we keep it open; otherwise honor her status call. A
@@ -488,17 +564,20 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
# Permanent record — these are really hers, alongside reflections/journal. # Permanent record — these are really hers, alongside reflections/journal.
memory.add_journal_entry("thought", content, source) memory.add_journal_entry("thought", content, source)
logbus.log("info", "thought loop", mode=mode, thread=thread_id, kind=kind, # Reach out if it tugs hard enough (config-gated; no-op when ntfy is unset).
maybe_ping(thread_id, title, content, salience)
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
salience=salience, status=status if mode != "new" else "open", salience=salience, status=status if mode != "new" else "open",
detail=f"[{mode}] thread {thread_id} ({kind}, sal {salience}):\n{content}") detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}")
return {"mode": mode, "thread_id": thread_id, "kind": kind, return {"mode": label, "thread_id": thread_id, "kind": kind,
"salience": salience, "status": status, "content": content} "salience": salience, "status": status, "content": content}
def main() -> int: def main() -> int:
import argparse import argparse
p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.") p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.")
p.add_argument("--mode", choices=["new", "continue", "respond"], help="force a mode") p.add_argument("--mode", choices=["new", "continue", "respond", "react"], help="force a mode")
args = p.parse_args() args = p.parse_args()
rep = think(force_mode=args.mode) rep = think(force_mode=args.mode)
print(json.dumps(rep, indent=2) if rep else "(no thought this pass)") print(json.dumps(rep, indent=2) if rep else "(no thought this pass)")
+1
View File
@@ -12,6 +12,7 @@ def lyra(tmp_path, monkeypatch):
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM.""" """A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("SUMMARY_BACKEND", "local") monkeypatch.setenv("SUMMARY_BACKEND", "local")
monkeypatch.setenv("LYRA_FEEDS", "") # dream cycle refreshes feeds; keep it offline
from lyra import llm from lyra import llm
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests. # Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
+72
View File
@@ -13,6 +13,7 @@ from lyra import clock
@pytest.fixture @pytest.fixture
def lyra(tmp_path, monkeypatch): def lyra(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) 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 from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts]) 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) importlib.reload(memory)
import lyra.self_state as self_state import lyra.self_state as self_state
importlib.reload(self_state) importlib.reload(self_state)
import lyra.feeds as feeds
importlib.reload(feeds)
import lyra.thoughts as thoughts import lyra.thoughts as thoughts
importlib.reload(thoughts) importlib.reload(thoughts)
# Canned LLM: tests set `box["next"]` to the dict think() should "generate". # Canned LLM: tests set `box["next"]` to the dict think() should "generate".
box = {"next": {}} box = {"next": {}}
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(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 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?" assert len(threads) == 1 and threads[0]["title"] == "am I continuous?"
chain = th.thread_thoughts(threads[0]["id"]) chain = th.thread_thoughts(threads[0]["id"])
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat" 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 == []