atomic-agent

August 31, 2026 · View on GitHub

This document is the source-of-truth for how cross-session memory works in atomic-agent. It complements:

  • MEMORY_GUIDE.md — the operator-facing walkthrough with worked examples; start there if you want to use memory rather than modify it.
  • ARCHITECTURE.md — overall runtime topology and invariants.
  • AGENTS.md — short engineering summary for automated contributors.
  • PROMPT.md — full anatomy of the stable prefix and variable tail, including where the memory channels render in the prompt.
  • EVOLUTION.md — historical evolution notes (Option 2 / 2a, etc.).

1. Goals and non-goals

The memory subsystem must:

  • Survive across sessions and across process restarts.
  • Surface its hot bits to the model without forcing a tool call.
  • Surface its warm bits as pointers so the model can pull on demand.
  • Not invalidate the stable prompt prefix on writes (KV-cache must keep working).
  • Form itself automatically — the user shouldn't have to hand-craft profiles.
  • Stay bounded in size so the SQLite file and the prompt tail never blow up.

It explicitly does not do:

  • Embeddings or semantic search (FTS5/BM25 only).
  • Importance scoring or decay curves.
  • Episodic / dialogue summarisation.
  • Cross-machine sync, redaction, or encryption-at-rest.
  • Per-skill or per-task scoped memory pools.

2. The three channels

Memory is a triangle of three independent channels that share one SQLite file (<stateDir>/memory.sqlite):

ChannelWhat it storesRead pathWrite path
ProfileStoreshort key/value factsauto-injected as ### profile (gated)memory.profile.set tool + reflection
MemoryStorefreeform notes (FTS5/BM25)top-K auto-injected, plus #id index hintsmemory.notes.store tool + reflection
Reflectionn/a — it's the writern/aend-of-turn micro-LLM call

Both stores live in the same database file but use different tables and migrations:

  • profile_facts (key TEXT PK, value TEXT, pinned INTEGER, keywords TEXT, updated_at INTEGER)
  • memories (id INTEGER PK, content TEXT, tags TEXT, source TEXT, scope TEXT, working_dir TEXT, created_at, updated_at)
  • memories_fts virtual table (FTS5, porter unicode61).

MEMORY_SCHEMA_VERSION is currently 3. Migrations are idempotent and applied lazily on applyMigrations(db). See src/memory/memory-schema.ts.

3. ProfileStore — durable facts in the tail

3.1 Shape

A ProfileFact is:

{ key: string; value: string; pinned: boolean; keywords: string[]; updatedAt: number }
  • key is a short identifier (language, timezone, name, preferred_browser, …). Free-form but bounded by PROFILE_KEY_MAX_LENGTH.
  • value is plain text bounded by PROFILE_VALUE_MAX_LENGTH.
  • pinned=true (default) → fact is always rendered into the ### profile section.
  • pinned=false → fact is contextual: rendered only when at least one of its keywords hits the current user message (case-insensitive substring match).

3.2 Prompt placement

Rendered by src/memory/profile-renderer.ts into the ### profile section of the variable tail (after optional ### loaded-skills, before ### memory-index / ### session-facts / ### recalled). The block is bounded by memory.profile.maxTokens (default 512) with a [truncated] marker.

The contextual gate is controlled by memory.profile.contextualKeywordGate (default true). When false, all facts render regardless of pinned — useful for debugging.

3.3 Why pinned exists

Without gating, every "user uses GPT-5 for Python" / "user prefers Cyrillic shortcut keys" fact would burn tail tokens forever. With gating:

  • Pinned facts are identity-level and small in number (name, language, timezone, …). They go in every prompt.
  • Contextual facts are topic-scoped (e.g. python_test_runner=pytest [keywords=python,pytest,test]). They only appear when the topic is hot, so they don't crowd the tail when the user is talking about something else.

3.4 Tools

Defined in src/tools/memory/ and surfaced via src/prompt/tool-descriptors.ts:

  • memory.profile.set { key, value, pinned?, keywords? } — upsert. pinned defaults to true. keywords is an array of short strings used by the gate.
  • memory.profile.remove { key } — delete.
  • memory.profile.list {} — read-only. Output marks pinned facts with * and contextual facts with ~ [keywords: …].

4. MemoryStore — freeform notes with FTS5

4.1 Shape

A MemoryEntry is:

{ id: number; content: string; tags: string[]; source: "user" | "agent";
  scope: "all" | "project"; workingDir: string | null;
  createdAt: number; updatedAt: number }
  • content is a freeform paragraph (cap: MEMORY_CONTENT_MAX_LENGTH = 4000 chars).
  • tags is an array of short keyword tags (cap: 8 tags, 32 chars each).
  • scope: "project" ties the entry to a workingDir; scope: "all" is global.

4.2 Read path — automatic injection (tail-only)

Two new sections live in the variable tail:

  • ### recalled — top-K notes ranked by BM25 against the current user message. Driven by memory.recallInjection.{enabled, k, previewChars, maxTokens}. Default: k=3, preview clipped to 160 chars.
  • ### memory-index — the most recently updated N note pointers as #id [tags] preview lines. Driven by memory.index.{enabled, limit, previewChars, maxTokens}. Default: 20 rows, 60-char previews.

Both sections are deduplicated by id: anything that surfaced in ### recalled is filtered out of ### memory-index so the same note never appears twice in one prompt.

The pre-fetch lives in src/memory/memory-context-provider.ts. It is invoked once per turn from agent-loop.runTurn, never inside the per-step loop.

4.3 Read path — explicit drill-down

memory.notes.recall { query? | id?, scope?, workingDir?, k? }:

  • { id: 42 } — direct lookup. Used by the agent when it sees #42 in the ### memory-index section and wants the full body.
  • { query: "..." } — BM25 search; same engine as the auto-injected ### recalled block.

4.4 Write path

  • memory.notes.store { content, tags?, scope?, workingDir? } — explicit save by the agent.
  • Reflection (§5) writes notes automatically when memory.reflection.autoStoreNotes=true.

4.5 Eviction

Uncapped freeform memory rots fast. MemoryStore enforces a hard cap of memory.notes.maxEntries rows (default 1000). On insert overflow, the oldest rows by (updated_at ASC, id ASC) are deleted. There is no content-based deduplicationMemoryStore is a log, and the same statement can land twice. This is a known limitation, see §10.

5. Reflection — automatic memory formation

5.1 When and where

Fired at the end of every AgentLoop.runTurn, after the assistant_reply event has already been emitted. It is fire-and-forget: the user-visible response never waits on it. The next runTurn calls abortPending() so at most one reflection is in flight per session.

Implementation: src/memory/reflection/reflection-runner.ts.

5.2 KV-cache hygiene

Reflection runs on a dedicated llama-server slot reserved at bootstrap via slotManager.reserveReflectionSlot(). The main agent slot is never touched, so the operator's KV cache survives. When llama-server is single-slot, reflection falls back to slotId: -1 (no cache reuse) but the main slot is still untouched.

5.3 What it produces

A tiny stable-prefix prompt asks the model to extract durable facts from the last USER / ASSISTANT exchange. Output is GBNF-constrained to one of:

  • NONE — nothing worth remembering.
  • A bounded list of SET and/or NOTE lines, each with optional metadata.

SET — flows into ProfileStore

SET key=value
SET key=value [pinned=false; keywords=python,test]
  • Plain SET writes a pinned fact.
  • [pinned=false; keywords=…] makes it contextual.

NOTE — flows into MemoryStore

NOTE this is a freeform observation [tag1, tag2]

The trailing [tag1, tag2] marker is parsed out by the runtime; tags are merged with the implicit reflection tag.

Caps:

  • memory.reflection.maxFactsPerCall (default 3) — upper bound on SET lines.
  • memory.reflection.maxNotesPerCall (default 2) — upper bound on NOTE lines (set to 0 to disable).
  • memory.reflection.timeoutMs (default 10000) — hard timeout; on timeout, nothing is written.
  • memory.reflection.autoStoreNotes (default true) — master switch for the NOTE channel.

5.4 Validation and observability

Parsed entries flow through the same validators as the explicit tools (ProfileStore.set, MemoryStore.store). Invalid lines are logged and skipped, never failing the whole call.

Metrics: agent.memory.reflection counter tagged by outcome (ok | none | failed | aborted | timeout) plus the agent.memory.reflection.latency_ms histogram. Logs: reflection.fired, reflection.ok, reflection.none, reflection.aborted, reflection.timeout, reflection.failed.

6. Per-turn data flow

runTurn(userMessage):
  1. abortPending()                         ← cancel any in-flight reflection
  2. memoryContextProvider.buildMemoryContext({ userMessage })
        → recalled  = MemoryStore.recall(userMessage, k)
        → index     = MemoryStore.listIndex(limit) − {ids in recalled}
     attach to SessionState.recalledNotes / memoryIndex (ephemeral)
  3. for each step:
        prompt = buildPrompt({
          stable-prefix unchanged,
          tail: [session, profile?, recalled?, memory-index?, world, conversation, notice?]
        })
        ← profile renderer filters by pinned + keyword hits against userMessage
        llmComplete → parseToolCall → invoke → recordResult
        terminate on reply / finish / max_steps
  4. emit assistant_reply / turn_finished
  5. fire-and-forget reflection.reflect({ userMessage, assistantReply })
        → SET … → ProfileStore.set
        → NOTE … → MemoryStore.store (capped, FIFO-evicted on overflow)

SessionState.recalledNotes and SessionState.memoryIndex are ephemeral fields — stripEphemeral(state) removes them before SessionStore writes the snapshot to SQLite. They are recomputed every turn so the prompt always sees fresh hits against the current user message.

7. Prompt placement and KV-cache invariants

# stable prefix (never touched by memory writes)
[system persona]              ← describes the memory protocol to the model
[tool catalog]                ← includes memory.profile.* and memory.notes.*
[capabilities]
[skill catalog]

# variable tail (memory-aware sections, slow → hot)
### loaded-skills  ← optional; skill bodies from SessionState.loadedSkills
### profile        ← ProfileStore.list() filtered by pinned + keywords
### memory-index   ← MemoryStore.listIndex(limit), dedup against recalled; rows sorted by id
### session-facts  ← optional; SessionState.knownFacts (last 8)
### recalled       ← MemoryStore.recall(userMessage, k)
### world
### conversation
### notice         ← (optional, loop-detector)
### respond

Hard rules enforced by tests:

  • The ### profile, ### recalled, and ### memory-index sections are always in the variable tail. They never participate in the stable prefix hash. Pinned in build-prompt.test.ts.
  • Reflection runs on a separate llama-server slot; the main agent slot's KV cache is untouched. Pinned in slot-manager.test.ts.
  • The memory.notes.* corpus is never dumped wholesale into the prompt. Only top-K recalled and pointer-only memory-index rows go in.

8. Configuration reference

All keys live under memory.* in <stateDir>/config.json. Defaults are in src/config/config-schema.ts.

KeyDefaultMeaning
memory.profile.enabledtrueInject ### profile and register the three profile tools.
memory.profile.maxTokens512Hard ceiling for the rendered ### profile block.
memory.profile.contextualKeywordGatetrueHide pinned=false facts unless a keyword hits user message.
memory.reflection.enabledtrueMaster switch for the async reflection runner.
memory.reflection.timeoutMs10000Hard timeout per reflection call.
memory.reflection.maxFactsPerCall3Max SET lines written per reflection.
memory.reflection.autoStoreNotestrueAllow reflection to emit NOTE lines into MemoryStore.
memory.reflection.maxNotesPerCall2Max NOTE lines per reflection. 0 disables notes.
memory.notes.enabledtrueRegister the three memory.notes.* tools.
memory.notes.maxEntries1000Hard cap on memories rows; FIFO-evicted on overflow.
memory.notes.maxContentChars4000Per-call ceiling for memory.notes.store.content.
memory.notes.recallDefaultK5Default k when memory.notes.recall omits it.
memory.recallInjection.enabledtrueInject ### recalled into the prompt tail.
memory.recallInjection.k3Top-K notes for the auto-injected ### recalled block.
memory.recallInjection.previewChars160Per-line preview clip in ### recalled.
memory.recallInjection.maxTokens400Hard ceiling for the rendered ### recalled block.
memory.index.enabledtrueInject ### memory-index into the prompt tail.
memory.index.limit20Number of pointer rows in ### memory-index.
memory.index.previewChars60Per-line preview clip in ### memory-index.
memory.index.maxTokens300Hard ceiling for the rendered ### memory-index block.
paths.memoryDbFilederivedResolves to <stateDir>/memory.sqlite.

9. Anti-bloat: why memory does not blow up

A real concern: every turn triggers reflection, which can write new facts and notes. Without safeguards, the SQLite file and the prompt tail would grow without bound. The system has five layers of defence:

  1. Per-call write caps. Reflection cannot write more than maxFactsPerCall SETs and maxNotesPerCall NOTEs per turn (default 3 + 2). Even in the worst case, growth is at most ~5 rows per turn.
  2. Hard storage cap with FIFO eviction. MemoryStore.maxEntries (default 1000) is a strict ceiling; the oldest rows by updated_at are dropped on overflow. The SQLite file is bounded.
  3. Prompt-tail caps. Each tail section has its own maxTokens ceiling: ### profile ≤ 512, ### recalled ≤ 400, ### memory-index ≤ 300. Per-line previews are clipped (previewChars) so even a 4000-char note costs ~160 chars in ### recalled.
  4. Contextual gating for ProfileStore. Only pinned facts are unconditionally injected. Contextual facts only appear when their keywords hit the current user message — most prompts see only a small slice of profile_facts.
  5. LLM-side discipline. The reflection prompt explicitly tells the model to "skip trivia" and to output NONE if nothing is worth remembering. Reflection is a small focused micro-prompt, not the operator persona, so this rule is followed reliably.

Worst case under defaults: 1000 notes × ~80 chars/preview ≈ 80 KB cap on memory.sqlite for note bodies + a few KB for profile_facts. The prompt tail adds at most ~1200 tokens of memory regardless of how many entries are stored.

9.1 Operator inspection (TUI)

atomic-agent tui → Manage → Memory tab (or /memory from chat).

Read-only browser over <stateDir>/memory.sqlite:

ChannelWhat you see
profileActive facts; Enter shows bi-temporal history()
notesIndex rows; Enter shows full content + links
lessonsPointer rows; Enter shows principle + parents
proceduresPointer rows; Enter shows steps[]
linksEdge table (from → to, kind)
votesAudit tail when memory.voting.enabled

Hotkeys: j/k move, Enter detail, r refresh, a auto-refresh (5s), [/] cycle channel, 16 jump channel, f cycle notes archive filter (active / archived / all). On note detail with links enabled: g expands the graph neighbourhood.

Legacy: /memory dump still prints the active profile into the chat transcript.

10. Known limitations

  • No content dedup in MemoryStore. The same NOTE body can be written multiple times if reflection produces it across turns. FTS5 will then return clones in ### recalled. Mitigation: maxNotesPerCall=2 keeps the rate low; explicit memory.notes.forget removes duplicates.
  • No usefulness signal in eviction. FIFO-by-updated_at evicts the oldest row even if it has been recalled 100 times. A future revision could weight by recall hits.
  • Profile keys are LLM-generated. Reflection can invent new keys (coding_style, favourite_editor, …). There is no schema check beyond length validation; horizontal growth of the profile is bounded only by memory.profile.maxTokens truncation.
  • Reflection quality depends on the model. A weak model can either skip durable facts or store trivia. The [pinned=false; keywords=…] syntax is a request, not a contract.
  • No embeddings, no semantic recall. BM25 misses paraphrases. A user asking "what did I tell you about my Python testing setup?" will hit notes containing python and test, but not notes that only say "I prefer pytest for unit work".

11. Failure modes

FailureSurfaceRecovery
memory.sqlite corrupt / missingstartup error in bootstraprerun creates a fresh db; data lost.
Reflection LLM call times outreflection.timeout log + counternext turn fires a new reflection; nothing written.
Reflection grammar parse errorreflection.failed log + counternothing written; agent loop unaffected.
MemoryStore.recall BM25 syntax errswallowed in memory-context-provider### recalled falls back to empty.
ProfileStore.set validation rejecttool error reported back to the modelmodel can retry with valid input.
llama-server single-slotreflection uses slotId: -1no cache reuse for reflection; main slot intact.

12. Code map

src/memory/
  index.ts                       — named exports
  memory-schema.ts               — SQLite schema + migrations (v1 → v2 → v3)
  profile-store.ts               — ProfileStore CRUD (pinned + keywords)
  profile-renderer.ts            — `### profile` rendering with contextual gate
  memory-store.ts                — MemoryStore CRUD + FTS5 recall + listIndex
  notes-renderer.ts              — `### recalled` and `### memory-index` rendering
  memory-context-provider.ts     — per-turn pre-fetch with dedup
  reflection/
    reflection-prompt.ts         — micro-prompt stable prefix + SET/NOTE rules
    reflection-grammar.ts        — GBNF for NONE | SET … | NOTE …
    reflection-parser.ts         — discriminated-union parser
    reflection-runner.ts         — fire-and-forget orchestrator + write fan-out

src/tools/memory/
  profile-set.ts / profile-remove.ts / profile-list.ts
  notes-store.ts / notes-recall.ts / notes-forget.ts
  index.ts                       — registerMemoryTools

src/agent/
  agent-loop.ts                  — calls memoryContextProvider + reflectionRunner
  step-executor.ts               — threads userMessage into buildPrompt

src/prompt/
  build-prompt.ts                — emits `### profile / recalled / memory-index`
  stable-prefix.ts               — persona explains the memory protocol

src/session/
  session-state.ts               — adds ephemeral recalledNotes / memoryIndex
  session-store.ts               — stripEphemeral before persistence

src/config/
  config-schema.ts               — `memory.*` keys + defaults
  load-config.ts                 — `paths.memoryDbFile` resolution

13. Glossary

  • Profile fact — a (key, value, pinned, keywords) row in profile_facts. Auto-injected into ### profile (gated).
  • Note — a freeform MemoryEntry row in memories. Searchable by FTS5/BM25; auto-injected as top-K hits or as pointer rows.
  • Pinned fact — a profile fact that is always rendered into the prompt.
  • Contextual fact — a profile fact rendered only when at least one of its keywords hits the current user message.
  • Recalled — the ### recalled block: top-K full notes ranked by BM25 against the current user message.
  • Memory-index — the ### memory-index block: compact #id [tags] preview pointer rows.
  • Reflection — the async end-of-turn micro-LLM call that writes SET and NOTE lines into the two stores.
  • Ephemeral session field — a SessionState field (e.g. recalledNotes, memoryIndex) that is recomputed per turn and stripped before persistence.
  • Memory context provider — the per-turn pre-fetch component that populates recalledNotes and memoryIndex for the prompt builder.