Calendar, to-dos, and the Hush
Three small, additive pieces shipped after the rest of the Twin: to-dos (a committed intention
with a state), the calendar (times, merged live with due-dated Signals), and the Hush (the
gate every proactive nudge must clear before it’s allowed to interrupt you at all). None of the
three needed a schema migration — each is its own CREATE TABLE IF NOT EXISTS, created lazily the
first time it’s touched, the same idiom apps/api/src/twin/memory.ts’s embed queue and the
market’s tables already use.
To-dos: a fifth shape for a thought
The primitive ontology
names four fates a thought can have — passing, spoken, kept, offered. apps/api/src/twin/todos.ts
names the gap directly in its own header comment: a to-do is “a thought the being has committed to
act on,” which needs a state machine none of the four existing fates carry, so it ships as its own
table rather than being forced into memories (kept has no “done”) or signals (offered is the
twin’s attention envelope, not the being’s own intention):
Todo = { id, beingId, title, notes, status: 'open' | 'done' | 'deferred' | 'dropped', priority: 'low' | 'normal' | 'high', dueAt: ISODate | null, projectId: string | null, memoryId: string | null, // set when a to-do grew out of something remembered deferredCount: number, createdAt, updatedAt, completedAt,}GET /api/twin/todos?status=open,deferred&projectId=&limit= → Todo[]POST /api/twin/todos → TodoGET /api/twin/todos/:id → TodoPATCH /api/twin/todos/:id → TodoPOST /api/twin/todos/:id/complete → TodoPOST /api/twin/todos/:id/defer { dueAt? } → Todo (defaults to +24h, bumps deferredCount)POST /api/twin/todos/:id/drop → TodoDELETE /api/twin/todos/:idThe calendar: stored events, merged live with Signals
calendar_events (apps/api/src/twin/calendar.ts) holds real, timed events: title, description,
location, startAt/endAt, allDay, recurrence, an optional link to a Project or to the
Signal it raised, and source: 'user' | 'twin'. But the route you actually read from is the
agenda, which is a merge, not a table scan:
GET /api/twin/calendar?from=&to= → AgendaEntry[] { origin: 'event' | 'signal', ... }listAgenda() unions stored events for the range with every open Signal whose dueAt falls in
that range and isn’t already linked to one of those events — the interop this was built for: a
bare schedule.create reminder (see Skills catalogue) shows
up on the calendar the moment it’s raised, with nobody having to also create a calendar event for
it. POST /api/twin/calendar accepts remind: true to do the opposite — create an event and a
linked reminder Signal in one call, through the same schedule.fire action the schedule.create
skill uses, so the twin’s tick fires it once, at startAt, through one mechanism either way.
The Hush: proactivity that has to earn the interruption
apps/api/src/twin/hush.ts opens with the design brief it exists to satisfy, quoted directly:
“Proactivity that is merely frequent is a notification system, which is exactly what the product
promises not to be.” evaluateNudge() is the whole restraint discipline in one pure, synchronous
function — no model call, no network, just SQLite reads and arithmetic — called by both of the
twin’s proactive channels before they’re allowed to raise anything: the learning
loop’s reflect(), for its insight/
reminder signals, and a second, wholly rule-based check this build adds, an overdue to-do with no
open reminder yet (nudgeOverdueTodos() in scheduler.ts).
Every candidate nudge is checked against eight gates, in this exact order, and the first one that fails wins:
- Snoozed —
mutedUntilin the future blocks everything, unconditionally. - Level
off— proactive nudges turned off entirely. - The relevance bar —
basedOn(the memory/to-do/event ids that justify this nudge) is empty → refused outright. No citation, no nudge, full stop. - Quiet hours — default 22:00–07:00 in the being’s own timezone, skipped only when
priority: 'urgent'. - The daily ceiling — default 3 a day, halved (minimum 1) at Hush level
fewer. - The relevance threshold — 0.35 normally, 0.6 at level
fewer, checked against either a caller-assertedrelevanceor the highestimportanceamong the cited memories. - 7-day dedup — refused if a nudge raised in the last week has a title whose word-overlap (Jaccard similarity) with this one is ≥ 0.6.
- “Fewer like this” — an exact topic-key match blocks outright; two or more pieces of feedback on the same kind of signal blocks unless this one clears a raised bar (relevance ≥ 0.75).
GET /api/twin/hush → HushSettings { level: 'normal'|'fewer'|'off', quietStart, quietEnd, dailyCap, mutedUntil }PUT /api/twin/hush → HushSettings“Fewer like this” actually changes future behaviour
POST /api/desktop/signals/:id/fewer-like-this → { ok: true }This doesn’t just dismiss the Signal card — recordFewerLikeThis() writes a real Memory of kind
preference ("Asked for fewer nudges like \"<title>\" (topic: <key>).", tagged hush-fewer, the
normalised topic key, and kind:<signalKind>), which gate 8 above reads on every future candidate.
Feedback compounds: enough of it on one kind softens that whole category, not just the one topic
you clicked away.
The defensibility contract: why was I told this
GET /api/twin/nudges/:id/why → { title, because, basedOn }Every nudge that clears the gate carries its own justification into the Signal it raises
(action.reason), built from the same basedOn list gate 3 required — for a reflect() signal,
that’s literally every memory the model saw when it decided to speak, so “why was I told this” is
answerable without asking a model to explain itself after the fact. Read against a Signal that
wasn’t raised through the Hush gate at all, it says exactly that: “No record of why — this was not
raised through the Hush gate.”
A blocked nudge isn’t silently lost either: it’s logged as one thought-kind Twin
activity line — “Held back an overdue nudge for ’…’: quiet
hours.” — so restraint is as auditable as speech.
A naming coincidence worth flagging
The soul model also uses the
word “the Hush” for something unrelated: a citizen’s mood decaying toward stillness overnight
(11pm–5am, in packages/souls/src/mood.ts). Same evocative name for the same rough idea — nighttime
quiet — but two independent systems with two independent schedules; don’t confuse a citizen’s own
mood drift with the proactive-nudge gate this page describes.