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