The learning loop
The learning loop is what makes the Twin feel like it’s actually paying attention rather than waiting to be asked. It has two halves: extraction, which runs every time there’s new text to learn from, and reflection, which runs on a timer and decides whether anything deserves your attention.
Extraction: text in, memories out
Every ingestion path in Novaterra funnels through the same function:
learn(beingId, text, source, opts?) → { memories: Memory[], profileUpdated: boolean }learn() (apps/api/src/twin/memory.ts) calls extractMemories() (cheap tier, structured output —
see The memory model), stores
whatever comes back as real Memory rows, merges any profile delta, and — unless called quietly —
records a human-readable Twin activity like “Learned 3 things from browser and
updated the profile.” Everything that teaches your Twin something calls this one function:
POST /api/twin/learn— teaching it directly from the Twin screen or Settings.- Every Muse conversation turn, once a reply completes (
apps/api/src/modules/muse/learn.ts). - The files sync, for a short description of what’s in your root folder.
- The Chrome history import, for clustered interests.
- The Gmail sync, for each new thread summary.
POST /api/twin/learn { "text": "I just switched to a 5am wake-up and I hate it", "source": "muse" }[ { "id": "mem_a1...", "kind": "episode", "content": "Owner recently switched to waking at 5am and dislikes it.", "source": "muse", "importance": 0.5, "tags": [] }]Reflection: the quiet second pass
Extraction is reactive — it only fires when there’s text to read. Reflection is proactive: every ten
minutes (TWIN_TICK_MS, default 600,000ms), tick() (apps/api/src/twin/scheduler.ts) walks every
being with a live connection (or a FILES_ROOT for humans), and for each one:
- Runs any due provider sync — files (always, if a root is set), Gmail (if connected), and Chrome/Edge/Brave history (at most once every 24 hours).
- Calls
reflect(beingId).
reflect() only actually calls the model when at least three new memories have arrived since
the last reflection for that being (MIN_NEW_FOR_REFLECTION = 3) — this is deliberate budget
discipline (PLAN.md §7: “cheap models by default… never loop”), not a limitation: reflecting on
one new memory almost never produces anything worth surfacing, so the guard saves a call rather than
losing insight. One structured cheap-tier call per being per tick, at most.
The reflection prompt gives the model the current profile summary and every fresh memory, and asks for:
{ signals: Array<{ kind: 'insight' | 'reminder', title, body, priority }>, // max 2 widgets: Array<{ type: 'files'|'inbox'|'calendar'|'memory'|'notes'|'projects'|'wallet', reason }>, // max 2 profile: { facts, goals, preferences }, thought: string, // one short first-person line, e.g. "Noticed you've been reading a lot about Rust lately."}The system prompt explicitly instructs the model to be quiet by default — “return no signals
when nothing is genuinely useful” — which is the learning loop’s version of Article IX of
the constitution: the Twin may notice things, but it doesn’t manufacture reasons to
interrupt you. That instruction is a soft ask the model can still get wrong, so every signal
reflect() proposes is checked a second, harder way before it’s ever raised: it must clear
the Hush gate —
quiet hours, a daily ceiling, cited evidence, and 7-day dedup — with the fresh memories that
produced it standing in as the citation. A signal the model proposed but the Hush blocked is logged
as a held-back Twin activity line, never silently dropped and never raised anyway. Any
signal that does clear the gate goes through the normal raiseSignal() path (see
Signals and the omnibar); any widget suggestions publish
widget.suggest for the desktop to offer, never force.
Triggering it yourself
POST /api/twin/reflect → { ran, signals, widgets, thought }This calls reflect(beingId, { force: true }), skipping the three-new-memories gate — the
Reflect now button on /twin uses exactly this.
Activity
Every ingestion and reflection step writes a TwinActivity row — a human-readable line, not a raw
log — so the Twin’s activity stream reads like a diary rather than a debug console:
TwinActivityKind = 'learned' | 'read_email' | 'indexed_file' | 'browsed' | 'sent' | 'scheduled' | 'thought';GET /api/twin/activity → TwinActivity[] (newest 60)Examples, verbatim from the code: "Indexed 12 files", "Files: 340 indexed (4 new, 1 changed, 0 gone, 3 summarised).", "Read 3 new email thread(s); 1 needs a reply.", "Reflected on 6 new memories and raised 1 signal.", "You edited your profile by hand."
Boot sequence
startTwin() runs once when the API boots: it registers the Twin’s built-in skills (memory.recall,
memory.store, signal.raise — see Skills catalogue), starts the Telegram
poller(s), starts file watchers for every being with an existing connection, and schedules the
ten-minute tick — plus one early pass 20 seconds after boot, so a fresh install doesn’t sit idle for
ten minutes before its first reflection.
What happens without an OpenRouter key
Every stage of the loop is written to degrade, never fail: extraction falls back to sentence
splitting, embeddings are simply skipped (FTS5-only recall), and reflection returns null outright
— llmReady() gates it before a single call is attempted. Syncs (files, browser, Gmail) still run
and still create memories; they just skip the model-backed summarising/clustering step and use their
own heuristic fallbacks instead (see each connection’s own page).