Memory
One factory, four types, seven strategies. Persistent context across agent runs — observable, swappable, multi-tenant.
Your support agent told a customer their refund was processed last Monday. Six weeks later they ask "why did you tell me that when it wasn't true?" You go to look. The agent is gone. Logs are scattered. The decision evidence is not there. Memory in agentfootprint exists to close that gap — and the Causal type goes one step further by persisting the decision evidence itself, not just the conversation.
What memory is
A Memory is one flavor of the Injection primitive that operates across runs: a paired read+write subflow that loads relevant past content into the system-prompt slot before the LLM call, then persists the new turn back to a store after the turn finalizes.
The discipline is captured in two orthogonal axes:
| Axis | What it is | Choose by |
|---|---|---|
| Type | What shape of memory you're keeping | Episodic / Semantic / Narrative / Causal ⭐ |
| Strategy | How content is selected for the next call | Window / Budget / Summarize / TopK / Extract / Decay / Hybrid |
type × strategy × store combinations cover almost every memory pattern in the agent literature, including ones the literature hasn't named yet. The store layer is where multi-tenant isolation lives — every read and write is namespaced by the identity tuple { tenant, principal, conversationId }.
The four types
| Type | Stores | When to use |
|---|---|---|
EPISODIC | Raw conversation messages | Default for chat — "what was said earlier" |
SEMANTIC | Extracted structured facts | "What does the agent know about this customer?" |
NARRATIVE | Beats / summaries of prior runs | Long-running session summaries; cross-session highlights |
CAUSAL ⭐ | footprintjs decision-evidence snapshots | Cross-run "why" replay — answer follow-up questions from the SOURCE, not from reconstruction |
Causal memory is the differentiator. Other libraries' memory remembers what was said. agentfootprint's defineMemory({ type: CAUSAL }) remembers the run itself, not just the messages. New questions cosine-match past queries; the matching stored run injects into the next prompt; the LLM answers from what actually happened rather than re-deriving. (Decisions, tool calls, iterations, and token usage are harvested automatically by the evidence bridge; commitLog/narrative capture is still on the roadmap.)
The seven strategies
| Strategy | How content is selected | Cost |
|---|---|---|
WINDOW | Last N entries (rule, no LLM, no embeddings) | Free |
BUDGET | Fit-to-tokens via decider | Free |
SUMMARIZE | Keeps the last recent entries raw, folds everything older into one stored summary | One llm call per span, not per turn — the summary is written back |
TOP_K | Score-threshold semantic retrieval | Embedding call per query |
EXTRACT | LLM distills structured facts on write | One LLM call per write |
DECAY | Drops entries that have faded on a half-life | Free — a timestamp and an exponent |
HYBRID | Compose multiple strategies | Sum of constituents |
How SUMMARIZE spends your money (9.14.0)
defineMemory({ strategy: { kind: SUMMARIZE, recent, size, llm, model } }) loads size entries, keeps the newest recent verbatim, and folds the rest with one call to model. The summary is then written back to the same store under msg-summary-{fromTurn}-{toTurn}, so the next turn reads it instead of buying it again — a span is compressed once in the life of a conversation.
Three things follow, and they are the whole design:
- The originals are kept. A summary is a claim about the conversation; the entries it covers stay in the store and are excluded from recall by the summary's coverage metadata. Delete the summary and recall is verbatim again.
modelis required, with no fallback to the agent's model — the same law.compaction()has enforced since 8.14.0.Agent.memory()additionally refuses a summarizer that is the agent's own provider instance at the agent's own model.- A broken summarizer degrades to
WINDOW, loudly — one warning, onememory.strategy_appliedevent, and recall proceeds verbatim. A summary that comes back no shorter than the span it would replace is refused and latched, so the same question is not bought twice.
.compaction({ summarizer, model }) on the Agent is this same move applied to the live window rather than to recall; the two compose.
A WINDOW strategy on an Episodic store keeps the last N messages; on Semantic / Narrative it keeps the last N facts / beats. Causal is the exception — it supports only TOP_K (semantic match), never WINDOW (see the Causal section below). Mix and match the rest.
Each row has a type behind it, exported from agentfootprint/memory and united as Strategy: MemoryWindowStrategy ({ kind: 'window', size }), BudgetStrategy, SummarizeStrategy, TopKStrategy, ExtractStrategy, DecayStrategy, HybridStrategy. You rarely need to name one — defineMemory({ strategy: { kind: 'window', size: 10 } }) infers it — but they are there when you want to build a strategy value somewhere else and pass it in.
Why MemoryWindowStrategy carries a prefix its siblings don't
WindowStrategy already means something else at the package root: the conversation-window seam ({ name, plan(input) }) that .window(...) takes. Two exported types with one name and incompatible shapes is a trap for whoever imports from the wrong path, so as of 7.27.1 the memory one is MemoryWindowStrategy. agentfootprint/memory still exports the old name as a deprecated alias, so nothing written against 7.27.0 breaks.
Quick start — sliding window
The simplest memory: last N turns, no LLM, no embeddings, near-zero cost. Good default for short-to-medium chats.
const memory = defineMemory({ id: 'last-10', description: 'Keep the last 10 turns of conversation.', type: MEMORY_TYPES.EPISODIC, strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 }, store,});InMemoryStore is for dev. Production swaps to RedisStore (agentfootprint/memory), AgentCoreStore (agentfootprint/memory), or another adapter — same MemoryStore interface, drop-in.
What "last N turns" actually retains
A window of 12 keeps twelve turns, and a turn is an ordinal the store has to agree on: memory entries are keyed by it (msg-{turn}-{index} for messages, snap-{turn} for causal snapshots, beat-{turn}-{index} for narrative beats). Two turns that share a number are one turn overwriting the other.
Through 9.5.1 the Agent seeded that number to 1 on every run(), so a conversation whose host builds a fresh agent per turn — a new request, a new process, the same conversationId — rewrote msg-1-0 and msg-1-1 every time. Nothing threw, the store reported successful writes, and a .memory() declaring a twelve-turn window recalled exactly one prior exchange. As of 9.6.0 the turn is resolved once per run from two honest sources:
- the conversation the run was handed (how many user turns are already in the history — right for
followUp()andrun({ continueFrom })), and - the stores the memory writes to, which is the only thing that survives a fresh agent per turn.
The rule is max(hostTurn, highestStoredTurn + 1): a host that tracks turns honestly keeps its numbering, a stale counter is raised to the next unused turn, and neither can drag a conversation backwards. Gaps are legal — it is an ordinal, not a count.
Memory usage grows with this — that is the fix, not a regression
A six-turn conversation now stores twelve message entries where it used to store two, and the window injects up to size of them instead of the last exchange. Prompts get longer and stores get bigger because the agent is now remembering what you asked it to remember. If that is more than you want, that is a size / DECAY / .compaction() decision — turn it down deliberately rather than by accident.
The resolution costs one paged list() per store per run, and only when a memory actually writes: an agent with no memory, a read-only memory, or a corpus-only .rag(...) makes no extra call. Hosts that mount mountMemoryRead / mountMemoryWrite into their own flowchart own the number themselves — pass a real turnNumber, or derive one with resolveTurnNumber({ stores, identity, hostTurn }) (and maxStoredTurn(store, identity) if you only want to know how far a conversation has got), both exported from agentfootprint/memory. Their argument shapes are exported too: ResolveTurnNumberOptions (stores, identity, the optional hostTurn floor) and MaxStoredTurnOptions (a turnFor narrowing, which is how causal snapshots count only their own snap-{n} ids).
Letting old memory fade — DECAY
Window keeps the last N turns whatever their age. DECAY asks the other question — is this still worth remembering? — and answers it with a half-life: each entry loaded this turn is scored 2 ^ (-age / halfLifeMs) against its lastAccessedAt and dropped below minScore, before the token budget is spent on it. Free: no LLM, no embeddings, no key.
const memory = defineMemory({ id: 'fading', description: 'Recall recent turns; let month-old ones fade out.', type: MEMORY_TYPES.EPISODIC, strategy: { kind: MEMORY_STRATEGIES.DECAY, halfLifeMs: DAY_MS, // worth half as much every day it goes untouched minScore: 0.1, // ~3.3 half-lives — below this it is not injected }, store,});A day-old entry scores 0.5 against a one-day half-life; a week-old one scores 0.008 and is gone. minScore: 0 scores without dropping.
Composing a pipeline by hand instead of through defineMemory? The stage itself is exported: filterByDecay(config) takes a FilterByDecayConfig (halfLifeMs, minScore — default DEFAULT_DECAY_MIN_SCORE, and a now clock seam for tests) and drops into any chart between your load stage and your picker.
Two things it deliberately does not do. It never deletes: the entry stays in the store, so a shorter half-life, a lower floor, or a different memory over the same store sees it again — reach for ttl on the entry when you mean "stop storing this". And it scores by age, not use: the underlying model has an access term, but accessCount is only bumped by store.get() and no shipped read path calls get(), so the strategy passes a neutral value rather than offering a knob wired to a counter that never moves.
Which strategies can this deployment actually run?
MEMORY_STRATEGIES is seven bare strings — enough to write a strategy, not enough to offer one. listMemoryStrategies() is the same seven described, so a settings screen (or an agent choosing its own memory) can check before it offers the choice instead of learning from an exception:
import { listMemoryStrategies } from 'agentfootprint/memory';
const canSupply = new Set(embedder ? ['embedder', 'vector-store'] : []);
const offerable = listMemoryStrategies()
.filter((s) => s.types.includes('episodic'))
.filter((s) => s.requirements.every((r) => canSupply.has(r)));
// no embedder → window, budget, decay, hybrid: the four that cost nothing to run.Each record is a MemoryStrategyInfo: the kind you write into strategy.kind, a plain description (current-truth caveats included), the types that accept it, and requirements — what the host must supply, as MemoryStrategyRequirement values ('embedder', 'vector-store', 'llm'). Empty means it runs anywhere, at $0. memoryStrategyInfo(kind) looks up one.
The declaration is enforced, not decorative: defineMemory refuses at build when a strategy's declared requirement is missing — including inside a HYBRID, whose sub-strategies used to be accepted and then quietly ignored. A missing embedder is a sentence at startup, never a TypeError from inside a stage halfway through a paid run.
Causal memory — replay decisions, not just messages
The CAUSAL type stores footprintjs decision-evidence snapshots tagged with the user's original query. On follow-up runs, the read subflow embeds the new query, cosine-searches the snapshot store, and (when above threshold) injects the matching past snapshot's decision evidence into the next LLM call. The LLM answers about past behavior from the actual recorded reasoning, not by hallucinating consistency.
⚠️ Causal memory is dev / single-process only today.
defineMemory({ type: CAUSAL })supports onlyTOP_K(semantic match) over a store that implementssearch()— and the only shipped store withsearch()isInMemoryStore. It throws at build onRedisStore(which implements everyMemoryStoremethod exceptsearch()), and on any non-TOP_Kstrategy (WINDOW/BUDGET/ … are rejected — causal snapshots are matched semantically against the new query, not by recency).AgentCoreStorepasses the build-time check — it does implementsearch()since 7.15 — but fails at runtime instead: AgentCore ranks server-side and needs the query as text inoptions.text, while this pipeline hands it the vector alone, so the call refuses by name rather than returning an empty list. So there is no shipped store for persistent, cross-session causal recall yet — the production path is a vector adapter (pgvector / Pinecone / Qdrant), all currently planned. Until one ships, run causal memory in-process viaInMemoryStore. For persistent or cross-conversation "why?" without a vector store, reach for.selfExplain()(in-conversation, no store needed) — or persist the decision evidence yourself.
const causal = defineMemory({ id: 'causal', description: 'Store snapshots of past runs; replay decisions on follow-up.', type: MEMORY_TYPES.CAUSAL, strategy: { kind: MEMORY_STRATEGIES.TOP_K, topK: 1, // single best-matching past run threshold: 0.5, // strict — drop weak matches (no fallback) embedder, }, store, projection: SNAPSHOT_PROJECTIONS.DECISIONS, // inject decision evidence});projection: SNAPSHOT_PROJECTIONS.DECISIONS says "when injecting, include only the decide() and select() evidence — not the full snapshot." Other projections: COMMITS (commit-log only), NARRATIVE (rendered narrative entries), FULL (everything).
The same snapshot data shape feeds SFT / DPO / process-RL training pipelines. One recording, three downstream consumers (audit / cheap-model triage / training data) — see the README's "differentiator" section for the full economic argument. A turnkey exportForTraining({ format }) is on the v2.5+ roadmap; until then a SnapshotEntry already is a training row, so project it yourself:
import type { SnapshotEntry } from 'agentfootprint/memory';
// One stored snapshot → one JSONL line. query = prompt, finalContent = completion;
// toolCalls + evalScore carry the extra signal for tool-use RL / DPO ranking.
const toJsonl = (e: SnapshotEntry): string =>
JSON.stringify({
prompt: e.query,
completion: e.finalContent,
tools: e.toolCalls.map((t) => ({ name: t.name, args: t.args })),
...(e.evalScore !== undefined && { score: e.evalScore }),
});
// Read the snapshots you persisted (e.g. from your store) and write one line each.Multi-tenant identity
Every store call takes a MemoryIdentity tuple — { tenant?, principal?, conversationId }. Adapters MUST namespace internal keys by the full tuple. A bug passing the wrong tenant surfaces as "no data" not as a cross-tenant leak.
const identity = { tenant: 'acme', principal: 'alice', conversationId: 'thread-42' };
await agent.run({ message: '...', identity });For a deeper dive on how identity flows through the store + RAG indexing footgun, see Memory store adapters.
Stores
| Store | Subpath | Production-ready |
|---|---|---|
InMemoryStore | (top-level) | Dev / tests / single-process scenarios |
RedisStore | agentfootprint/memory | ✅ — peer-dep ioredis, atomic Lua CAS, pipelined writes, GDPR forget |
AgentCoreStore | agentfootprint/memory | ✅ — peer-dep @aws-sdk/client-bedrock-agentcore, session/event mapping |
| DynamoDB / Postgres / Pinecone | (planned) | v2.6+ |
Both production adapters lazy-require their SDK and accept _client for test injection. See memory-stores for the full integration matrix.
Anti-patterns
- ❌ Don't fall back when TopK threshold returns nothing — strict semantics. Garbage past context is worse than no context. The library throws on empty by design; don't catch + ignore.
- ❌ Don't change
embedderIdbetween writes and reads — stored entries are tagged with the embedder used at write time. Reading with a different embedder silently corrupts retrieval. Use the same embedder or filterembedderIdat search. - ❌ Don't use
_globalidentity in production multi-tenant apps — defaults are dev-friendly footguns. Pass per-tenant identity at everyagent.run()call.
Next steps
- Skills, explained — context engineering for instructions, the cousin pattern to memory
- Auto memory (Hybrid) — stack recent window + extracted facts + causal snapshots, each its own
.memory()call - Memory store adapters — Redis · AgentCore · planned backends
Grounding
Reduce hallucination by giving the LLM the source material — and recording what it produced vs what it was given. The trace IS the grounding evidence.
Auto memory (Hybrid)
Compose multiple memory layers — recent window + extracted facts + causal snapshots — each as its own .memory() call. Production-grade memory stack in ~30 lines.
