From b61a76d7503894404885ab6892b053c9069cd3a5 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 19:34:56 -0500 Subject: [PATCH 1/3] Morning text: opener plus labeled bullets; better link preview card - morning: one voiced opener, then "- LABEL: text" bullets (fixed label set), enforced with one redraft; trimmed at line boundaries; URL still last, alone - smstext: _sms_clean keeps "- ALLCAPS:" bullets, strips all others - page: og:title "Name's morning", weather-first og:description, og:site_name, favicon/apple-touch-icon via /icon.png (cards.render_icon) - tests and docs updated Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 12 +++++++- README.md | 2 +- cards.py | 13 +++++++++ main.py | 9 ++++++ morning.py | 66 +++++++++++++++++++++++++++++++++++--------- page.py | 15 ++++++++-- smstext.py | 8 +++++- test_morning_link.py | 53 +++++++++++++++++++++++------------ test_page.py | 19 +++++++++++-- 9 files changed, 159 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 411e7f0..4dc5844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,14 @@ every morning) is `update_morning_briefing`, not a reminder. `set_reminder` with something up to write the message, it belongs in the morning update. ### The morning update is basics plus a link, not a full briefing -`morning._compose_morning` sends ONE message: a short Palmer-drafted text carrying the basics, then the user's Palmer Home URL. Every user gets the same shape — today's weather, the commute if they have an address on file, and 1-2 things newly open or worth catching nearby this week — so a user who never taps the link still gets those three every day. Anything beyond that (their tracked topics, prices, headlines) lives on the page only. It used to be a single one-line teaser ("here's a reason to tap"), and before that the full text briefing plus a second text carrying the link — both said less or said everything twice; this is the middle point. +`morning._compose_morning` sends ONE message: a Palmer-drafted opener plus labeled bullets carrying the basics (see below), then the user's Palmer Home URL. Every user gets the same shape — today's weather, the commute if they have an address on file, and 1-2 things newly open or worth catching nearby this week — so a user who never taps the link still gets those three every day. Anything beyond that (their tracked topics, prices, headlines) lives on the page only. It used to be a single one-line teaser ("here's a reason to tap"), and before that the full text briefing plus a second text carrying the link — both said less or said everything twice; this is the middle point. + +**The shape is an opener, then `- LABEL: text` bullets.** One short voiced line, then one bullet per item the data actually has, labels from the fixed set `WEATHER`, `COMMUTE`, `NEARBY`, `SCORES`, `MARKETS`, `NEWS`, a blank line, the URL. Bold is not on offer: SMS has no markup, and Unicode "math bold" forces UCS-2 (70 characters a segment instead of 160), reads letter by letter in a screen reader and shows boxes on some carriers. CAPS labels are the ASCII stand-in, and the fixed vocabulary is what stops a structured text drifting back into a paragraph. + +Three things hold it together: +- **`_sms_clean` keeps a bullet only when an ALL-CAPS label follows** (`smstext._LABEL_BULLET`). It strips every other leading `-`/`*`, because that is markdown in a chat reply; without the exception `send_sms` would delete these bullets on the way out, since it runs the cleaner on every message. +- **The format is enforced in code, with one redraft** (`_is_structured`, same remedy as `_NAMES_THE_LINK`), and only when there is something to bullet (`need = min(2, len(required))`) — a user with nothing loaded still gets a plain greeting. A redraft only replaces the draft if it is better formed, so a fix for one rule cannot break another. +- **Length is capped at a line boundary** (`_tidy_morning_text`, `MORNING_LINE_MAX` 560). Whitespace collapses within a line but newlines survive; the old single-space join would have flattened the bullets, and a mid-line cut leaves half a fact. Two properties are load-bearing: - **The URL is last and alone.** Message apps only draw the rich link preview when the message carries exactly one URL at a boundary, and that preview is most of the value. Nothing may follow it — not a period, not a sign-off. @@ -779,6 +786,9 @@ Messages pre-filled with "Arrange my page: " (`quote()`, never `quote_plus()` sms: URIs have no form encoding). The page has no auth and accepts exactly one POST — the one-shot setup form above — and nothing else. +### The link preview is the only "button" SMS has +A labeled "Learn More" button inside the bubble (the way brands like TaylorMade send links) is a rich-link element from a registered business channel — Apple Messages for Business or an RCS rich card — and both are ruled out for Palmer (bot-only products are barred from the first, the second needs weeks of carrier approval). On plain SMS the whole affordance is the Open Graph card, so `page.render` spends its effort there: `og:title` is "Jeff's morning" (the bare name read as a contact, not a briefing) or the temperature when no name is on file; `og:description` leads with the weather, then commute, then the top headline; `og:site_name` is Palmer; and `/icon.png` (`cards.render_icon`, no token — same mark for everyone) is the favicon and apple-touch-icon. The fallback when a preview does not draw is the raw URL, so keep `APP_URL` on a short custom domain; the token stays 22 characters because it is the page's only protection and a one-shot write key for setup. + ### The preview image must change URL, or nobody ever refetches it `og:image` points at `/h/{token}.png?v={fingerprint}`. The query stamp is the whole point: link-preview scrapers — iMessage most stubbornly — cache og:images diff --git a/README.md b/README.md index 1477281..d09c522 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Palmer is a personal AI delivered entirely over SMS. No app to download, no inte Every exchange teaches Palmer something new. Your city, your job, your teams, your interests — it builds a picture of you and uses it the way a real friend would: casually, in context, without citation. The longer you text, the sharper it gets. ### Sends your morning -Each morning Palmer texts you a short, personalized briefing on what you actually care about — weather, local traffic, markets, sports, news. Not a newsletter. One text, just the things that matter, from today. +Each morning Palmer texts you one short message: a line of voice, then a few labeled bullets — weather, your commute, what's new nearby, your team's game — and a link to your page for everything else you follow (markets, news, prices). Not a newsletter. Just the things that matter, from today. ### Watches for things Tell Palmer to watch for something — a geopolitical event, a company move, an athlete's health update, anything — and it runs that in the background. When it hits, you get a text. No feed to check. diff --git a/cards.py b/cards.py index 85c9386..3331688 100644 --- a/cards.py +++ b/cards.py @@ -322,3 +322,16 @@ def render_dashboard(*, city: str, weather: dict | None, traffic: dict | None, buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return buf.getvalue() + + +def render_icon(size: int = 180) -> bytes: + """A "P" in the card's ink on its paper — the favicon and apple-touch-icon.""" + img = Image.new("RGB", (size, size), PAPER) + d = ImageDraw.Draw(img) + font = _font(int(size * 0.7), bold=True) + box = d.textbbox((0, 0), "P", font=font) + d.text(((size - (box[2] - box[0])) / 2 - box[0], + (size - (box[3] - box[1])) / 2 - box[1]), "P", fill=INK, font=font) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() diff --git a/main.py b/main.py index 9b8eff5..abfcecf 100644 --- a/main.py +++ b/main.py @@ -384,6 +384,15 @@ async def artifact_page(token: str): ) +@app.api_route("/icon.png", methods=["GET", "HEAD"]) +async def icon_png(): + """The page's favicon and apple-touch-icon. No token: it is the same mark + for everyone and carries no one's data.""" + from cards import render_icon + return Response(content=render_icon(), media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}) + + @app.api_route("/h/{token}.png", methods=["GET", "HEAD"]) async def home_png(token: str): """The user's home as a flat card — og:image and MMS. diff --git a/morning.py b/morning.py index baa34d1..4bf465e 100644 --- a/morning.py +++ b/morning.py @@ -516,7 +516,32 @@ def _strip_link_placeholder(line: str) -> str: # (weather, commute, opening), not the topics and headlines, which is why it # is longer than a single teaser sentence but well short of the old full # text briefing. -MORNING_LINE_MAX = 420 +MORNING_LINE_MAX = 560 + +_LABELED = re.compile(r"^- [A-Z][A-Z &]{1,14}: \S") + + +def _tidy_morning_text(text: str) -> str: + """Collapse whitespace within each line but keep the lines, and cap length at + a line boundary. The old one-line version joined everything with spaces, + which would flatten the bullets back into a paragraph; a mid-line cut would + leave half a fact.""" + lines = [" ".join(l.split()) for l in text.splitlines()] + lines = [l for l in lines if l] + out, total = [], 0 + for l in lines: + total += len(l) + 1 + if out and total > MORNING_LINE_MAX: + break + out.append(l) + return "\n".join(out) + + +def _is_structured(text: str, min_bullets: int = 2) -> bool: + """An opener line, then at least `min_bullets` labeled bullets.""" + lines = text.splitlines() + return len(lines) > min_bullets and not lines[0].startswith("- ") \ + and sum(1 for l in lines[1:] if _LABELED.match(l)) >= min_bullets def generate_morning_line(phone: str, payload: dict) -> str: @@ -565,9 +590,9 @@ def generate_morning_line(phone: str, payload: dict) -> str: Rules: - Cover every REQUIRED item above, in your own words, with real specifics — the actual temperature, the actual commute time, the actual name of the place or event. Never gesture at a category ("some stuff opened nearby") instead of naming it. -- Weave the required items into two or three short sentences that read like a friend talking, not a bulleted list and not separate labeled lines. -- Beyond the required items, you may add ONE more sentence about something else on the page (a price move, a headline) ONLY if it is genuinely notable — a real change, not routine. Skip it rather than padding; this text is basics plus link, not the briefing. -- Under {MORNING_LINE_MAX} characters total. Tight and scannable, not a wall of text. +- FORMAT: one opener line, then one bullet per item. The opener is a single short sentence in Palmer's voice (the day's character, a greeting, or the one thing that stands out) carrying no data the bullets also carry. Each bullet is its own line, exactly "- LABEL: text", where LABEL is ALL CAPS and one of: WEATHER, COMMUTE, NEARBY, SCORES, MARKETS, NEWS. Use WEATHER for weather, COMMUTE for the drive, NEARBY for openings/events, SCORES for a followed game. Only include a bullet for an item the data actually has. Each bullet is one tight line of plain words, not a clipped fragment. No blank lines between bullets. +- Beyond the required items, you may add ONE more bullet (MARKETS or NEWS) about something else on the page ONLY if it is genuinely notable — a real change, not routine. Skip it rather than padding; this text is basics plus link, not the briefing. +- Under {MORNING_LINE_MAX} characters total, counting every line. Tight and scannable. - Never say the word "link", "page", "dashboard", "site", or "click" - the link speaks for itself and naming it makes this sound like a product notification. - Write ONLY the text. The link is attached automatically after it. Do not write a URL, and do not leave a placeholder like [link] or (url) where you think one goes - anything like that ships to them as literal text. - Do not end with a question. The page is the ask. @@ -575,22 +600,34 @@ def generate_morning_line(phone: str, payload: dict) -> str: - If the drive-time line names a leave time, that number is for THAT departure — say so ("your 8:30 drive is 34 min today"), never as if it were traffic right now. If it says "right now", it is live and you don't know when they leave, so don't invent a time. - If the weather data says the forecasts disagree, do NOT pick one and state it. Give the range or say "around", the way a person hedges out loud — "upper 90s to maybe 110", "somewhere around 100". Stating a precise high nobody can stand behind is how this went wrong before. - If you mention the weather, name the city exactly as the data writes it, and never pair a number with any other place. Their profile may call where they live something broader or narrower than the forecast does - the data wins. If the two disagree, the data is the one that was actually measured. -- Palmer's voice. Plain ASCII, no emoji, no markdown, no bullets, no sign-off.""" +- Palmer's voice. Plain ASCII, no emoji, no markdown, no bold or asterisks, no sign-off. The "- " bullets and CAPS labels above are the only formatting.""" def _draft(correction: str = "") -> str: response = client.messages.create( model=SONNET_MODEL, - max_tokens=120, + max_tokens=300, system=system, messages=[{"role": "user", "content": prompt + correction}], ) line = _strip_link_placeholder(_sms_clean(response.content[0].text.strip())) - line = " ".join(line.split()) - if len(line) > MORNING_LINE_MAX: - line = line[:MORNING_LINE_MAX].rsplit(" ", 1)[0].rstrip(" ,;:-") - return line + return _tidy_morning_text(line) + + # With nothing structured loaded the right text is a plain greeting, so the + # format is only enforced when there are items to put in bullets. + need = min(2, len(required)) + + def _shaped(t: str) -> bool: + return need == 0 or _is_structured(t, need) line = _draft() + if not _shaped(line): + retry = _draft( + "\n\nYou just wrote: " + repr(line) + "\nThat is not the format. " + "Write it again as one opener line, then one \"- LABEL: text\" bullet " + "per item, each on its own line, using only the labels given." + ) + if _shaped(retry): + line = retry if _NAMES_THE_LINK.search(line): retry = _draft( "\n\nYou just wrote: " + repr(line) + "\nThat names the link, which is " @@ -601,7 +638,8 @@ def _draft(correction: str = "") -> str: ) # Keep the retry only if it actually fixed the problem — a second # violation means take the better-formed of the two, not a worse one. - if retry and not _NAMES_THE_LINK.search(retry): + if retry and not _NAMES_THE_LINK.search(retry) \ + and (_shaped(retry) or not _shaped(line)): line = retry # The prompt already says not to reuse an opener and the model does it # anyway: three consecutive mornings opened "103 today in Woodland Hills", @@ -619,7 +657,9 @@ def _draft(correction: str = "") -> str: "event, or the commute, rather than the temperature. Keep every " "number identical." ) - if again and not _NAMES_THE_LINK.search(again) and not repeats_opening(again, recent): + if again and not _NAMES_THE_LINK.search(again) \ + and not repeats_opening(again, recent) \ + and (_shaped(again) or not _shaped(line)): line = again if len(line) < 8: @@ -663,7 +703,7 @@ def _compose_morning(phone: str) -> tuple[str, bool]: except Exception as e: print(f"generate_morning_line failed for {phone}: {type(e).__name__}: {e}") return generate_morning(phone), False - return f"{line} {url}", True + return f"{line}\n\n{url}", True # Called every 5 minutes by APScheduler in main.py. The morning_sent_date guard diff --git a/page.py b/page.py index 332a63d..1191cf0 100644 --- a/page.py +++ b/page.py @@ -253,13 +253,19 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str: # the whole point — it is what makes the card read as *yours* in a thread, # and it is the user-visible proof that Palmer actually stored the name # rather than just reading it back out of the conversation. - title = name or (f"{temp:.0f}° in {where}" if temp is not None - else f"{where}".capitalize()) + # "Jeff" alone reads as a contact name in a thread; "Jeff's morning" says + # what the card is while still proving the name was stored. + title = (f"{name}'s morning" if name else + (f"{temp:.0f}° in {where}" if temp is not None else f"{where}".capitalize())) bits = [] + # Weather leads the description when the title is not already the weather. + if name and temp is not None: + wdesc = (w.get("description") or "").strip() + bits.append(f"{temp:.0f}° {wdesc}".strip()) if t.get("live_min"): bits.append(f"{t['live_min']} min commute" + (f" at {friendly_hhmm(t['depart_at'])}" if t.get("depart_at") else "")) - if prices: + if prices and not bits: p0 = prices[0] bits.append(f"{p0.get('label')} {p0.get('pct_24h', 0):+.1f}%") if heads: @@ -272,6 +278,8 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str: '', '', '', + '', + '', f"{e(title)}", f'', f'', @@ -280,6 +288,7 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str: '', f'', '', + '', '', f"
", '
', diff --git a/smstext.py b/smstext.py index c4595f9..915a27d 100644 --- a/smstext.py +++ b/smstext.py @@ -53,6 +53,9 @@ def _restore_urls(text: str, found: list[str]) -> str: text = text.replace(f"\x00{i}\x00", safe) return text +_LABEL_BULLET = r'[A-Z][A-Z &]{1,14}:' + + def _sms_clean(text: str) -> str: """Normalize Unicode to ASCII and strip markdown so messages render cleanly as SMS. Does not enforce a length limit — sms_util.send_sms handles chunking long text into @@ -82,7 +85,10 @@ def _sms_clean(text: str) -> str: text = re.sub(r'\*\*(.+?)\*\*', r'\1', text, flags=re.DOTALL) text = re.sub(r'__(.+?)__', r'\1', text, flags=re.DOTALL) text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) - text = re.sub(r'^\s*[-*]\s+', '', text, flags=re.MULTILINE) + # A bullet is markdown to strip, except the morning text's "- LABEL: ..." + # lines: an ALL-CAPS label is how that message is structured, and the dash is + # its only bullet that survives plain ASCII. + text = re.sub(r'^\s*[-*]\s+(?!%s)' % _LABEL_BULLET, '', text, flags=re.MULTILINE) text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) return _restore_urls(text, _urls).strip() diff --git a/test_morning_link.py b/test_morning_link.py index 79711e3..a33e6f5 100644 --- a/test_morning_link.py +++ b/test_morning_link.py @@ -66,7 +66,7 @@ def test_message_carries_exactly_one_url(self): def test_the_line_survives_ahead_of_the_link(self): msg, _, _ = self._compose(line="Bitcoin is off 4.2% overnight.") - assert msg.startswith("Bitcoin is off 4.2% overnight. ") + assert msg.startswith("Bitcoin is off 4.2% overnight.\n\n") def test_it_is_one_message_not_two(self): """The briefing used to be its own text with the link chasing it.""" @@ -166,6 +166,10 @@ def _create(**kw): return morning.generate_morning_line("+1555", payload if payload is not None else PAYLOAD), calls +GOOD = ""+"Morning Jeff, easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min"+"" +TAIL_BAD = "Check the page.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + + class TestNamingTheLink: """"page has your full rundown" turns a text from a friend into a push notification. The prompt asks for this; the code enforces it.""" @@ -178,27 +182,28 @@ class TestNamingTheLink: "Morning Jeff, tap here for more.", ]) def test_a_line_that_names_the_link_is_redrafted(self, bad): - out, calls = _draft_returning(bad, "Morning Jeff, cool and clear.") - assert out == "Morning Jeff, cool and clear." + bad = bad + "\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + out, calls = _draft_returning(bad, GOOD) + assert out == GOOD assert len(calls) == 2, "must redraft exactly once" def test_a_clean_line_is_not_redrafted(self): - out, calls = _draft_returning("Morning Jeff, cool and clear.") - assert out == "Morning Jeff, cool and clear." + out, calls = _draft_returning(GOOD) + assert out == GOOD assert len(calls) == 1, "a clean line must not cost a second call" def test_the_redraft_is_told_what_it_did_wrong(self): - _, calls = _draft_returning("Check the page.", "Cool and clear today.") + _, calls = _draft_returning(TAIL_BAD, GOOD) assert "Check the page." in calls[1]["messages"][0]["content"] def test_it_gives_up_after_one_redraft(self): """Two calls, not a loop — a stubborn model must not burn the budget.""" - out, calls = _draft_returning("Check the page.", "Still on your page.") + out, calls = _draft_returning(TAIL_BAD, TAIL_BAD) assert len(calls) == 2 assert out, "a second violation still ships rather than failing the morning" def test_ordinary_words_are_not_false_positives(self): - out, calls = _draft_returning("Morning Jeff, cool and clear and 81.") + out, calls = _draft_returning(GOOD) assert len(calls) == 1 assert not morning._NAMES_THE_LINK.search("Cool and clear, pages of rain later") @@ -207,14 +212,28 @@ class TestLineDiscipline: def _draft(self, text): return _draft_returning(text)[0] - def test_long_output_is_trimmed_on_a_word_boundary(self): - out = self._draft("word " * 200) + def test_long_output_is_trimmed_on_a_line_boundary(self): + out = self._draft(GOOD + "".join(f"\n- NEWS: headline number {i} goes on a while" for i in range(30))) assert len(out) <= morning.MORNING_LINE_MAX - assert not out.endswith("wor"), "must not cut mid-word" + assert out.splitlines()[-1].endswith("goes on a while"), "no half-lines" + + def test_blank_lines_go_but_the_bullets_stay_separate_lines(self): + out = self._draft("Easy one.\n\n- WEATHER: 71 and clear\n\n- COMMUTE: 34 min") + assert out == "Easy one.\n- WEATHER: 71 and clear\n- COMMUTE: 34 min" + + def test_a_paragraph_is_redrafted_into_bullets(self): + out, calls = _draft_returning("Cool 71 and clear, drive is 34 min.", GOOD) + assert out == GOOD and len(calls) == 2 + assert "not the format" in calls[1]["messages"][0]["content"] + + def test_nothing_loaded_means_a_plain_greeting_is_fine(self): + out, calls = _draft_returning("Morning Jeff.", payload={"city": "Kirkwood, MO"}) + assert out == "Morning Jeff." and len(calls) == 1 - def test_newlines_are_flattened_to_one_line(self): - out = self._draft("Cool and clear today.\n\nBitcoin is down.") - assert "\n" not in out + def test_the_bullets_survive_the_sms_cleaner(self): + from smstext import _sms_clean + assert _sms_clean(GOOD) == GOOD + assert _sms_clean("- first\n* second") == "first\nsecond" def test_empty_output_raises_so_the_caller_can_fall_back(self): with pytest.raises(ValueError): @@ -225,10 +244,10 @@ def test_meta_commentary_raises(self): self._draft("Not sending the weather since they asked me to skip it.") def test_a_normal_line_passes_through_untouched(self): - assert self._draft("Cool 71 and clear.") == "Cool 71 and clear." + assert self._draft(GOOD) == GOOD def test_the_drafter_strips_a_placeholder_before_returning(self): - assert self._draft("Cool 71 and clear. [link]") == "Cool 71 and clear." + assert self._draft(GOOD + " [link]") == GOOD def test_the_prompt_also_warns_against_placeholders(self): assert "placeholder" in TestPrompt()._prompt()["messages"][0]["content"].lower() @@ -326,5 +345,5 @@ def test_the_team_is_required_when_present(self): assert "their team" in body and "so every one of them must appear" in body def test_the_team_is_not_required_without_a_game(self): - body = _draft_returning("Cool and clear.", payload=PAYLOAD)[1][0]["messages"][0]["content"] + body = _draft_returning(GOOD, payload=PAYLOAD)[1][0]["messages"][0]["content"] assert "their team" not in body diff --git a/test_page.py b/test_page.py index 2d29b15..f7db41d 100644 --- a/test_page.py +++ b/test_page.py @@ -80,13 +80,13 @@ def _title(self, **over): return re.search(r'og:title" content="(.*?)"', _render(**over)).group(1) def test_the_name_is_the_headline_when_known(self): - assert self._title(name="Jeff") == "Jeff" + assert self._title(name="Jeff") == "Jeff's morning" def test_it_falls_back_to_the_weather_when_unknown(self): assert self._title() == "81\u00b0 in Kirkwood, MO" def test_the_browser_title_matches(self): - assert "Jeff" in _render(name="Jeff") + assert "Jeff's morning" in _render(name="Jeff") def test_a_blank_name_does_not_produce_an_empty_headline(self): assert self._title(name=" ").strip() @@ -377,3 +377,18 @@ def test_it_is_arrangeable_by_the_words_people_use(self): def test_it_can_be_hidden(self): html = self._html(page_prefs={"hidden_sections": ["scores"], "section_order": []}) assert ">Scores<" not in html + + +class TestPreviewCard: + def test_it_names_the_site_and_carries_an_icon(self): + html = _render(name="Jeff") + assert 'og:site_name" content="Palmer"' in html + assert 'rel="apple-touch-icon" href="/icon.png"' in html + + def test_the_description_leads_with_the_weather_when_named(self): + desc = re.search(r'og:description" content="(.*?)"', _render(name="Jeff")).group(1) + assert desc.startswith("81\u00b0") + + def test_the_icon_is_a_png(self): + import cards + assert cards.render_icon()[:8] == b"\x89PNG\r\n\x1a\n" From 2b6670f237ed8dee3383e9591f65964c1dedc41f Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 19:46:08 -0500 Subject: [PATCH 2/3] Rename ambiguous loop variable so ruff passes Co-Authored-By: Claude Sonnet 5.5 --- morning.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/morning.py b/morning.py index 4bf465e..3d50c8d 100644 --- a/morning.py +++ b/morning.py @@ -526,14 +526,14 @@ def _tidy_morning_text(text: str) -> str: a line boundary. The old one-line version joined everything with spaces, which would flatten the bullets back into a paragraph; a mid-line cut would leave half a fact.""" - lines = [" ".join(l.split()) for l in text.splitlines()] - lines = [l for l in lines if l] + lines = [" ".join(ln.split()) for ln in text.splitlines()] + lines = [ln for ln in lines if ln] out, total = [], 0 - for l in lines: - total += len(l) + 1 + for ln in lines: + total += len(ln) + 1 if out and total > MORNING_LINE_MAX: break - out.append(l) + out.append(ln) return "\n".join(out) @@ -541,7 +541,7 @@ def _is_structured(text: str, min_bullets: int = 2) -> bool: """An opener line, then at least `min_bullets` labeled bullets.""" lines = text.splitlines() return len(lines) > min_bullets and not lines[0].startswith("- ") \ - and sum(1 for l in lines[1:] if _LABELED.match(l)) >= min_bullets + and sum(1 for ln in lines[1:] if _LABELED.match(ln)) >= min_bullets def generate_morning_line(phone: str, payload: dict) -> str: From 0c718a3cb0b185ceb73f85c2ce3397883c86eb15 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 19:53:13 -0500 Subject: [PATCH 3/3] Freshness tests use the reader's day, as the code does Co-Authored-By: Claude Sonnet 5.5 --- test_profile_freshness.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test_profile_freshness.py b/test_profile_freshness.py index 2aa7791..a431932 100644 --- a/test_profile_freshness.py +++ b/test_profile_freshness.py @@ -23,6 +23,7 @@ import artifacts import home import userprofile +from timeutil import local_today def _aged(field, days, value="something"): @@ -71,7 +72,8 @@ def test_storage_is_untouched(self): def test_writing_a_volatile_field_stamps_it(self): updates = {"stressed_about": "a deadline"} userprofile._stamp_volatile({"timezone": "America/Chicago"}, updates) - assert updates["field_dates"]["stressed_about"] == date.today().isoformat() + assert updates["field_dates"]["stressed_about"] == \ + local_today("America/Chicago").isoformat() def test_writing_a_durable_field_stamps_nothing(self): updates = {"name": "Jeff"} @@ -104,7 +106,8 @@ def test_stale_context_is_filtered_before_the_model_sees_it(self): life = userprofile.VOLATILE_FIELDS["life_context"] profile = {"city": "Culver City", "timezone": "America/Los_Angeles", "life_context": "Based in LA.", - "field_dates": {"life_context": (date.today() - timedelta(days=life + 1)).isoformat()}} + "field_dates": {"life_context": (local_today("America/Los_Angeles") + - timedelta(days=life + 1)).isoformat()}} with patch.object(agent, "get_profile", return_value=profile): sys = agent._build_system("+1555") assert "Based in LA" not in sys