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:
+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)")
|
||||
|
||||
Reference in New Issue
Block a user