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.go→Former.InsertTurn,formation/writes.go): role, text, speaker, the assistant turn'stool_calls_jsonexecution 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 ispinned = 1, exempting it from L0 decay;retention_window_days/sweep_interval_secondsin 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 asSignals.History. A thread is seeded lazily from L0 on its first touch after process start (Store.SetLoader, wired inmain.go; the lastDefaultRecencyTurnsuser 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 — theclear_contexttool orResetCompaction(webui/memory_admin.go) — goes through the startup-wiredclearSpeaker: 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'smaybeCompactfolds the older prefix of the working history into one LLM-generated rolling summary, keeping the lastkeep_last_turnsuser turns verbatim (Compactbuilds the summary,FoldCompaction/history.FoldCompactionrebuilds 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, plustext,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_linkrecords 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), withfact_episode_linkpreserving 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:
- Reads the whole session back from L0 (
turnsForConversation). - Session-gates trivial sessions out (
session_gate_*— too few user turns / too little content are stamped formed with no facts, no episode). - 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-alliswhen too close to call), a deterministic provenance gate (USER-STATED vs self-hedged INFERRED), keyed dedup (dedup_similarity, bumpssupport_countinstead of inserting a duplicate), and contradiction/supersession (contradiction_similaritycandidate + judge). - Builds the L4 episode from the reconstructed execution record (
BuildEpisode,formation/episode.go): a### HEADLINE / ### SUMMARYnarrative, embedded, optionally linked to similar recent episodes, then written withfact_episode_linkprovenance rows. - Stamps
session_formationexactly 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:
SearchFactsfuses the L3 fact vector + FTS tracks only — the auto-inject path.SearchAllfuses turns + facts + episodes into one ranked list — the explicitrecall_memorytool.
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 followgeneral.system_language(no separate date-locale knob). - Episodes (
EpisodesSettings):enabledmaster toggle, thelinking_*Zettelkasten knobs, andrecency_decay_half_life_days(therecall_memorytool's recency half-life).
The RRF fusion constants (rrfK, per-source weights) are fixed in hybrid.go.