Skip to content

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 → Todo
GET /api/twin/todos/:id → Todo
PATCH /api/twin/todos/:id → Todo
POST /api/twin/todos/:id/complete → Todo
POST /api/twin/todos/:id/defer { dueAt? } → Todo (defaults to +24h, bumps deferredCount)
POST /api/twin/todos/:id/drop → Todo
DELETE /api/twin/todos/:id

The 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:

  1. SnoozedmutedUntil in the future blocks everything, unconditionally.
  2. Level off — proactive nudges turned off entirely.
  3. The relevance barbasedOn (the memory/to-do/event ids that justify this nudge) is empty → refused outright. No citation, no nudge, full stop.
  4. Quiet hours — default 22:00–07:00 in the being’s own timezone, skipped only when priority: 'urgent'.
  5. The daily ceiling — default 3 a day, halved (minimum 1) at Hush level fewer.
  6. The relevance threshold0.35 normally, 0.6 at level fewer, checked against either a caller-asserted relevance or the highest importance among the cited memories.
  7. 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.
  8. “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.