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..3d50c8d 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(ln.split()) for ln in text.splitlines()] + lines = [ln for ln in lines if ln] + out, total = [], 0 + for ln in lines: + total += len(ln) + 1 + if out and total > MORNING_LINE_MAX: + break + out.append(ln) + 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 ln in lines[1:] if _LABELED.match(ln)) >= 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"