Skip to content

Memory subsystem

Memory lives in the Go server, over a single SQLite database (core.db, internal/core/store, DefaultPath<dataDir>/core.db). The Python sidecar owns no memory state: it is pure calc, exposing only embedding RPCs (EmbedText, EmbedVoice on packages/aleph/proto/aleph/sidecar/v1/sidecar.proto). The Go side calls EmbedText — the one shared multilingual sentence embedder (paraphrase-multilingual-MiniLM-L12-v2, sidecar/aleph_sidecar/embedding/embedder.py), which also serves tool arming — for every vector it needs. The background flag on the RPC marks bulk batches (fact upserts, provider context, anchors) that yield to interactive live-turn requests inside the embedder; MiniLM is symmetric, so there are no query:/passage: prefixes. There is no MemoryService, no sidecar memory.proto, and no sqlite-vec — vectors are stored as normalised little-endian float32 BLOBs (384 × 4 bytes) and scored in Go. If the store's recorded embedding model id no longer matches the active one, the server wipes the embedding-bearing tables at boot (loudly) rather than mixing two incompatible vector spaces (memory.EnsureEmbedderConsistency).

Two Go packages own the subsystem:

  • internal/core/memory/ — the schema and the read path (recall, search, hybrid ranking).
  • internal/core/formation/ — the write path (fact + episode extraction, compaction, per-session formation).

Layers

The schema (memory/schema.go, applied idempotently on every start via ApplySchema) defines three physical layers on core.db, and two more live in memory / are derived on the turn path:

  • L0 — verbatim turn ledger (turns + turn_fts). Every finalised turn is written verbatim by the live turn driver (internal/transport/listener/turndriver.goFormer.InsertTurn, formation/writes.go): role, text, speaker, the assistant turn's tool_calls_json execution record, and an embedding BLOB. An FTS5 external-content index (turn_fts, delete-directive triggers) keeps keyword search consistent. A turn linked to an extracted fact is pinned = 1, exempting it from L0 decay; retention_window_days / sweep_interval_seconds in the Memory settings expose the retention policy for unpinned turns.

  • L1 — working conversation window (internal/core/history). A per-thread, in-memory message list — the compaction-folded set plus a recency window of recent turns — that the turn loop feeds to context assembly as Signals.History. A thread is seeded lazily from L0 on its first touch after process start (Store.SetLoader, wired in main.go; the last DefaultRecencyTurns user turns), so a restart never wipes a conversation's live context, then mutated in memory across the live loop. Evicted threads release their working set (Store.Drop, called by the formation sweeper) and re-seed on a later touch. An explicit clear — the clear_context tool or ResetCompaction (webui/memory_admin.go) — goes through the startup-wired clearSpeaker: it drops the speaker's live working sets, finds their conversations in the durable ledger too, tombstones every id against the lazy seed, and retires the thread rows so the next input opens a fresh thread id. Cleared content is deliberately not formed into facts/episodes — the user asked to forget it.

  • L2 — compaction (formation/compaction.go). When the live turn's observed prompt-token count crosses the high-water mark (HighWater(nCtx, outputReserve) = 80 % of the usable window) — or unconditionally when the turn hit a context overflow, even a failed one — the turn driver's maybeCompact folds the older prefix of the working history into one LLM-generated rolling summary, keeping the last keep_last_turns user turns verbatim (Compact builds the summary, FoldCompaction / history.FoldCompaction rebuilds the message list, revision-guarded so a turn landing mid-summary survives). The overflow force is the deadlock self-heal: the in-turn refit shrinks only the in-flight prompt copy, so without folding the durable set an over-window thread would overflow on every turn while a success-gated compaction never runs. The summarize call itself caps its transcript (MaxSummaryTranscriptChars, most recent tail kept) so the rescue can never die on the overflow it is fixing.

  • L3 — fact store (facts + fact_fts). Persistent typed triples (subject, predicate, object, plus text, display_text, provenance, confidence, support_count, always_inject, superseded_by, embedding). A fact is typed by its predicate alone (the supersession/dedup key); functional predicates (lives_in, works_as) supersede on the exact (subject, predicate, scope) key, everything else falls to an embedding-candidate + one-word Gemma judge. Facts never expire — a stale fact is retired by supersession, never by a clock. fact_turn_link records which L0 turns produced a fact (and pins them).

  • L4 — episode archive (episodes + episode_fts). Narrative per-conversation summaries (headline, summary, participants, time window, embedding). Built at session end (formation/episode.go), Zettelkasten-linked to topically similar recent episodes (related_episodes), with fact_episode_link preserving provenance back to the L3 facts a session produced.

Formation (the write path)

Fact and episode formation runs per session, off the hot path, driven by the wake-driven formation sweeper (formation/sweep.go, kicked by persisted turns and settings changes, so formation starts ~coupling-TTL after a session's last word): each pass calls Store.EvictStale for threads idle past the coupling window, then Former.MarkSessionEnded and releases each evicted thread's L1 working set (history.Store.Drop), before forming the ended-and-past-grace backlog. The same sweep runs once at boot as a startup reconcile — a session marked ended but left unformed by a crash or a restart mid-formation persists as a session_formation row, so Former.FormPending re-drives it on the next start instead of losing its facts + episode. The sweep goroutine is async off the boot path, so it never delays the WebUI/Alabama binds. FormSession (formation/dispatch.go) is idempotent (a session whose formed_at is stamped early-returns) and memoryless for anonymous sessions (a session with no identified speaker forms nothing). It:

  1. Reads the whole session back from L0 (turnsForConversation).
  2. Session-gates trivial sessions out (session_gate_* — too few user turns / too little content are stamped formed with no facts, no episode).
  3. Chunks the user turns and distils typed facts per chunk in order so a later statement supersedes an earlier one across a chunk boundary (DistillTurns, formation/extract.go): one extraction pass per chunk (structured constrained-output when the provider supports GBNF, else a prose parse), predicate classification by anchor-cosine argmax margin (classifyMargin; the winner must beat the runner-up predicate by a relative margin, so the rule survives an embedder swap — identity catch-all is when too close to call), a deterministic provenance gate (USER-STATED vs self-hedged INFERRED), keyed dedup (dedup_similarity, bumps support_count instead of inserting a duplicate), and contradiction/supersession (contradiction_similarity candidate + judge).
  4. Builds the L4 episode from the reconstructed execution record (BuildEpisode, formation/episode.go): a ### HEADLINE / ### SUMMARY narrative, embedded, optionally linked to similar recent episodes, then written with fact_episode_link provenance rows.
  5. Stamps session_formation exactly once.

Former (formation/extract.go) depends only on the small provider seams (Generator, MemoryEmbedder, optional ConstrainedFiller) and a live settings-snapshot accessor; it writes core.db directly — no RPCs.

Recall (the read path)

memory/search.go holds the per-track SQL: SearchFactsVector / SearchFactsFTS, SearchEpisodesVector / SearchEpisodesFTS, SearchTurnsVector / SearchTurnsFTS, plus AlwaysInjectFacts (the pinned profile card) and BumpUsage. memory/hybrid.go fuses tracks with Reciprocal-Rank-Fusion (fixed constants: rrfK = 60; per-source weights turn 1.0, fact 1.5, episode 1.0) plus a recency half-life boost:

  • SearchFacts fuses the L3 fact vector + FTS tracks only — the auto-inject path.
  • SearchAll fuses turns + facts + episodes into one ranked list — the explicit recall_memory tool.

memory/recall.go is the shared recall surface (Recaller), used by both the WebUI memory-admin RPCs (webui) and the memory_recall tool provider. Recall embeds the query via EmbedText, runs SearchAll, applies the time-range / participant filters, and returns ranked episodes + turns + facts. Anonymous callers get zero results, and an owned pool is owner-only — a non-owner caller cannot read another user's personal memory. Fact recall is scoped by the confidently identified speaker (voiceprint at the high/Context band), never by the device: memory.Scope{OwnerUserID} selects the pool (a high-band speaker sees their own facts ∪ the household pool; a medium/greeting-band match — an "unsure" verdict that may be the wrong enrolled user — and an anonymous voice both see the household pool only). The high band is the sole guard on the personal pool now that device class is gone: only a confident voiceprint (or a verified account link on a chat transport) unlocks it. Both the auto-inject and tool surfaces build the same high-band-gated scope, so they can never diverge; and formation mirrors it on the write side — a fact is owner-scoped only when its source turn was high-band, so an uncertain match never poisons a user's pool.

Auto-inject into the prompt

The context-assembly decider (internal/core/decider/contextassembly/contextassembly.go) builds every turn's memory block: the always-inject pinned profile card plus the speaker-scoped SearchFacts hits for the query, rendered into a [Turn context] preamble on the current user message (never the KV-cached system prefix). Injected facts have their use_count bumped. The block is skipped entirely when memory is paused (Memory.enabled = false or recall_mode OFF) and limited to the pinned card under recall_mode PINNED_ONLY. Privacy rides the scope itself: a user's private facts (their pinned card included) are injected only when that user is the high-confidence identified speaker; any other voice — an anonymous guest, a medium-band ("confirm if unsure") match, or a different enrolled user — still gets the household pool but never another user's facts, on any device.

Recall tools

internal/tools/providers/memory_recall/ exposes three LLM tools — recall_memory (unified turns + facts + episode headlines), expand_episode, get_related_episodes — over the in-process Recaller. The provider is gated by the tools.providers.memory_recall integration entry: the LLM only sees the tools once the user adds and enables the integration. The tools resolve the caller's user id and speaker-keyed scope from the dispatch context; an unsigned caller is refused.

Settings

All tunables flow through the settings proto (Memory and its nested EpisodesSettings sub-message); there is no ALEPH_* env surface. The core knobs are wired live:

  • Auto-inject recall: enabled, recall_mode, recall_top_k, recall_min_similarity, pinned_card_max_facts.
  • Fact write: dedup_similarity, contradiction_similarity, inferred_fact_confidence_cap.
  • Session formation: session_gate_enabled / _min_turns / _min_content_tokens.
  • Compaction: keep_last_turns. The prompt datetime's weekday/month names follow general.system_language (no separate date-locale knob).
  • Episodes (EpisodesSettings): enabled master toggle, the linking_* Zettelkasten knobs, and recency_decay_half_life_days (the recall_memory tool's recency half-life).

The RRF fusion constants (rrfK, per-source weights) are fixed in hybrid.go.