Memory
How Fermix stores, extracts, and recalls conversation history and long-term facts across three layers: SQLite, ETS caches, and prompt-memory markdown files.
Memory is how Fermix remembers your conversations and long-term facts about you between turns and across restarts. It operates in three layers: a durable SQLite database (memory.db, a single-file local database) that is the authoritative store, fast in-memory ETS caches (ETS is the BEAM runtime’s built-in in-memory table store) for quick access during a turn, and derived markdown files that are loaded into the system prompt. The markdown files are just copies; SQLite is the source of truth.
Storage layers
| Layer | What it holds |
|---|---|
SQLite (~/.fermix/memory.db) |
Messages, memories with FTS search (full-text search, so you can find memories by keyword), scheduled jobs, job runs, memory sources, versioned resources. Migrations are applied at startup. |
| ETS: ConversationStore | Hot cache of recent messages per conversation key. Write-through to SQLite with up to 3 write attempts (failed writes are retried with a short delay, then logged). Unbounded by default (max_conversation_history = :infinity, operator-configurable to a positive integer); token-based auto-compaction, not a message count, is the real bound on history size. |
| ETS: Store | Read-through cache for individual memory rows (scoped facts). |
| Prompt files | Per-agent markdown files (~/.fermix/memory/<agent_id>/USER.md, MEMORY.md). Loaded by PromptComposer as system messages ahead of conversation history. |
SQLite schema
messages
Conversation history with a full-text search mirror (messages_fts) maintained by SQLite triggers on insert, update, and delete.
messages(
id, agent_id, owner_id, channel, chat_id, thread_scope,
sender, role, kind, content, metadata_json, created_at
)
index on (agent_id, channel, chat_id, thread_scope, created_at, id)
memories
Scoped key-value facts with confidence, category, and promotion metadata. A unique constraint on (agent_id, owner_id, scope_type, scope_id, key) enforces deduplication at the database level.
memories(
id, agent_id, owner_id, scope_type, scope_id, category,
key, value, confidence, promote_target,
source_id, source_type, source_name, source_description,
session_id, run_id, source_message_id,
archived_at, archived_by, archive_reason, created_at, updated_at
)
unique (agent_id, owner_id, scope_type, scope_id, key)
promote_target determines which prompt file (user_md or memory_md) a memory row is rendered into. Outdated rows are archived rather than deleted (archived_at, archived_by, archive_reason); fermix memory restore ID un-archives a row.
For resources and resource_revisions, see resource versioning. For scheduled_jobs and job_runs, see scheduled jobs.
Memory categories
Memory categories determine where facts are stored in the prompt and who may write them. The current taxonomy uses six categories on a general-assistant spine: four about the user and two about the work.
| Category | Prompt target | Trust gate |
|---|---|---|
identity |
USER.md | Any trust |
preference |
USER.md | Any trust |
interest |
USER.md | Any trust |
goal |
USER.md | Any trust |
context |
MEMORY.md | Any trust |
directive |
MEMORY.md | Operator only (guests cannot promote) |
directive rows represent standing behavioral rules set by the operator. Guests cannot write them. The memory reviewer prompt also enforces that the agent’s own refusals and one-shot answers are not stored as directive or context rows.
Whether a row actually renders into a prompt file also depends on its scope: conversation-scoped context rows still render into MEMORY.md, conversation-scoped directive rows never do, and job-scoped rows never render into either file.
Each prompt file is bounded by per-section row caps (USER.md: Identity 6, Preferences 6, Interests 6, Goals 5; MEMORY.md: Context 12, Working Rules 8) and a per-value character limit (200 characters). The most recently updated rows are kept when a section exceeds its cap. Token budgets per file are 1500 tokens for USER.md and 2000 tokens for MEMORY.md.
Scope resolution
Every memory row has a scope_type and a scope_id that control which conversations and agents can read it back.
| Scope type | scope_id source |
|---|---|
owner |
The human’s owner_id |
agent |
The Fermix agent_id |
conversation |
Derived from {channel, chat_id, thread_scope} |
job |
The scheduled job’s memory_source_id |
Scope is determined by who writes the row: the background reviewer stores user-profile facts (identity, preference, interest, goal) at owner scope and working knowledge (context, directive) at agent scope; the memory_store tool writes at conversation scope; scheduled job runs write at job scope.
Extraction pipeline
Fact extraction happens through the time-gated background reviewer, dispatched after the reply is delivered (in TurnRunner.commit/4) so it never sits on the turn’s critical path. A failed review does not affect the turn that triggered it. There is no separate per-turn extractor — the reviewer is started every turn but only runs when its interval is due.
- After a successful agent turn, the main agent calls
Memory.Reviewer.start_background/1. - The reviewer is time-gated: it runs only if the review interval (
review_interval_hours, default 24) has elapsed since this conversation was last reviewed — or it has never been reviewed; otherwise it skips (:under_interval). A recent failure backs it off further (review_failure_backoff_ms, default 5 minutes).fermix memory review --nowforces a run past the interval and failure-backoff gates; it still requires new messages since the last review, and an atomic claim ensures only one review per conversation runs at a time. - When it runs, it reads the user-sent messages added since the last review (up to
review_max_messages, default 40, packed toreview_input_token_budget, default 4000 tokens) plus the current memory state. - It makes one bounded model call — on the same provider route the agent’s turns use (the primary/fallback chain), tagged
agent: "memory_reviewer"in traces — with a prompt to consolidate memory. The model returns strict operations (add,replace,archive) or the literal “Nothing to save.”; when a message refines or contradicts an existing row, the latest statement wins — the row is replaced or archived by id instead of a duplicate being added. - Operations pass through admission (
Memory.Admission): trust gating (guest-sourceddirectiveoperations are refused), category validation, and category-to-scope mapping. The database-level unique key prevents duplicate rows. - Admitted facts are written to SQLite, then
Memory.PromptFiles.rebuild/4re-renders the promoted rows intoUSER.mdandMEMORY.mdusing an atomic temp-file-and-rename to avoid partial reads.
The reviewer uses the centralized buffered LLM timeout policy (:llm_buffered) rather than a bespoke per-feature timeout. The four review knobs (review_interval_hours, review_max_messages, review_input_token_budget, review_failure_backoff_ms) live under [fermix_core.memory] in config.toml; the review interval is also settable through setup — see configuration.
Memory review
A background memory reviewer (“dreaming”) runs on a configurable interval (default 24 hours, set in hours through the setup wizard or web setup; setting it to 0 disables background review — --now still works). It reads recent user-sent messages and the current memory state, then applies updates: replacing outdated rows, archiving stale ones, and generalizing overly-specific facts. The reviewer is instructed to write each value as a declarative fact about the user or their work, not a self-instruction, and to update-in-place rather than append duplicates.
You can trigger a review immediately or restore an archived memory row from the CLI:
fermix memory review --now # all conversations with new messages
fermix memory review --now --conversation telegram:12345
fermix memory restore ID # un-archive a memory row
review --now also accepts --agent ID, --owner ID, and --json. Restoring a row rebuilds the prompt files so the fact reappears in the system prompt immediately.
Compaction
Compaction is how Fermix keeps a long conversation from outgrowing the model’s context window: it replaces older messages with a short summary so the conversation can continue. Auto-compaction is handled by Memory.Compactor. When a conversation’s provider-reported context-token count (the peak prompt tokens from the turn) crosses a configurable threshold of the model’s context window, Fermix summarizes older messages into a single checkpoint and replaces the stored history. The default compaction budget is 0.5 times the context window. The default trigger threshold is 0.85 (configurable via [fermix_core.compaction]).
[fermix_core.compaction]
enabled = true
threshold = 0.85
reasoning_effort = "medium"
reasoning_effort sets the thinking effort of the summarization call itself (default medium); summaries are mechanical, so they deliberately do not inherit the agent’s own — possibly much higher — reasoning-effort setting.
Compaction can fire both before a turn (a preflight check against the previous turn’s recorded peak context-token count) and after delivery. When the provider-reported context-token count reaches threshold * model_context_window, it compacts; a cold or first turn with no prior measurement skips the preflight check cleanly and the post-delivery pass catches it. Failures are recorded with a 60-second backoff so the next turn does not retry immediately. When a preflight compaction succeeds, Fermix sends a short notice to the chat: “Trimmed older conversation history to stay within the context window.”
You can also trigger compaction manually from any channel (operator-only, like all state-changing chat commands):
/compact
/compact summarizes the current window and keeps the summary in history, using the same primary/fallback provider chain as auto-compaction. /new or /clear clears only the conversation window; long-term memory, resource revisions, and scheduled jobs are preserved.
For the prompt composition details that govern where USER.md and MEMORY.md appear in the system prompt, see prompt and compaction.
Memory tools
The agent has three tools for direct memory access.
| Tool | Description |
|---|---|
memory_store |
Store a small key-value fact for the current conversation (arguments: key, value). Rows are written at conversation scope and are not rendered into the prompt files. |
memory_recall |
Key lookup or FTS5 search over memories and/or history. Accepts scope (current, owner, all) and source (memories, history, all); defaults to current scope and memories source. Scheduled job runs are restricted to their own job-scoped memories and cannot search conversation history. |
memory_sources_list |
List visible memory sources, including scheduled job sources |
Memory content loaded into the system prompt (the USER.md and MEMORY.md sections) is fenced in a <memory-context> block and labeled as informational background data, not new user input.
Prompt files
USER.md holds identity, preference, interest, and goal rows. MEMORY.md holds context and directive rows. Both files live at ~/.fermix/memory/<agent_id>/. They are rebuilt automatically after each extraction cycle.
The files are derived artifacts. Rolling back a prompt file restores the rendered content, but future extraction rebuilds will overwrite it if the underlying promoted memory rows are unchanged. To inspect or roll back resource revisions, see resource versioning.
mix fermix.resource.list
mix fermix.resource.history user_md --limit 10
mix fermix.resource.show user_md 3
mix fermix.resource.diff user_md 2 3
mix fermix.resource.rollback user_md 2
Realtime voice transcripts
If transcript persistence is enabled (persist_transcripts = true in the realtime config; off by default), final spoken user and assistant transcript text is stored in conversation history as message rows of kind voice_turn, under a dedicated realtime conversation per device, with source_type = "realtime" in their metadata. Persisted voice turns feed the same background memory reviewer as chat messages. See realtime voice.