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:
+1
-1
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
+38
-10
@@ -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?")
|
||||||
assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced
|
r = th.think(force_mode="new")
|
||||||
assert th._meta_get("last_ping_at")
|
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()
|
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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user