From 43697f8340b7e344a8f97c810cabaecfd388a85c Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 01:39:11 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20ntfy=20ping=20is=20her=20personal=20text?= =?UTF-8?q?=20to=20Brian,=20by=20her=20decision=20=E2=80=94=20not=20a=20th?= =?UTF-8?q?ought=20dump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/config.py | 2 +- lyra/notify.py | 4 ++- lyra/thoughts.py | 56 +++++++++++++++++++++++++++++------------- tests/test_thoughts.py | 48 ++++++++++++++++++++++++++++-------- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/lyra/config.py b/lyra/config.py index 9f147f5..dc47237 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -64,7 +64,7 @@ def load() -> Config: ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"), web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"), 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_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"), feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"), diff --git a/lyra/notify.py b/lyra/notify.py index c56f70d..3252b64 100644 --- a/lyra/notify.py +++ b/lyra/notify.py @@ -26,7 +26,9 @@ def push(title: str, message: str, click: str | None = None, cfg = config.load() if not cfg.ntfy_url: 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: payload["click"] = click if tags: diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 43269e8..ad47802 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -370,23 +370,27 @@ 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, title: str, content: str, salience: float) -> bool: - """Push a thought to Brian's phone if it tugs hard enough and we're allowed - (ntfy configured, past the salience bar, outside quiet hours, past cooldown). - On success, record the ping and mark the thread surfaced (so chat won't also - re-raise the same one). All thresholds are config-tunable.""" +def maybe_ping(thread_id: int, message: str, salience: float) -> 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, + 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() - 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 if 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 ok = notify.push( - title=f'Lyra · "{title}"', - message=content, + title="Lyra", + message=message, click=(cfg.web_url + "/thoughts") if cfg.web_url else None, - tags="thought_balloon", + tags="speech_balloon", ) if ok: _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 \ 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: { "title": "", "kind": "observation|question|idea|follow-up|closing", "content": "", "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]: @@ -564,14 +580,20 @@ 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 if it tugs hard enough (config-gated; no-op when ntfy is unset). - maybe_ping(thread_id, title, content, salience) + # 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 = (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, - salience=salience, status=status if mode != "new" else "open", - detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}") - return {"mode": label, "thread_id": thread_id, "kind": kind, - "salience": salience, "status": status, "content": content} + 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 "")) + return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience, + "status": status, "content": content, "reach_out": reach_out, "pinged": pinged} def main() -> int: diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index 0b22e4a..804c50e 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -226,28 +226,56 @@ def test_react_mode_makes_a_thread_about_a_feed_item(lyra, monkeypatch): # --- 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 monkeypatch.setenv("NTFY_URL", "http://ntfy.test") monkeypatch.setenv("PING_QUIET_HOURS", "0-0") # disable quiet window for the test sent = [] monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1]) - _gen(box, title="big one", content="this really tugs", salience=0.9) - r = th.think(force_mode="new") # high salience -> should ping - assert len(sent) == 1 and "big one" in sent[0]["title"] - assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced - assert th._meta_get("last_ping_at") + # high salience AND she wrote a personal note to Brian -> texts him that note + _gen(box, title="big one", content="internal thought, essay voice", salience=0.9, + 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 + +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() - assert th.maybe_ping(r["thread_id"], "x", "quiet musing", 0.4) is False # below bar - assert sent == [] + monkeypatch.setenv("PING_SALIENCE", "0.7") + 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): _, th, _ = lyra sent = [] monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1]) - # no NTFY_URL in env -> disabled regardless of salience - assert th.maybe_ping(1, "t", "c", 0.99) is False + # no NTFY_URL in env -> disabled even with a message + high salience + assert th.maybe_ping(1, "hey there", 0.99) is False assert sent == []