Skip to content

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:

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