The shipped version of the proactive feature: Anna texts you first through the day. This is the implementation companion to the design doc — what's actually running, the files, the decision logic, and the knobs.
5 mininterest, sent over iMessage). The cadence/anti-annoying rationale lives in the design doc; this doc is the build.A slow setInterval sweep walks every learner. For each, one predicate decides whether to text; if yes, it composes an opener with the same brain as a reply and sends it with the normal typing choreography. No new infrastructure — it's a second, slower sibling of the 1 s inbound poller.
All five must hold, or the learner is skipped this tick:
| Gate | Rule |
|---|---|
| On the whitelist | same ALLOW_FROM as inbound — proactive never texts a handle the poller wouldn't (also drops stale entries left from when the list was open). |
| Has a chatGuid | captured from a prior inbound message. No chatGuid → nothing to send to (this is why it's re-engage-only, no cold-start). |
| Waking hours | local 9:00–21:00. Outside it, the whole sweep returns early — no overnight texts. |
| Quiet past the gap | now − last activity > ~2.5h + jitter(0–1h). Activity = last inbound OR outbound, so a poke we just sent resets the clock. |
| Not already sending | skips if a reply's mid-flight (shares the sending latch with the reactive path). |
The sweep picks an angle — a short steer handed to the model, which writes the actual words at the learner's level — and skips the last-used one so it's not repetitive. The opener is composed from the same persona + memory as a reply, just with no inbound message and a [note to self] stage-direction as the final turn.
| Angle | Steer |
|---|---|
continue | pick a recent thread back up |
memory | surface something you know about them, out of the blue |
day | just check in on their day |
vibe | share a tiny thing from her own day, then turn it back |
interest | open on something they love |
morning / lunch / evening | time-of-day check-ins — leaned into ~50% when the clock's in that window |
The model has no clock of its own. The first live poke proved it — at 1:40 AM it opened with おはよ (good morning). So the sweep now computes the learner's local time and passes it into the opener, with an explicit "fit the time of day."
Now the note reads it's Wednesday 1:42 AM (the middle of the night) for them right now, so fit the time of day naturally (don't say good morning at night) — so a late-night poke lands as a low-key "still up?" instead of a morning greeting. The clock comes from the host's local timezone (same source as the waking-hours gate).
One new module; light touches to four existing files. No new dependencies.
pickAngle, describeTime, and the whitelist gate. All the when.reachOut() + directorNote() + the "you're texting first" system block. Shares runToolLoop (and the kana converter) with respond().appendTurn; allLearners() + markReachOut() for the sweep.config.proactive block reading .env.startProactive(...) with the shared typing helpers + sending latch; passes chatGuid into the inbound appendTurn.Per learner (keyed by handle), alongside profile/history/summary:
| Field | Meaning |
|---|---|
chatGuid | which iMessage chat to send to (from inbound) |
lastInboundAt / lastOutboundAt | activity clock for the quiet-gap check |
lastReachOutAt | when we last poked |
unansweredReachOuts | poke streak since their last reply (reset on any inbound) |
lastProactiveAngle | so the next pick avoids repeating it |
All in .env (see config.proactive for defaults):
| Env var | Default | What |
|---|---|---|
PROACTIVE | on | =off / 0 / no disables it |
PROACTIVE_START_HOUR / _END_HOUR | 9 / 21 | local waking window (end exclusive) |
PROACTIVE_GAP_HOURS | 2.5 | quiet floor before a poke (fractional ok) |
PROACTIVE_JITTER_HOURS | 1 | random add-on so it's never on a clean clock |
PROACTIVE_CHECK_MIN | 5 | sweep interval — the checker, not the text rate |
To watch it fire now instead of waiting hours, drop these in .env and restart — they widen hours, shrink the gap to ~3 min, kill jitter, and sweep every 30 s:
Scope it to one number with ALLOW_FROM so a test can't ping the wrong person. Delete the five lines to restore defaults. Watch the log for ~> +1…: proactive [angle] N bubble(s).