From 149e9a6dd582eb69221b215b6d099098fb9e1ca4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 20:25:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20proactive=20thoughts=20=E2=80=94=20auto?= =?UTF-8?q?-ping=20salient=20ones=20+=20daily=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 3 ++ lyra/config.py | 10 ++++-- lyra/dream.py | 5 +++ lyra/thoughts.py | 78 ++++++++++++++++++++++++++++++++++++++---- tests/test_thoughts.py | 46 +++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index be15506..d4c4940 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/lyra/config.py b/lyra/config.py index 07e57d4..ffe464f 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -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")), ) diff --git a/lyra/dream.py b/lyra/dream.py index 3842031..bc578b4 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -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] = [] diff --git a/lyra/thoughts.py b/lyra/thoughts.py index d860ad7..f2d597e 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -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} diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index 08d60b8..1b8b2b0 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -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