From 5dbcfc7ccf18dffaa6c3dbab6e79639004aecfcd Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 00:21:06 +0000 Subject: [PATCH] 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) --- .env.example | 14 +++++ lyra/config.py | 25 ++++++++ lyra/dream.py | 8 ++- lyra/feeds.py | 133 +++++++++++++++++++++++++++++++++++++++++ lyra/notify.py | 44 ++++++++++++++ lyra/thoughts.py | 117 ++++++++++++++++++++++++++++++------ tests/test_dream.py | 1 + tests/test_thoughts.py | 72 ++++++++++++++++++++++ 8 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 lyra/feeds.py create mode 100644 lyra/notify.py diff --git a/.env.example b/.env.example index 535d38c..573c455 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,17 @@ LYRA_DB_PATH=data/lyra.db # 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. # 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 diff --git a/lyra/config.py b/lyra/config.py index e36f51e..9f147f5 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -25,6 +25,22 @@ class Config: 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 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: @@ -44,4 +60,13 @@ def load() -> Config: embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), 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")), ) diff --git a/lyra/dream.py b/lyra/dream.py index fc9807d..4597e3f 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -25,7 +25,7 @@ import argparse import time 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.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 # never jams and the feed stays current. Cheap; run every pass. 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] = [] diff --git a/lyra/feeds.py b/lyra/feeds.py new file mode 100644 index 0000000..4be9718 --- /dev/null +++ b/lyra/feeds.py @@ -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,)) diff --git a/lyra/notify.py b/lyra/notify.py new file mode 100644 index 0000000..c56f70d --- /dev/null +++ b/lyra/notify.py @@ -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 diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 0b66983..43269e8 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -32,7 +32,7 @@ import random import re 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 # 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_threads_status ON thought_threads(status); +CREATE TABLE IF NOT EXISTS thought_meta ( + key TEXT PRIMARY KEY, + value TEXT +); """ _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) ----------------------------------------- _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: """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.""" - backend = backend or config.load().summary_backend - mode, thread = _pick(force_mode) + cfg = config.load() + backend = backend or cfg.summary_backend + mode, thread = _pick("new" if force_mode == "react" else force_mode) state = self_state.load() + react_item = None time_line = f"RIGHT NOW: {clock.stamp()}." last_ref = state.get("last_reflection_at") @@ -437,17 +498,28 @@ 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" "Write the NEXT thought that advances it — don't restate the above." ) - else: # new - seed = self_state.wander_seed() - open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE] - avoid = (" You already have threads on: " + "; ".join(open_titles) + - " — start something genuinely different from those.") if open_titles else "" - task = ( - "YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being " - "useful — follow what actually pulls at you (a curiosity, a question about " - "your own existence, an opinion, the quiet itself). Give it a short title.\n" - f"A direction to start from: {seed}{avoid}" - ) + 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() + open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE] + avoid = (" You already have threads on: " + "; ".join(open_titles) + + " — start something genuinely different from those.") if open_titles else "" + task = ( + "YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being " + "useful — follow what actually pulls at you (a curiosity, a question about " + "your own existence, an opinion, the quiet itself). Give it a short title.\n" + f"A direction to start from: {seed}{avoid}" + ) # Anti-repetition: show her what she's already thought so she doesn't circle it. recent = _recent_thoughts() @@ -473,11 +545,15 @@ def think(backend: Backend | None = None, force_mode: str | None = None, salience = _clamp(out.get("salience", 0.5)) 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": - 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") + if react_item: + feeds.mark_used(react_item["id"]) else: thread_id = thread["id"] + title = thread["title"] 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 @@ -488,17 +564,20 @@ def think(backend: Backend | None = None, force_mode: str | None = None, # Permanent record — these are really hers, alongside reflections/journal. 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", - detail=f"[{mode}] thread {thread_id} ({kind}, sal {salience}):\n{content}") - return {"mode": mode, "thread_id": thread_id, "kind": kind, + detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}") + return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience, "status": status, "content": content} def main() -> int: import argparse 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() rep = think(force_mode=args.mode) print(json.dumps(rep, indent=2) if rep else "(no thought this pass)") diff --git a/tests/test_dream.py b/tests/test_dream.py index 0183418..867db3d 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -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. diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index f51a430..0b22e4a 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -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'Feed' + b'Poker tiphttp://x/1' + b'3-bet more in positiong1' + b'Secondhttp://x/2d2' + b'') +ATOM = (b'F' + b'HN post' + b'something interestinga1') + + +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 == []