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.
INTROSPECTION_BACKEND=
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"
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_salience: float # hard floor for any push (0 = her decision drives it)
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"
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.
feeds: tuple[str, ...]
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("/"),
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
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"),
digest_hour=int(os.getenv("DIGEST_HOUR", "18")),
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")),
)
+5
View File
@@ -87,6 +87,11 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
feeds.refresh()
except Exception as exc:
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] = []
+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)
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
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,
@@ -383,7 +384,7 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
cfg = config.load()
if not message or not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
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"))
if gap is not None and gap < cfg.ping_cooldown_min * 60:
return False
@@ -400,6 +401,62 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
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) -----------------------------------------
_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.
memory.add_journal_entry("thought", content, source)
# Reach out only if she *decided* to tell Brian — a real personal message, not
# the placeholder echoed back or her thought pasted in. (Config/quiet-gated.)
# Reach out two ways: (1) she *decided* to tell Brian (an explicit reach_out — a
# 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()
if reach_out.lower() in ("null", "none", "reach_out", "") or len(reach_out) < 8 \
or reach_out == content:
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,
salience=salience, status=status if mode != "new" else "open", pinged=pinged,
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,
"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
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_AUTO_SALIENCE", "1.1") # disable auto-ping to isolate reach_out path
sent = []
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)
@@ -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 == []
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):
_, th, _ = lyra
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
monkeypatch.setenv("PING_COOLDOWN_MIN", "0") # isolate the salience floor from cooldown
sent = []
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