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:
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
+7
-1
@@ -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] = []
|
||||
|
||||
|
||||
+133
@@ -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,))
|
||||
@@ -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
|
||||
+98
-19
@@ -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)")
|
||||
|
||||
@@ -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