Anna texts you first. Not once a day — throughout the day, the way a chatty friend does. The nudge isn't "come back to the app"; the text is the practice. This doc is the design: how she decides when to reach out, why it's a sweep and not a scheduled job, and how "frequent" stays "friend" instead of "spam."
6 minThe whole bet is that you learn a language from a person who's around, not from an app you open. A real friend doesn't wait for you to text first. They text you when something reminds them of you — and they do it more than once a day.
That's the feature. Anna reaches out on her own, several times across the day, and every message is real conversation in two languages. So you get exposure without ever "starting a session" — you just have a friend who keeps texting, and some of it's in Japanese.
Proactive texting splits cleanly. V0 is the relationship engine — she initiates, she keeps it warm, she keeps exposure flowing. V1 is what she's smart enough to say — resurfacing the exact phrases you fumbled, scheduled by an SRS.
Key line: V0 decides when to text from the clock and the thread; V1 decides what to text from a model of what you're forgetting. Same delivery path, different brain. Build the delivery path now.
We weighed three ways to trigger an outbound text:
| Approach | How | Verdict |
|---|---|---|
| Schedule after "session close" | Detect the conversation dying down, queue a job for ~later. | Needs a fuzzy "session ended" event. Lost on restart. |
| Fixed timer, cancel on reply | Arm a timer; an incoming message cancels and re-arms it. | Cancel bookkeeping on every inbound. Timers die on restart. |
| Sweep + check pick | A slow tick walks every thread and asks: due to text right now? | Simplest, restart-safe, one testable function. |
The insight collapsing the first two into the third: you never need to detect a "session close" — "quiet for N hours" is the session-close signal. And you never need to cancel a timer — an inbound message just resets last-activity, so the next tick naturally sees "too soon" and skips.
setInterval (~every 15–30 min) sweeps each thread. For each it checks one predicate — quiet long enough + inside waking hours + not ghosting — and if true, composes an opener from memory and sends it. We already run exactly this shape: the inbound poller is a 1 s setInterval. Proactive is just a second, slower one. No new infra.The proactive gap is only a floor. When you're replying, the live back-and-forth rides on top of it (that's the normal reactive path — it doesn't count as proactive). So the floor sets "if it's gone quiet, how long until she pokes," and real conversation fills the rest.
| Knob | V0 default |
|---|---|
| Quiet gap before a poke (the floor) | ~2–3h + random jitter, so it's never on a clean clock |
| Waking hours | local 9:00–21:00, never outside |
| Ignored-streak behavior | Persistent — keeps lightly poking through the day; resets each morning |
| Sweep interval | every ~15–30 min (just the checker; not the send rate) |
We considered two personalities for the ignored case. We're shipping B.
B leans into "throughout the day." It carries more annoy-risk, so the guardrails below matter — and the user-tuning hypothesis (§7) is the pressure valve.
This is the open question the onboarding doc flagged and didn't answer: "what governs proactive cadence so it's sticky, not spammy?" The answer is to make the cadence asymmetric rather than to make it slow:
Three hard guardrails make B safe:
Same brain as a reply — the persona and memory from core/agent.mjs, just kicked off without an inbound message. She pulls a real thread from memory, reacts to nothing in particular the way friends do, brings a little Japanese, and ends on something easy to answer. Not drilling your phrases back — that's V1.
She didn't say "let's practice." She just texted, and now you're in Japanese again — because answering is the natural move. The exposure is the message, not a lesson attached to it.
A competing model worth testing: instead of us picking the cadence, the user does. Anna asks — during onboarding or the first time she reaches out — something like "when do you want to hear from me? mornings? a few times a day?" — and honors it.
Open for V0: do we proactively ask (the user's hypothesis), or only listen for them to volunteer it? Asking is more explicit but adds an onboarding beat. Leaning: don't ask up front — ship B, listen for adjustments. Revisit if B tests as too much.
Small. One new module, light touches to three existing files. No new dependencies.
setInterval that walks all learners and applies the due-check (quiet gap + jitter, waking hours, ignored-streak). All the when lives here. Started from server.mjs, reusing its typing-dots choreography so a proactive text sends like a human reply.respond() that builds the same persona prompt with a "reach out first" stage-direction and no inbound message. Returns reply bubbles. Shares the tool loop with respond().chatGuid (so we can send), last-inbound / last-outbound timestamps, an unanswered-poke counter, and (for §7) an optional cadence preference. The chatGuid is captured from inbound, which is why V0 re-engages existing threads only.