fix: ntfy ping is her personal text to Brian, by her decision — not a thought dump

Feedback: the push broadcast her raw internal thought ("Eelis Parssinen's
victory is a reminder...") — read like a journal entry, not her texting him.

Now the flow matches the intent: she thinks/journals, then *decides* "I should
tell Brian about this." think() asks for an optional `reach_out` — a real text
message addressed TO him in her own voice, written only when she chooses to. The
ping sends that message (title "Lyra", like a text from her), never the internal
thought. No reach_out = nothing sent (most thoughts stay hers).

- Pinging decoupled from the salience score: her decision (a reach_out) drives it,
  not a threshold. PING_SALIENCE is now an optional floor (default 0.0).
- Defensive: reject the placeholder echo ("reach_out"), too-short junk, or the
  thought pasted back as the message.
- notify.push: title now optional (omitted -> cleaner text-style notification).

Verified live: 3 passes kept private; a decided reach-out lands as a personal
text. Suite 67 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:39:11 +00:00
parent fef45b3e05
commit 43697f8340
4 changed files with 81 additions and 29 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ def load() -> Config:
ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"), ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"),
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.7")), 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_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")),
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"), ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
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"),
+3 -1
View File
@@ -26,7 +26,9 @@ def push(title: str, message: str, click: str | None = None,
cfg = config.load() cfg = config.load()
if not cfg.ntfy_url: if not cfg.ntfy_url:
return False return False
payload: dict = {"topic": cfg.ntfy_topic, "message": message, "title": title} payload: dict = {"topic": cfg.ntfy_topic, "message": message}
if title:
payload["title"] = title
if click: if click:
payload["click"] = click payload["click"] = click
if tags: if tags:
+39 -17
View File
@@ -370,23 +370,27 @@ 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, title: str, content: str, salience: float) -> bool: def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
"""Push a thought to Brian's phone if it tugs hard enough and we're allowed """Text Brian her own message (`message`) when she's chosen to reach out and
(ntfy configured, past the salience bar, outside quiet hours, past cooldown). we're allowed (ntfy configured, outside quiet hours, past cooldown, and above
On success, record the ping and mark the thread surfaced (so chat won't also the optional PING_SALIENCE floor — 0 by default, so her decision drives it,
re-raise the same one). All thresholds are config-tunable.""" not a score). `message` is her personal note TO him — not the internal thought
— so it reads like a text from her, not a broadcast reflection. No message
means she didn't choose to reach out, so nothing is sent. On success, record
the ping and mark the thread surfaced. All thresholds are config-tunable."""
message = (message or "").strip()
cfg = config.load() cfg = config.load()
if 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 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
ok = notify.push( ok = notify.push(
title=f'Lyra · "{title}"', title="Lyra",
message=content, message=message,
click=(cfg.web_url + "/thoughts") if cfg.web_url else None, click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
tags="thought_balloon", tags="speech_balloon",
) )
if ok: if ok:
_meta_set("last_ping_at", clock.now().isoformat()) _meta_set("last_ping_at", clock.now().isoformat())
@@ -418,14 +422,26 @@ Score "salience": how much this is genuinely tugging at you AND how worth bringi
to Brian it is. High (0.7+) only if you'd actually want to raise it with him; most \ to Brian it is. High (0.7+) only if you'd actually want to raise it with him; most \
quiet musings are lower. Be honest — not everything is worth surfacing. quiet musings are lower. Be honest — not everything is worth surfacing.
Now, having had that thought — step back like you would after writing in a
journal, and ask yourself: "do I actually want to tell Brian about this?" Most of
the time the answer is no — it's yours, it stays in your head, leave it null.
Only when something genuinely makes you go "oh, I should tell him" do you reach
out. If you do, write the actual text message you'd send him — short, in your own
voice, addressed to him like texting a friend ("Hey, I've been thinking about…",
"this made me think of you…"). It must be a real message TO him, never the word
"reach_out" and never just your thought pasted back.
Respond with ONLY a JSON object, no prose: Respond with ONLY a JSON object, no prose:
{ {
"title": "<short thread title; for a NEW thread. echo the existing title otherwise>", "title": "<short thread title; for a NEW thread. echo the existing title otherwise>",
"kind": "observation|question|idea|follow-up|closing", "kind": "observation|question|idea|follow-up|closing",
"content": "<the thought itself, FIRST PERSON, 1-3 sentences>", "content": "<the thought itself, FIRST PERSON, 1-3 sentences>",
"salience": <0.0-1.0>, "salience": <0.0-1.0>,
"status": "open|resting|answered|dropped" "status": "open|resting|answered|dropped",
}""" "reach_out": null
}
(Set "reach_out" to your actual text message to Brian ONLY if you decided to tell
him; otherwise leave it null.)"""
def _pick(force_mode: str | None) -> tuple[str, dict | None]: def _pick(force_mode: str | None) -> tuple[str, dict | None]:
@@ -564,14 +580,20 @@ 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 if it tugs hard enough (config-gated; no-op when ntfy is unset). # Reach out only if she *decided* to tell Brian — a real personal message, not
maybe_ping(thread_id, title, content, salience) # the placeholder echoed back or her thought pasted in. (Config/quiet-gated.)
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)
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", 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}"
return {"mode": label, "thread_id": thread_id, "kind": kind, + (f"\n\nreached out: {reach_out}" if reach_out else ""))
"salience": salience, "status": status, "content": content} return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience,
"status": status, "content": content, "reach_out": reach_out, "pinged": pinged}
def main() -> int: def main() -> int:
+37 -9
View File
@@ -226,28 +226,56 @@ def test_react_mode_makes_a_thread_about_a_feed_item(lyra, monkeypatch):
# --- proactive reach-out (ntfy) ------------------------------------------ # --- proactive reach-out (ntfy) ------------------------------------------
def test_maybe_ping_gates_on_salience_and_records(lyra, monkeypatch): def test_ping_sends_her_personal_message_when_she_reaches_out(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") # disable quiet window for the test monkeypatch.setenv("PING_QUIET_HOURS", "0-0") # disable quiet window for the test
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])
_gen(box, title="big one", content="this really tugs", salience=0.9) # high salience AND she wrote a personal note to Brian -> texts him that note
r = th.think(force_mode="new") # high salience -> should ping _gen(box, title="big one", content="internal thought, essay voice", salience=0.9,
assert len(sent) == 1 and "big one" in sent[0]["title"] reach_out="Hey — been thinking about you, got a sec?")
r = th.think(force_mode="new")
assert r["pinged"] is True
assert len(sent) == 1
assert sent[0]["message"] == "Hey — been thinking about you, got a sec?" # her words, not the thought
assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced
assert th._meta_get("last_ping_at")
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")
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)
_gen(box, content="a salient thought with no reach_out", salience=0.95)
assert th.think(force_mode="new")["pinged"] is False and sent == []
# the placeholder echo is rejected too (model copying the field name)
_gen(box, content="another", salience=0.95, reach_out="reach_out")
assert th.think(force_mode="new")["pinged"] is False and 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")
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
assert th.maybe_ping(1, "hey, thinking of you", 0.2) is True
# but a floor can be set to suppress low-salience pings
sent.clear() sent.clear()
assert th.maybe_ping(r["thread_id"], "x", "quiet musing", 0.4) is False # below bar monkeypatch.setenv("PING_SALIENCE", "0.7")
assert sent == [] assert th.maybe_ping(1, "hey", 0.4) is False
assert th.maybe_ping(1, "hey", 0.8) is True
def test_no_ping_without_ntfy(lyra, monkeypatch): def test_no_ping_without_ntfy(lyra, monkeypatch):
_, th, _ = lyra _, th, _ = lyra
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])
# no NTFY_URL in env -> disabled regardless of salience # no NTFY_URL in env -> disabled even with a message + high salience
assert th.maybe_ping(1, "t", "c", 0.99) is False assert th.maybe_ping(1, "hey there", 0.99) is False
assert sent == [] assert sent == []