Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
9 changes: 9 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 53 additions & 13 deletions morning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -565,32 +590,44 @@ 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.
- Use the numbers from the data verbatim.
- 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 "
Expand All @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -272,6 +278,8 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str:
'<meta name="robots" content="noindex,nofollow">',
'<meta name="referrer" content="no-referrer">',
'<meta name="theme-color" content="#f7f5ef">',
'<link rel="icon" href="/icon.png">',
'<link rel="apple-touch-icon" href="/icon.png">',
f"<title>{e(title)}</title>",
f'<meta property="og:title" content="{e(title)}">',
f'<meta property="og:description" content="{e(desc)}">',
Expand All @@ -280,6 +288,7 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str:
'<meta property="og:image:height" content="630">',
f'<meta property="og:url" content="{e(page_url)}">',
'<meta property="og:type" content="website">',
'<meta property="og:site_name" content="Palmer">',
'<meta name="twitter:card" content="summary_large_image">',
f"<style>{CSS}</style></head><body><div class=wrap>",
'<div class=masthead>',
Expand Down
8 changes: 7 additions & 1 deletion smstext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading