Proactive texts — how it works

BUILT · V0

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 min
Status: live and dogfooded. A real proactive text fired end-to-end (angle interest, sent over iMessage). The cadence/anti-annoying rationale lives in the design doc; this doc is the build.
Contents
  1. The flow at a glance
  2. When it fires (the due-check)
  3. What it says (the angles)
  4. Time awareness
  5. Where the code lives
  6. What it tracks in memory
  7. Tuning knobs
  8. Testing it fast
  9. Limits / not yet

The flow at a glance

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.

every 5 minsweep all learners
  for each: due? (whitelist · has chatGuid · waking hours · quiet > gap+jitter)
    ↓ yes
  pickAngle (varied, avoids repeat) + describeTime (local clock)
  → agent.reachOut (persona + memory + [note to self]) → bubbles
  → raise typing dots → send → stamp memory (lastReachOutAt, angle, streak)

When it fires (the due-check)

All five must hold, or the learner is skipped this tick:

GateRule
On the whitelistsame 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 chatGuidcaptured from a prior inbound message. No chatGuid → nothing to send to (this is why it's re-engage-only, no cold-start).
Waking hourslocal 9:00–21:00. Outside it, the whole sweep returns early — no overnight texts.
Quiet past the gapnow − last activity > ~2.5h + jitter(0–1h). Activity = last inbound OR outbound, so a poke we just sent resets the clock.
Not already sendingskips if a reply's mid-flight (shares the sending latch with the reactive path).
Model B (persistent), self-limiting. The gap resets off the last message, so an ignored thread gets a light poke roughly every 2.5–3.5h, capped by the waking window (~4–5/day max) and reset each morning. It tracks an unanswered-streak counter but — by design — doesn't back off on it; the waking-hours + gap are what keep "persistent" from becoming "spam." Swapping to the gentler "model A" (go quiet after N unanswered) is a one-line change against that counter.

What it says (the angles)

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.

AngleSteer
continuepick a recent thread back up
memorysurface something you know about them, out of the blue
dayjust check in on their day
vibeshare a tiny thing from her own day, then turn it back
interestopen on something they love
morning / lunch / eveningtime-of-day check-ins — leaned into ~50% when the clock's in that window

Time awareness

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."

1:40 AM · before — no time passed
anna
おはよ!
今日もカフェで仕事してる?☕️

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).

Where the code lives

One new module; light touches to four existing files. No new dependencies.

1
The sweep core/proactive.mjs — the interval, the due-check, the angle pool + pickAngle, describeTime, and the whitelist gate. All the when.
2
The opener core/agent.mjsreachOut() + directorNote() + the "you're texting first" system block. Shares runToolLoop (and the kana converter) with respond().
3
Activity tracking core/memory.mjs — timestamps + chatGuid stamped in appendTurn; allLearners() + markReachOut() for the sweep.
4
Knobs config.mjs — the config.proactive block reading .env.
5
Wiring server.mjsstartProactive(...) with the shared typing helpers + sending latch; passes chatGuid into the inbound appendTurn.

What it tracks in memory

Per learner (keyed by handle), alongside profile/history/summary:

FieldMeaning
chatGuidwhich iMessage chat to send to (from inbound)
lastInboundAt / lastOutboundAtactivity clock for the quiet-gap check
lastReachOutAtwhen we last poked
unansweredReachOutspoke streak since their last reply (reset on any inbound)
lastProactiveAngleso the next pick avoids repeating it

Tuning knobs

All in .env (see config.proactive for defaults):

Env varDefaultWhat
PROACTIVEon=off / 0 / no disables it
PROACTIVE_START_HOUR / _END_HOUR9 / 21local waking window (end exclusive)
PROACTIVE_GAP_HOURS2.5quiet floor before a poke (fractional ok)
PROACTIVE_JITTER_HOURS1random add-on so it's never on a clean clock
PROACTIVE_CHECK_MIN5sweep interval — the checker, not the text rate

Testing it fast

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:

PROACTIVE_START_HOUR=0
PROACTIVE_END_HOUR=24
PROACTIVE_GAP_HOURS=0.05
PROACTIVE_JITTER_HOURS=0
PROACTIVE_CHECK_MIN=0.5

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).

Limits / not yet