Build

Memory & stores

The MemoryStore port — eleven required members, one optional method, and two declared capability bits. Seven adapters with their supported and missing operations stated honestly, the strategy axes that sit above them, and what each store's search() actually ranks.

Memory's TYPE × STRATEGY combinations are agnostic about WHERE the bytes live. The store is the third axis — the persistence boundary that turns memory from "ephemeral local state" into "production multi-tenant state with GDPR forget + multi-instance access". Pick by access pattern + durability needs.

The port

MemoryStore is the I/O boundary. Stages above it never talk to a concrete backend — they invoke these members and trust the adapter to handle durability, consistency, encryption and pagination.

Eleven required members, in four groups:

GroupMembersNotes
Read / writeget, put, putMany, putIfVersion, list, deleteputIfVersion is the optimistic-concurrency write; put is the "I know I'm the only writer" convenience
Recognitionseen(signature), recordSignature(signature)Cheaper than get when the caller only needs have we processed this before? — and a signature outlives the entry that produced it
Feedbackfeedback(id, usefulness), getFeedback(id)usefulness in [-1, 1]. getFeedback returns null for "never recorded", which is not the same as an average of 0
GDPRforget(identity)Remove everything for one identity, in one operation per backend

Four design principles are baked into those signatures, and they are worth reading before you write an adapter:

  1. Identity is always the first argument. Every call takes MemoryIdentity, so stores enforce tenant / principal isolation at the boundary. A bug passing the wrong identity surfaces as "no data" rather than a cross-tenant leak.
  2. Every method returns a Promise, even InMemoryStore's sync ops — so an adapter can swap sync ↔ async without breaking a caller.
  3. Reads return cursors, not unbounded arrays. list takes { cursor?, limit?, tiers? }, so a large namespace never OOMs.
  4. putMany with an empty batch MUST be a no-op — callers rely on it to skip a round-trip for a turn that produced nothing.

The optional, feature-detected members

Three, and each answers a different question:

MemberQuestionAbsence means
search?(identity, query, options?)Does this store rank at all?No vector search. Callers feature-detect with if (store.search)
supportsVectorSearch?: booleanCan you serve back the embeddings I wrote?Undeclared — behaves exactly as before this bit existed
ranksBy?: 'vector' | 'server-text'What query form does your search() take?Undeclared — nothing refuses, nothing changes

The next two sections are why the second and third exist.

The adapters

StoreDoorPeer depRanksMissing / emulatedWhen to use
InMemoryStoreagentfootprint/memory✅ vectors (O(n) scan)nothing — full portDev, tests, single-process
sqliteVectorStoreagentfootprint/memorynone (node:sqlite)✅ vectors, exact cosinenothing — full portA corpus that must survive a restart
pgVectorStoreagentfootprint/memorypg + the vector extension✅ vectorsnothing — full port. Does not create the tableA corpus beside your own data
s3VectorsStoreagentfootprint/memory@aws-sdk/client-s3vectors✅ vectorsrefuses putIfVersion, recordSignature, feedback; seen()false, getFeedback()nullA corpus you can add to without a redeploy
staticVectorStoreagentfootprint/memory✅ vectorsread-only: every write method refuses. seen()false, getFeedback()nullA corpus built elsewhere, served on a runtime with no disk
RedisStoreagentfootprint/memoryioredis❌ no search() at allvector search (RedisSearch is a separate module)Hot recent memory + signatures + durable feedback
AgentCoreStoreagentfootprint/memory@aws-sdk/client-bedrock-agentcore⚠️ ranks text, server-side, over records it derived itselfputIfVersion emulated; seen/feedback are in-process and do not survive a restartBedrock-native session + event memory

Every store adapter lives on the canonical agentfootprint/memory door — a new store adds an export, never a new import path.

They all implement the same MemoryStore interface — defineMemory({ store }) doesn't care which one you pass. Drop-in.

The refusals above are worth reading as a pattern rather than as gaps: an operation a backend genuinely cannot do is refused by name, and its read half answers truthfully rather than stubbing. s3VectorsStore.seen() returns false because nothing can be recorded there, so nothing has been — "TRUE, not a stub".

"Vector search" is two different questions

search() is optional on the port, so "can this store do vector search?" used to be answered by whether the method exists. That answers the wrong question. AgentCoreStore has a search() — it ranks server-side, over the records AgentCore's own extraction strategies derived, and never over the embeddings you wrote into it. Handed one, indexCorpus embedded an entire corpus, billed you for it, and reported embedded: 214 — over an index nothing could ever read.

So the port carries one declared bit, supportsVectorSearch, and the corpus-building calls (indexCorpus, indexFolder, indexDocuments) refuse a store that declares false, naming it and what to use instead:

indexCorpus: `AgentCoreStore` cannot serve vectors back, so indexing a corpus
into it would report success and retrieve nothing.
  Fix: index into a vector-capable store — InMemoryStore (dev/tests) or
  sqliteVectorStore (durable, one file), …

InMemoryStore, sqliteVectorStore and staticVectorStore declare true. AgentCoreStore and RedisStore declare false — the ⚠️ and ❌ in the table above are facts the code states, not only prose. For RedisStore that moves an existing failure earlier: it has no search(), so defineRAG always refused it, but only after the corpus had been embedded and billed.

Three values, and the third is the compatibility clause: true means vectors in, ranked vectors out; false means refuse me for a corpus; absent means undeclared, and behaves exactly as it did before this existed. Every adapter written against an earlier release — and any you have written yourself — is in that third case and is unaffected. Declare true on your own adapter if it ranks the vectors it is given.

Ranking mode: what query form does your search take?

supportsVectorSearch answers what a corpus builder needs to know. There is one question further down, and it is what a corpus reader needs: ranksBy.

ranksByWhat search() ranksWhat defineRAG needs
'vector'The embedding it was handed. Every local backend.An embedder — somebody has to turn the question into a vector
'server-text'TEXT, on the backend's side, over its own index. Reads SearchOptions.text.No embedder at all
absentUndeclared.Unchanged from before this existed

A managed knowledge-base service takes the question as WORDS. Wired to one of those, defineRAG still built an embedder, still called it once per turn, still billed you — and the vector it produced was discarded on arrival. The wiring read exactly like a working one.

Declare ranksBy: 'server-text' and the retriever is the whole wiring:

// A store whose backend embeds and ranks on its own side.
const kb = myManagedKnowledgeBase();  // ranksBy: 'server-text'

const agent = Agent.create({ provider })
  .rag(defineRAG({ id: 'docs', store: kb }))   // no embedder — nothing to embed
  .build();

Passing one anyway is refused, not ignored: an ignored embedder reads, from the wiring, exactly like a working one — the same line, the same id in the recording, and no way to tell that nothing was embedded. Such a retriever is read-only by construction (there is no embedder to build a write half around), and no agentfootprint.embedding.generated event is emitted, because none happened.

The two declarations must agree. { ranksBy: 'vector', supportsVectorSearch: false } is refused by name rather than resolved — either line could be the mistake, and guessing which would silently decide whether your queries get embedded.

Which shipped stores declare what, exactly

pgVectorStore and s3VectorsStore declare ranksBy: 'vector' explicitly. InMemoryStore, sqliteVectorStore and staticVectorStore declare only supportsVectorSearch: true, which resolveRankingMode reads as 'vector' — a bare true is a store saying it ranks the vectors it was given. AgentCoreStore and RedisStore declare only supportsVectorSearch: false, which says they cannot serve your vectors back but says nothing about how they rank, so they stay undeclared here.

So no store shipped today declares 'server-text'. It is the slot a managed knowledge-base adapter fills — and AgentCoreStore, whose search() really does take text, is the shape it describes without yet claiming the label.

Writing your own adapter? Two helpers are exported from agentfootprint/memory so you can make the same checks the library makes: resolveRankingMode(store, caller) returns 'vector' | 'server-text' | undefined and refuses a contradiction (call it in your own tests, so a typo in your two declarations fails there rather than at somebody's first query), and assertServesVectors(store, caller) is the refusal the corpus builders raise — useful if you write your own indexing helper and want it to fail the same way.

Strategy axes — two kinds of recall, and they are not interchangeable

Once the store is picked, one choice remains: who does the recalling.

Axis A — the library's own strategies, and what each one needs

A strategy is a rule this library runs over what the store returns. MEMORY_STRATEGIES is a const of seven bare strings, which is enough to write strategy: { kind: … } and not nearly enough to offer the choice — a picker built off that const offers seven options and learns which of them a deployment can run by calling defineMemory and reading the exception. That is a selector that discovers its own capabilities by failing.

So each strategy declares itself, and listMemoryStrategies() enumerates the declarations:

interface MemoryStrategyInfo {
  readonly kind: MemoryStrategyKind;                          // the value you write as strategy.kind
  readonly description: string;                               // plain, current-truth caveats included
  readonly requirements: readonly MemoryStrategyRequirement[]; // what the HOST must supply
  readonly types: readonly MemoryType[];                       // the memory TYPES that accept it
}

requirements is the load-bearing field. Three well-known values ship, and the type stays open so your own strategy descriptor can name something else:

RequirementWhat the host must supply
'embedder'An Embedder that turns text into a vector
'vector-store'A store that implements search()not the same requirement as an embedder: the embedder makes the query vector, the store ranks against it, and a deployment can easily have one without the other
'llm'A chat provider the strategy calls on the host's behalf

Empty means the strategy runs anywhere, at $0. Since 9.5.0 defineMemory refuses at build when a declared requirement is absent — including inside a HYBRID, so a composite cannot smuggle in a strategy this deployment cannot run.

kindRequiresAccepted by typesChoose it when
WINDOWepisodic, semantic, narrativeThe right default for short-to-medium chats: keep the last size entries, no LLM, nothing to configure but the number
BUDGETepisodicYou care about tokens: inject as many recent entries as fit, and none at all below the floor. The pick/skip decision is recorded as branch evidence
DECAYepisodicA long-running agent should stop rehearsing last month. Arithmetic on a timestamp — free
EXTRACT— (extractor: 'llm' additionally needs llm)semantic, narrativeYou want structured facts or narrative beats distilled on the write side
TOP_Kembedder, vector-storesemantic, causalSimilarity recall above a threshold — and strictly: when nothing clears it, nothing is injected
HYBRID— (each sub-strategy carries its own)episodic, semantic, narrativeSeveral rules on one store
SUMMARIZEllm, modelepisodicRecall outgrew the window: keep the newest recent verbatim, fold the rest with one call, and store the summary so the call is paid once

Offer only what a deployment can run:

import { listMemoryStrategies } from 'agentfootprint/memory';

const available = new Set(embedder ? ['embedder', 'vector-store'] : []);
const offerable = listMemoryStrategies().filter(
  (s) => s.types.includes('episodic') && s.requirements.every((r) => available.has(r)),
);
// → window, budget, decay, hybrid — the four that cost nothing to run.

memoryStrategyInfo(kind) returns one descriptor, or undefined for a string that is not a strategy at all.

SUMMARIZE declares TWO requirements (9.14.0)

llm and model. They are separate because a deployment can hold a provider and still have no answer for which model compression should run on — and a library that picked one for you would be picking your invoice. There is no fallback to the agent's own model, and Agent.memory() refuses a summarizer that is the agent's own provider instance at the agent's own model.

What the strategy does with them: keep the newest recent entries verbatim, fold everything older into one summary entry, and write that entry back to this store under msg-summary-{fromTurn}-{toTurn} — so a span costs one call in the life of the conversation, not one per turn. The folded originals are not deleted; they are excluded from recall by the summary's coverage metadata, and they age out under the same TTL they always had. Through 9.13.0 this strategy required an llm and never called it; the description in listMemoryStrategies() said so, and now says what it does instead.

Axis B — the service's own extraction, targeted by opaque ids

A managed backend can do its own recall, and then the strategy is not a rule this library runs — it is a resource someone configured on the service, addressed by an id this library never interprets.

AgentCoreStore is the shipped example. AgentCore organises the records its extraction strategies derive (semantic, summary, user-preference…) into namespaces on the Memory resource, and two options steer search() at them:

OptionWhat it isDefault
searchNamespace({ actorId, sessionId })Where search() looks — a function, because the namespace usually contains the actor. Commonly shaped like /strategies/{strategyId}/actors/{actorId}/actors/{actorId}/sessions/{sessionId} — the session's own records
searchStrategyIdRestrict search() to one extraction strategy. Omit to search across all of themomitted

Both are opaque: they reach AgentCore's own side as a filter, and this library never parses, validates or reasons about them. What comes back are records AgentCore derived, not the entries you put()k, minScore and tiers from SearchOptions are applied to what arrives.

Choose-when

Your situationAxisReach for
No embedder, no LLM, and a chat that needs recent contextAWINDOW, or BUDGET if tokens are the constraint
A long-lived assistant that should forget gracefullyADECAY
Similarity recall over facts you wroteATOP_K + an embedder + a vector-ranking store
Several rules over one storeAHYBRID — every sub-requirement is still checked at build
The backend already extracts summaries and preferences for youBAgentCoreStore + searchStrategyId / searchNamespace
A managed knowledge base that takes the question as wordsBA store declaring ranksBy: 'server-text' and defineRAG with no embedder
You are building a picker for someone else to choose fromeitherlistMemoryStrategies() — filter on requirements and types

InMemoryStore

The default for dev / tests. O(n) linear scan for search(); fine for thousands of entries; bounded by process memory:

import { InMemoryStore } from 'agentfootprint/memory';
const store = new InMemoryStore();

Resets on process restart. Per-process — multi-instance deploys lose state.

For a RAG corpus that restart is not a small thing: it means re-embedding every document on every boot. That is what sqliteVectorStore exists to remove.

sqliteVectorStore — a corpus in a file

Zero dependencies: SQLite is inside Node. One file, exact cosine search, and the vectors still there on the next boot.

import { sqliteVectorStore } from 'agentfootprint/memory';
import { staticEmbedder } from 'agentfootprint/providers';
import { indexDocuments, defineRAG } from 'agentfootprint';

const store = sqliteVectorStore({ file: './corpus.db' });
const embedder = staticEmbedder();

// First boot indexes. Every boot after this finds the vectors already there.
await indexDocuments(store, embedder, docs, { embedderId: embedder.id });

// Optional: pay the hydration cost at boot instead of on the first question.
const { count, durationMs } = await store.warm({ conversationId: '_global' });

const agent = Agent.create({ provider })
  .rag(defineRAG({ id: 'docs', store, embedder, embedderId: embedder.id }))
  .build();

The two-phase cost model

Embedding cost is not one number, and the split is the whole argument for a file:

  • Index time embeds the corpus. Once. Cost scales with how much you store.
  • Query time embeds the user's question. Per retrieval. Cost scales with traffic.

A 10,000-chunk corpus is 10,000 embeddings once and one per question thereafter. With a Map it is 10,000 embeddings per restart. Both halves are reportable — agentfootprint.embedding.generated carries inputKind: 'document' | 'query':

agent.on('agentfootprint.embedding.generated', (e) => {
  console.log(e.payload.inputKind, e.payload.count, e.payload.durationMs);
});

indexDocuments runs at startup, outside any run, so it has no emit channel to ride. It hands you the same payload through onEmbedding instead.

Exact search, and the ceiling said out loud

Vectors are hydrated into one resident Float32Array matrix on the first search of a namespace, normalised, and every later query is an exact dot product. No approximate index and no pretence of one — it returns the true top-K or it does not answer.

Measured on Node 22.16, Apple silicon, median of five queries:

corpusqueryresident matrixfilefirst search (hydration)
10,000 × 384-d6 ms15 MB21 MB45 ms
50,000 × 384-d31 ms77 MB105 MB251 ms
100,000 × 384-d65 ms154 MB211 MB939 ms
10,000 × 1536-d16 ms61 MB83 MB122 ms
50,000 × 1536-d89 ms307 MB413 MB5.7 s

The documented ceiling is 50,000 chunks. Below it every query is under 100 ms at every shipped embedder and the matrix is under ~300 MB. It degrades linearly to about 100,000. Above that, move to a managed vector database — MemoryStore is the seam and nothing else in your code changes. For scale intuition: 50,000 chunks at ~1,000 characters is roughly 50 MB of text, on the order of 25,000 pages.

A recommendation, not an enforced limit

Nothing in the store counts chunks, refuses a write at 50,000, or degrades on purpose past it — chunk 50,001 is stored and searched exactly like chunk 3. The number is where the measured curve stops making this obviously the right tool, published so the decision is yours and dated rather than discovered in production. What the store does refuse is named above and below: a missing node:sqlite, an unreadable file, a schema mismatch, ':memory:', and an embedder-fingerprint conflict.

Hydration is the number to plan around, not the query. Steady-state search is fast everywhere in that table; reading the vectors off disk the FIRST time is what costs. Left lazy, that bill lands on whoever asks the first question after a deploy. store.warm(identity) moves it somewhere you chose. Smaller vectors are dramatically cheaper here — 384 dimensions hydrates 100,000 chunks in under a second — which is one more reason a 384-dimension embedder is the better default for a corpus this size.

One embedder per namespace, refused both ways

The store records a fingerprint — '<id>@<dims>' — for each namespace, from the first vector written to it. A vector from a different embedding space is refused at write and a query from one is refused at search, with EmbedderMismatchError.

This is not caution. Cosine similarity between two embedding spaces is not a weak signal — it is not a signal, and it comes back as a confident number in the same range as a real one, which no threshold separates and nothing downstream can detect.

store.fingerprintOf({ conversationId: '_global' });  // 'static:@yarflam/potion-base-8m@256'

The named fix is an explicit re-index: await store.forget(identity) and build it again with one embedder, or point the retriever at a different file. It is never a fallback — silently mixing corrupts every ranking it touches, and silently re-embedding your corpus is a bill you did not agree to.

Dimensions always decide; model ids decide only when both sides named themselves, so a caller who never passes an embedderId is not blocked by a name nobody supplied.

What it refuses, and why it never falls back

SituationWhat happens
Node has no node:sqlite (Node 20)SqliteUnavailableError, naming your version, the 22.5 floor, the --experimental-sqlite flag, and InMemoryStore
file: ':memory:'TypeError pointing at InMemoryStore — it says so in its name
The file is not a databaseUnreadableIndexFileError (problem: 'cannot-open')
Someone else's af_vectors tableUnreadableIndexFileError (problem: 'not-our-schema')
Written by a newer agentfootprintUnreadableIndexFileError (problem: 'newer-schema')
A second embedderEmbedderMismatchError

None of these fall back to an empty index, because an unreadable index and an empty one are different facts, and only one of them is safe to answer with "no matches". A store that opened a corrupt file as empty would answer every question from the model's own weights and log nothing.

Reference

NameWhat it is
sqliteVectorStore(options)The factory. Returns a SqliteVectorStore.
SqliteVectorStoreThe store: every MemoryStore method, plus journalMode, file, fingerprintOf(identity), warm(identity) and close().
SqliteVectorStoreOptions{ file, busyTimeoutMs? }.
UnreadableIndexFileErrorThe file exists but cannot be used — problem is 'cannot-open' / 'not-our-schema' / 'newer-schema'.
EmbedderMismatchErrorA second embedding space met this namespace — indexed, incoming, and problem ('dimensions' / 'model').
SqliteUnavailableErrorThis Node has no node:sqlite. The same class sqliteSessions raises.
SqliteVectorDatabaseLike · SqliteVectorStatementLike · SqliteVectorModuleLikeThe slice of node:sqlite the adapter calls, declared locally so nothing takes a hard import on a module that is missing on Node 20.

The ceiling on concurrency

One process on one machine, writing one file. It survives a restart, a crash, a deploy. It is not distributed: WAL gives one writer and many readers at once, and a second writer waits up to busyTimeoutMs before failing loudly. store.journalMode reports what the file actually got — a network filesystem can silently downgrade it, and that is only ever discovered under load.

putMany is one transaction. The port does not require that, but a half-indexed corpus is a specific kind of bad: retrieval keeps working and quietly cannot see what did not land, which reads as "the model does not know that" rather than as a failure.

pgVectorStore — a corpus beside your own data

Postgres is the database most teams already run and pgvector is an extension away. A corpus that lives in it inherits the backups, the failover, the access control and the migrations you already have.

pg is an optional peer dependency, required at the first call — pass client to reuse the pool your app already has, which is the recommended shape (a second pool to one database is a second set of connections nobody counted).

import { Pool } from 'pg';
import { pgVectorStore } from 'agentfootprint/memory';
import { openaiEmbedder } from 'agentfootprint/providers';
import { indexDocuments, defineRAG } from 'agentfootprint';

const store = pgVectorStore({ client: new Pool({ connectionString: process.env.DATABASE_URL }) });
const embedder = openaiEmbedder();

await indexDocuments(store, embedder, docs, { embedderId: embedder.id });

const agent = Agent.create({ provider })
  .rag(defineRAG({ id: 'docs', store, embedder, embedderId: embedder.id }))
  .build();

The table, and why this does not create it

A vector(N) column fixes N at creation, and N is a fact about your embedder. Creating the table implicitly would pick that number — and the index type, and the operator class — on your behalf, in a migration you never reviewed. So the schema is yours to run, and a missing table is refused (PgVectorSchemaError) rather than read as an empty corpus:

CREATE EXTENSION IF NOT EXISTS vector;

-- 1024 = your embedder's dimensions. bedrockEmbedder() default: 1024.
--        openaiEmbedder() default: 1536. staticEmbedder(): 256.
CREATE TABLE af_vectors (
  namespace        TEXT    NOT NULL,
  id               TEXT    NOT NULL,
  value            JSONB   NOT NULL,
  metadata         JSONB,
  embedding        vector(1024),
  embedder_fp      TEXT,
  version          INTEGER NOT NULL,
  created_at       BIGINT  NOT NULL,
  updated_at       BIGINT  NOT NULL,
  last_accessed_at BIGINT  NOT NULL,
  access_count     INTEGER NOT NULL,
  ttl              BIGINT,
  tier             TEXT,
  source           JSONB,
  embedding_model  TEXT,
  PRIMARY KEY (namespace, id)
);

-- Cosine, because that is the score this port reports and every threshold in
-- this library is calibrated on. Match the operator class to the metric.
CREATE INDEX af_vectors_hnsw ON af_vectors USING hnsw (embedding vector_cosine_ops);
CREATE INDEX af_vectors_ns ON af_vectors (namespace);

-- Recognition (seen/recordSignature), usefulness feedback, and the
-- per-namespace embedder fingerprint.
CREATE TABLE af_signatures (
  namespace TEXT NOT NULL, signature TEXT NOT NULL,
  PRIMARY KEY (namespace, signature)
);
CREATE TABLE af_feedback (
  namespace TEXT NOT NULL, id TEXT NOT NULL,
  total DOUBLE PRECISION NOT NULL, count INTEGER NOT NULL,
  PRIMARY KEY (namespace, id)
);
CREATE TABLE af_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);

Every table and column name above is an option with that value as its default (schema, table, signaturesTable, feedbackTable, metaTable, columns), so this drops into a schema that already has naming conventions. Identifiers are validated and quoted — a name that is not a plain SQL identifier is refused rather than interpolated.

search is 1 - (embedding <=> $query::vector) — pgvector's cosine distance turned into the cosine similarity the port reports. <-> (L2) and <#> (inner product) are deliberately not options: their ranges are not that range.

One statement at a time, on purpose. A pg.Pool hands each query() its own connection, so BEGIN on one and the next statement on another is a transaction that silently is not one. Everything that must be atomic here is ONE statement: putMany is one multi-row upsert, putIfVersion is one conditional upsert, and forget is one statement with CTEs across all four tables.

s3VectorsStore — a corpus you can add to at 14:00

sqliteVectorStore made a corpus survive a restart; a corpus bundle made it survive a runtime with no disk. Both leave the same gap: a bundle can only change when you redeploy. Amazon S3 Vectors closes it — object storage with a native vector index and a query API, priced like storage rather than like a database cluster.

import { s3VectorsStore } from 'agentfootprint/memory';
import { bedrockEmbedder } from 'agentfootprint/providers';
import { indexDocuments, defineRAG } from 'agentfootprint';

const store = s3VectorsStore({ bucket: 'my-corpus', index: 'docs', region: 'us-east-1' });
const embedder = bedrockEmbedder({ region: 'us-east-1' });

// Run this from a cron job. No deploy, no restart — the agent sees it next turn.
await indexDocuments(store, embedder, newDocs, { embedderId: embedder.id });

search() maps 1:1 onto QueryVectors (ktopK, tiers → a metadata filter, distance → the cosine score); put/putMany map onto PutVectors, which is what lets the corpus builders run against it unchanged.

Create the index first — this store never does

A vector index has a dimension and a distance metric fixed at creation, and both are decisions about your embedder:

aws s3vectors create-vector-bucket --vector-bucket-name my-corpus
aws s3vectors create-index \
  --vector-bucket-name my-corpus \
  --index-name docs \
  --data-type float32 \
  --dimension 1024 \
  --distance-metric cosine \
  --metadata-configuration '{"nonFilterableMetadataKeys":["af"]}'

nonFilterableMetadataKeys: ["af"] is load-bearing. This adapter stores the whole entry — the passage, its provenance, its timestamps — as JSON under the metadata key af. Filterable metadata has a small per-vector budget; non-filterable has the large one. Leave af filterable and PutVectors starts refusing your longer chunks partway through an indexing run.

Only ns (the identity namespace) and tier are ever filtered on.

The store reads your index before it trusts it (9.4.0)

On its first call — once, memoized — the store issues one GetIndex and refuses anything it cannot serve honestly:

It checksIt refuses whenWhy it is fatal
distanceMetric is cosinethe index says euclideana self-query scored 0.9991630113800056 against a euclidean index, where a true cosine is exactly 1.0. A number that reads like a cosine and is not one — no threshold (starting with defineRAG's 0.7) can separate it from a real score.
af is non-filterablethe index does not declare itotherwise the import writes the short chunks and then fails on a longer one with AWS's "Filterable metadata must have at most 2048 bytes"partway through, having already reported success for everything before it. The refusal happens before the first byte.
dimension matches the embedderthe vectors are another lengththe service would reject each write anyway, one at a time, saying nothing about which embedder is wrong. This names both numbers, and catches the first search of a fresh process — which the per-process fingerprint cannot.

Before 9.4.0 this store dispatched only PutVectors and QueryVectors and never looked at the index. The euclidean check read options.distanceMetric — a claim by the caller about an index the store did not create — so leaving it undefined (the default) sailed straight through. A production deployment hit all three at once.

New IAM permission

The caller now needs s3vectors:GetIndex alongside PutVectors / QueryVectors / GetVectors / ListVectors / DeleteVectors. A GetIndex that fails is refused by name — naming the index, and telling you to check the permission, the region and the names — never passed through, because an index this store cannot read is an index whose metric, layout and dimension it would be guessing at.

This is the discipline the sibling stores already had at open — sqliteVectorStore's schema identity, pgVectorStore's schema check, staticVectorStore's load-time fingerprint. This store had nothing to open, so it opened nothing.

What a vector index is not

A vector index is not a key-value store, and this adapter refuses the operations that would need one rather than faking them: putIfVersion (PutVectors is last-write-wins — there is no compare-and-set), recordSignature and feedback. Their read halves answer truthfully — seen() is false and getFeedback() is null, because nothing can be recorded, so nothing has been. Pair it with a second store for conversation memory: defineRAG for the corpus, defineMemory for the chat.

RedisStore

Subpath import keeps the main barrel small. Lazy-required ioredis:

import { RedisStore } from 'agentfootprint/memory';

const store = new RedisStore({ url: 'redis://localhost:6379' });
// or share an existing client:
// const store = new RedisStore({ client: existingIoredisClient });

Implements every MemoryStore method except search(). putIfVersion uses an atomic Lua compare-and-swap; putMany uses pipelining; forget() uses SCAN (never KEYS — KEYS blocks Redis). Tested with mock-injected _client; CI doesn't need a live Redis.

Vector search NOT included. RedisSearch is a separate Redis module with its own API. A RedisSearchStore may ship in a future release.

AgentCoreStore

AWS Bedrock AgentCore Memory adapter. Subpath import; lazy-required AWS SDK:

import { AgentCoreStore } from 'agentfootprint/memory';

const store = new AgentCoreStore({
  memoryId: 'arn:aws:bedrock-agentcore:us-west-2:...:memory/my-mem',
  region: 'us-west-2',
});

Maps MemoryStore onto AgentCore's append-only event log (CreateEvent / ListEvents / DeleteEvent on @aws-sdk/client-bedrock-agentcore). Caveats (also in JSDoc):

  • put appends; list is ListEvents (window / episodic memory is the natural fit); get/delete by id are list-then-find (server-assigned event ids); forget lists + deletes each event (no DeleteSession).
  • putIfVersion is emulated client-side; seen / feedback are in-process shadow state — don't survive process restart. Use Redis for durable recognition.
  • search() is wired (since 7.15) onto RetrieveMemoryRecords — but it takes the query as text in options.text, not the vector, because AgentCore embeds and ranks server-side. Omit the text and it throws rather than returning an empty list that reads like "no matches". What comes back is AgentCore's own extracted records, not the entries you put(). Full story: AgentCore Memory.

BedrockAgentMemory (legacy Bedrock Agents — read-only)

AWS has two memory systems. AgentCoreStore above targets the newer AgentCore platform (the go-forward path). The prior-generation Bedrock Agents product has its own memory — but it's read-only: the agent auto-generates SESSION_SUMMARY records; you can read and delete them, but you can't put arbitrary entries. So it's not a MemoryStore (that would be a "store that can't store") — it's a small reader:

import { BedrockAgentMemory } from 'agentfootprint/memory';

const mem = new BedrockAgentMemory({ agentId, agentAliasId, region: 'us-west-2' });

const summaries = await mem.readSummaries(userMemoryId); // the summaries Bedrock generated
const text = await mem.readText(userMemoryId);           // joined, ready to inject as a Fact
await mem.forget(userMemoryId);                          // DeleteAgentMemory

Use it to surface Bedrock's built-in memory in an agentfootprint agent (e.g. inject text as a Fact). Peer-dep @aws-sdk/client-bedrock-agent-runtime. Prefer AgentCoreStore for a real read/write storeBedrockAgentMemory exists mainly for teams migrating off Bedrock Agents.

Choosing per layer

In hybrid memory setups (multiple .memory(...) calls on one agent), each layer can use a different store optimized for its access pattern:

import { defineMemory, MEMORY_TYPES, MEMORY_STRATEGIES } from 'agentfootprint/memory';
import { RedisStore, AgentCoreStore } from 'agentfootprint/memory';

const recent = defineMemory({
  id: 'recent',
  type: MEMORY_TYPES.EPISODIC,
  strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
  store: new RedisStore({ url: redisUrl }),  // sub-ms hot reads, TTL
});

const causal = defineMemory({
  id: 'causal',
  type: MEMORY_TYPES.CAUSAL,
  strategy: { kind: MEMORY_STRATEGIES.TOP_K, topK: 1, threshold: 0.7, embedder },
  store: new AgentCoreStore({ memoryId, region }),  // managed durability
});

agent.memory(recent).memory(causal);

See Auto memory (hybrid) for the layered pattern.

Planned

StoreWhy it's planned
DynamoDBAWS-native, globally distributed, serverless-friendly
Pinecone / Qdrant / WeaviateVector-first; serious-scale RAG / semantic-retrieval workloads
RedisSearchStoreVector search via the RedisSearch module

All three follow the same peer-dep + subpath import pattern as every store above. (Postgres + pgvector was on this list until 9.3.0 — it ships as pgVectorStore.)

Reference — the two vector adapters added in 9.3.0

NameWhat it is
pgVectorStore(options?)The factory. Returns a PgVectorStore — every MemoryStore method, plus fingerprintOf(identity) and close().
PgVectorStoreOptions{ connectionString?, client?, schema?, table?, signaturesTable?, feedbackTable?, metaTable?, columns?, batchSize? }.
PgVectorColumnsPer-column overrides, each defaulting to the documented name.
PgVectorSchemaErrorThe database is reachable but its schema is not this store's — carries missingColumns.
PgLikeClient · PgQueryResult · PgSdkModuleThe slice of pg the adapter calls, declared locally so nothing takes a hard import on an optional peer.
s3VectorsStore(options)The factory. Returns an S3VectorsStore — every MemoryStore method it can honour, plus bucket, index, fingerprintOf(identity) and close().
S3VectorsStoreOptions{ bucket, index, region?, distanceMetric?, batchSize?, client? }.
S3VectorsLikeClient · S3VectorsSdkModuleThe slice of @aws-sdk/client-s3vectors the adapter calls.
EmbedderMismatchErrorShared by all three vector stores since 9.3.0 — catching it does not depend on which store threw.

Multi-tenant identity is enforced at the store

Every MemoryStore method takes MemoryIdentity as the first arg. Adapters MUST namespace internal keys by the full tuple. A bug passing the wrong identity surfaces as "no data" — not as a cross-tenant leak.

Status

PieceDoorPeer depStatus
MemoryStore port + supportsVectorSearch / ranksBy capability bitsagentfootprint/memoryShipped
InMemoryStore, staticVectorStoreagentfootprint/memoryShipped
sqliteVectorStoreagentfootprint/memorynone (node:sqlite, Node ≥ 22.5)Shipped
pgVectorStoreagentfootprint/memorypg + the vector extensionShipped (9.3.0)
RedisStoreagentfootprint/memoryioredisShipped; tested through an injected _client
s3VectorsStoreagentfootprint/memory@aws-sdk/client-s3vectorsShipped (9.3.0); contract-mapped and injection-tested, command names pinned
AgentCoreStoreagentfootprint/memory@aws-sdk/client-bedrock-agentcoreShipped; contract-mapped and injection-tested, command names pinned
BedrockAgentMemory (reader, not a store)agentfootprint/memory@aws-sdk/client-bedrock-agent-runtimeShipped; contract-mapped and injection-tested
listMemoryStrategies() / build-time requirement refusalagentfootprint/memoryShipped (9.5.0)
SUMMARIZE LLM compressionDeclared, not wired — see the caveat above
RedisSearchStore, DynamoDB, Pinecone / Qdrant / WeaviatePlanned

Next steps

On this page