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