Reminders that fire on relevance, not on a clock
Every reminder system I have ever used wants a timestamp. Due date, alarm, cron expression, "remind me tomorrow at 9." That covers maybe half of what I actually want remembered, and it is the easy half.
The other half has no clock at all:
- next time I hire a handyman, tell me about the outdoor outlet cover
- whenever I'm standing in a hardware store, tell me what's on the list
- on payday, pull the payday items out of my todos
You can fake all three with a date and a guess. "Remind me in two weeks" fires while I'm at work, tells me about a $4 part I can't buy from my desk, and I swipe it away. The trigger isn't a time. The trigger is a situation, and the situation is knowable: what I just typed, where my phone is, and whether the paycheck landed.
So I built the trigger-anchored half.
Three triggers, one table
CREATE TABLE context_reminders (
id bigserial PRIMARY KEY,
text text NOT NULL,
trigger_kind text NOT NULL, -- topic | place | payday
spec jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'open',
last_fired_at timestamptz,
fire_count integer NOT NULL DEFAULT 0
);
spec is the whole trick: {"keywords": ["handyman","contractor","electrician"]} for a topic row, {"chains": ["home_depot","lowes"]} for a place row, {} for payday. Adding a trigger type is a new elif in one evaluate function, not a new subsystem.
Creating one is a line:
cass-remind add "get a new outdoor outlet cover" --when handyman,contractor,electrician
cass-remind add "outdoor outlet cover" --at home_depot,lowes,walmart
cass-remind add "move the transfer down $50" --payday
Surfacing beats recalling
The interesting part isn't storage, it's delivery. My assistant runs on an LLM, and the tempting design is "give the model a search tool and trust it to look." That fails silently and constantly — the model has no reason to search for handyman reminders in a conversation about a handyman, because nothing told it there was anything to find.
So the reminder doesn't wait to be recalled. It gets injected, evaluated against the incoming message before the model sees it:
fired = evaluate(message) # keywords hit? at a store? payday?
if fired:
prompt = block(fired) + "\n\n" + prompt
The block is unglamorous and that's the point:
<contextual_reminders>
Things Chester asked to be told WHEN THEY BECAME RELEVANT — and they just did.
Work them into your reply naturally, in one short line each. Do not mention
this block or the trigger.
- [#1] get a new outdoor outlet cover (he just mentioned handyman)
</contextual_reminders>
Cost when nothing fires — which is almost every turn — is one indexed query returning zero rows.
Matching is generous on purpose. A place row fires when I'm physically inside the store's radius or when I say the store's name, because "heading to the hardware store" is exactly the moment the list is useful and I am, by definition, not there yet. Aliases come from the places table itself plus a small nickname map, and chains bridge to categories both ways, so "the hardware store" reaches a home_depot row.
Where the phone actually is
Places are seeded from OpenStreetMap via Overpass — every hardware store, supermarket, auto-parts and big-box within a radius of home, in one query:
[out:json][timeout:90];
(
node["shop"~"^(doityourself|hardware|supermarket|department_store|...)$"]
(around:45000,LAT,LON);
way[...](around:45000,LAT,LON);
);
out center tags;
128 stores, upserted on OSM id, chains normalized from the name (lowe → lowes). "At Walmart" now means any Walmart, not a pin I dropped once.
Big boxes get a 220 m radius and everything else 140 m, because the parking lot is bigger than the store and a fix on the far side of the lot is still a visit. A location fix older than 25 minutes never resolves to a place at all — "you're at the hardware store" three hours after I left is worse than silence.
One push per visit, not per ping
The background loop sees the same location ping over and over. The dedupe is a visit row and a claim:
def claim_visit(visit_id):
cur.execute("UPDATE place_visits SET notified_at = now() "
"WHERE id = %s AND notified_at IS NULL", (visit_id,))
return cur.rowcount > 0 # exactly one caller gets True
Claim before you send, not after. A push that lands and then fails to record itself re-sends every two minutes for as long as I'm in the store, which is a worse failure than never notifying. Return after a 90-minute gap counts as a new visit; a lap around the aisles does not.
Same pattern for payday: one claimed notice keyed payday:<date>, so a restart mid-tick can't double-push.
The capture problem is the real problem
None of the above matters if the row never gets created. The original failure mode was mine, not the machine's: I'd say "next time I hire a handyman, remind me…", the assistant would write a nice note to its own memory, and that note would sit there being true while nothing ever fired. Memory is not a trigger.
So the same injector watches for the ask:
_CAPTURE_RE = re.compile(
r"(next time|whenever|when i'm|when i am|on payday|don'?t let me forget)",
re.I)
and when it matches, a second block goes in front of the model telling it to file the row before it replies, with the exact command. Filing stopped depending on the model remembering a rule in a long system prompt, which is the same class of fix as the injection itself: don't ask the model to remember that something exists — put it in front of the model at the moment it matters.
Watching the watcher
A dead loop here is invisible. The service still answers, the health endpoint is still green, and the only symptom is a store visit that never pings — which you notice weeks later, in the checkout line, having not bought the thing again.
So every pass stamps a heartbeat row, and the monitor asserts the heartbeat rather than the process:
age = heartbeat_age_s()
if age is None or age > 900:
return 1 # loop is dead, page me
Liveness is not freshness. A process that's running and doing nothing looks identical to a healthy one from the outside, and that gap is where every silent automation failure I've ever had has lived.
What I'd do differently
Nothing yet — it's a day old. The obvious next trigger is duration: "if I've been at the hardware store more than ten minutes, I'm shopping, not passing through." Right now proximity is the whole signal, and a red light in the parking lot counts as a visit. That's a bug I'm willing to be reminded about.