feat: proactive thoughts — auto-ping salient ones + daily digest

She was passive (thoughts piled up 'open'; Brian had to mine the feed). Now she
brings them to him:

- Live: a thought >= PING_AUTO_SALIENCE (0.8) auto-pings — _compose_reachout writes
  a short personal text in her voice (not a thought-dump), on a cooldown
  (PING_COOLDOWN_MIN=60, AUTO only; explicit reach-outs bypass), quiet hours respected.
- Daily: maybe_daily_digest() texts a once-per-local-day summary of what she's been
  turning over (after DIGEST_HOUR=18), run from the dream cycle.
- maybe_ping gains bypass_cooldown (her deliberate reach-outs always go through).

8 new/updated tests (auto-ping above/below bar, digest once-per-day, floor/cooldown
isolation). Suite 80 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:25:14 +00:00
parent cf4238911e
commit 149e9a6dd5
5 changed files with 133 additions and 9 deletions
+3
View File
@@ -45,3 +45,6 @@ FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
# Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model. # Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model.
INTROSPECTION_BACKEND= INTROSPECTION_BACKEND=
INTROSPECTION_MODEL= INTROSPECTION_MODEL=
PING_AUTO_SALIENCE=0.8 # a thought this salient auto-pings even without an explicit reach-out
PING_COOLDOWN_MIN=60 # min minutes between AUTO pings (explicit reach-outs bypass)
DIGEST_HOUR=18 # local hour to send her daily "what I've been thinking" digest
+7 -3
View File
@@ -32,9 +32,11 @@ class Config:
ntfy_topic: str # topic to publish to, e.g. "lyra" 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 web_url: str # base url of the Lyra web app, for push tap-through links
timezone: str # IANA tz for quiet hours / local time timezone: str # IANA tz for quiet hours / local time
ping_salience: float # min thought salience to push (eager = ~0.7) ping_salience: float # hard floor for any push (0 = her decision drives it)
ping_cooldown_min: int # min minutes between pushes (eager = 0) ping_auto_salience: float # a thought this salient auto-pings even without an explicit reach-out
ping_cooldown_min: int # min minutes between AUTO pushes (explicit reach-outs bypass it)
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9" ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
digest_hour: int # local hour (0-23) to send her daily "what I've been thinking" digest
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs. # External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
feeds: tuple[str, ...] feeds: tuple[str, ...]
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
@@ -73,8 +75,10 @@ def load() -> Config:
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"), web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"), timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
ping_salience=float(os.getenv("PING_SALIENCE", "0.0")), # her decision drives pinging; optional floor ping_salience=float(os.getenv("PING_SALIENCE", "0.0")), # her decision drives pinging; optional floor
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")), ping_auto_salience=float(os.getenv("PING_AUTO_SALIENCE", "0.8")),
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "60")),
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"), ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
digest_hour=int(os.getenv("DIGEST_HOUR", "18")),
feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"), 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")), feed_react_prob=float(os.getenv("FEED_REACT_PROB", "0.5")),
) )
+5
View File
@@ -87,6 +87,11 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
feeds.refresh() feeds.refresh()
except Exception as exc: except Exception as exc:
logbus.log("error", "feed refresh failed", error=str(exc)[:160]) logbus.log("error", "feed refresh failed", error=str(exc)[:160])
# Her daily "what I've been turning over" digest (sends at most once/local-day).
try:
thoughts.maybe_daily_digest()
except Exception as exc:
logbus.log("error", "daily digest failed", error=str(exc)[:160])
actions: list[str] = [] actions: list[str] = []
+72 -6
View File
@@ -371,7 +371,8 @@ def _in_quiet_hours(cfg) -> bool:
return start <= hour < end if start < end else (hour >= start or hour < end) return start <= hour < end if start < end else (hour >= start or hour < end)
def maybe_ping(thread_id: int, message: str, salience: float) -> bool: def maybe_ping(thread_id: int, message: str, salience: float,
bypass_cooldown: bool = False) -> bool:
"""Text Brian her own message (`message`) when she's chosen to reach out and """Text Brian her own message (`message`) when she's chosen to reach out and
we're allowed (ntfy configured, outside quiet hours, past cooldown, and above we're allowed (ntfy configured, outside quiet hours, past cooldown, and above
the optional PING_SALIENCE floor — 0 by default, so her decision drives it, the optional PING_SALIENCE floor — 0 by default, so her decision drives it,
@@ -383,7 +384,7 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
cfg = config.load() cfg = config.load()
if not message or not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg): if not message or not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
return False return False
if cfg.ping_cooldown_min > 0: if not bypass_cooldown and cfg.ping_cooldown_min > 0:
gap = clock.gap_seconds(_meta_get("last_ping_at")) gap = clock.gap_seconds(_meta_get("last_ping_at"))
if gap is not None and gap < cfg.ping_cooldown_min * 60: if gap is not None and gap < cfg.ping_cooldown_min * 60:
return False return False
@@ -400,6 +401,62 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
return ok return ok
_REACHOUT_PROMPT = """Turn this private thought of yours into a short, warm text message \
TO Brian — first person, the way you'd text a friend ("Hey, I've been thinking about…"), \
1-2 sentences, inviting him to take a look if he wants. Reply with ONLY the message text — \
no quotes, no preamble, not the thought restated verbatim."""
def _compose_reachout(title: str, content: str, backend, model) -> str:
"""Auto-write her a short personal text about a genuinely salient thought she didn't
explicitly flag — so the good ones reach Brian, in her voice, not as a thought-dump."""
try:
out = llm.complete(
[{"role": "system", "content": _REACHOUT_PROMPT},
{"role": "user", "content": f'Thought "{title}": {content}'}],
backend=backend, model=model,
).strip().strip('"').strip()
except Exception:
out = ""
if not out or len(out) < 8:
out = f'Been turning something over — "{title}". Come see it if you want.'
return out[:300]
def maybe_daily_digest() -> bool:
"""Once a day (after digest_hour, local), text Brian a short summary of what she's
been turning over — so he gets a low-pressure 'here's my day' even if nothing
crossed the live-ping bar. Sends at most once per local day."""
cfg = config.load()
if not cfg.ntfy_url:
return False
try:
from zoneinfo import ZoneInfo
now_local = clock.now().astimezone(ZoneInfo(cfg.timezone))
except Exception:
now_local = clock.now()
if now_local.hour < cfg.digest_hour or _in_quiet_hours(cfg):
return False
today = now_local.date().isoformat()
if _meta_get("last_digest_date") == today:
return False
active = [t for t in list_threads(limit=40) if t["status"] in _ACTIVE]
active.sort(key=lambda t: t["updated_at"], reverse=True)
active = active[:4]
if not active:
return False
titles = "; ".join(f'"{t["title"]}"' for t in active)
msg = (f"A few things I've been turning over today: {titles}. "
"I'm in my thoughts if you want to dig in.")
ok = notify.push(title="Lyra · today's thoughts", message=msg,
click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
tags="thought_balloon")
if ok:
_meta_set("last_digest_date", today)
logbus.log("info", "daily digest sent", threads=len(active))
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 — \
@@ -584,18 +641,27 @@ 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)
# Reach out only if she *decided* to tell Brian — a real personal message, not # Reach out two ways: (1) she *decided* to tell Brian (an explicit reach_out — a
# the placeholder echoed back or her thought pasted in. (Config/quiet-gated.) # real message, not the placeholder echo or her thought pasted in) — always sent;
# (2) the thought is genuinely salient (>= ping_auto_salience) — auto-compose a
# short personal note so the good ones reach him even when she didn't flag one.
reach_out = (out.get("reach_out") or "").strip() reach_out = (out.get("reach_out") or "").strip()
if reach_out.lower() in ("null", "none", "reach_out", "") or len(reach_out) < 8 \ if reach_out.lower() in ("null", "none", "reach_out", "") or len(reach_out) < 8 \
or reach_out == content: or reach_out == content:
reach_out = "" reach_out = ""
pinged = bool(reach_out) and maybe_ping(thread_id, reach_out, salience) if reach_out:
message, explicit = reach_out, True
elif salience >= cfg.ping_auto_salience:
message, explicit = _compose_reachout(title, content, backend, model), False
else:
message, explicit = "", False
pinged = bool(message) and maybe_ping(thread_id, message, salience, bypass_cooldown=explicit)
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind, logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
salience=salience, status=status if mode != "new" else "open", pinged=pinged, salience=salience, status=status if mode != "new" else "open", pinged=pinged,
detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}" detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}"
+ (f"\n\nreached out: {reach_out}" if reach_out else "")) + (f"\n\nreached out{' (auto)' if pinged and not explicit else ''}: {message}"
if pinged else ""))
return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience, return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience,
"status": status, "content": content, "reach_out": reach_out, "pinged": pinged} "status": status, "content": content, "reach_out": reach_out, "pinged": pinged}
+46
View File
@@ -250,6 +250,7 @@ def test_no_ping_without_a_reach_out_message(lyra, monkeypatch):
_, th, box = lyra _, th, box = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test") monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0") monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_AUTO_SALIENCE", "1.1") # disable auto-ping to isolate reach_out path
sent = [] sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1]) monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
# salient thought but she did NOT decide to tell him -> no ping (it's not a broadcast) # salient thought but she did NOT decide to tell him -> no ping (it's not a broadcast)
@@ -260,10 +261,55 @@ def test_no_ping_without_a_reach_out_message(lyra, monkeypatch):
assert th.think(force_mode="new")["pinged"] is False and sent == [] assert th.think(force_mode="new")["pinged"] is False and sent == []
def test_auto_ping_on_salient_thought(lyra, monkeypatch):
_, th, box = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_AUTO_SALIENCE", "0.7")
monkeypatch.setenv("PING_COOLDOWN_MIN", "0")
sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
monkeypatch.setattr(th, "_compose_reachout", lambda *a, **k: "Hey, been thinking about this.")
_gen(box, content="a genuinely salient thought", salience=0.9) # no explicit reach_out
r = th.think(force_mode="new")
assert r["pinged"] is True and sent and "thinking about" in sent[0]["message"]
def test_no_auto_ping_below_bar(lyra, monkeypatch):
_, th, box = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_AUTO_SALIENCE", "0.8")
sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
_gen(box, content="a quieter musing", salience=0.5) # below auto bar, no reach_out
assert th.think(force_mode="new")["pinged"] is False and sent == []
def test_daily_digest_sends_once_per_day(lyra, monkeypatch):
_, th, box = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("DIGEST_HOUR", "0") # any time qualifies
monkeypatch.setenv("PING_AUTO_SALIENCE", "1.1") # keep think() from pinging during setup
sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
_gen(box, title="thread A", content="a", salience=0.5)
th.think(force_mode="new")
_gen(box, title="thread B", content="b", salience=0.5)
th.think(force_mode="new")
assert th.maybe_daily_digest() is True
assert sent and "thread" in sent[-1]["message"].lower()
sent.clear()
assert th.maybe_daily_digest() is False # already sent today
assert sent == []
def test_ping_salience_floor_is_optional(lyra, monkeypatch): def test_ping_salience_floor_is_optional(lyra, monkeypatch):
_, th, _ = lyra _, th, _ = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test") monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0") monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_COOLDOWN_MIN", "0") # isolate the salience floor from cooldown
sent = [] sent = []
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1]) monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
# default floor 0.0 -> her decision (a message) is enough, any salience pings # default floor 0.0 -> her decision (a message) is enough, any salience pings