@cogitator-ai/memory
v0.29.0
Published
Memory adapters for Cogitator AI agents
Maintainers
Readme
@cogitator-ai/memory
Memory adapters for Cogitator AI agents. Supports in-memory, Redis (short-term), PostgreSQL with pgvector, SQLite and MongoDB (persistent), plus in-memory/pgvector/Qdrant embedding stores for semantic memory.
Installation
pnpm add @cogitator-ai/memory
# Install the driver of the store you use (optional peer dependencies)
pnpm add ioredis # For Redis adapter
pnpm add pg # For PostgreSQL adapter and graph adapter
pnpm add better-sqlite3 # For SQLite adapter, SQLite graph adapter and CoreFactsStore
pnpm add mongodb # For MongoDB adapter
pnpm add @qdrant/js-client-rest # For Qdrant embedding adapterDatabase drivers are not installed with the package (nor with @cogitator-ai/core): add the one your adapter needs. An adapter whose driver is missing fails connect() with the command to install it. The in-memory adapters need no driver.
With @cogitator-ai/core you usually configure memory on the runtime (new Cogitator({ memory: { adapter: 'postgres', postgres: { ... } } })) and read it with await cog.getMemory(); the adapters below are for direct use. On the runtime, memory.contextBuilder without a strategy uses hybrid when memory.embedding is set and recent otherwise, and compaction, extraction, memory files, the brief, observations and consolidation are off until configured, see Memory benchmarks for the measurements behind these defaults. Website docs: Memory, Adapters, Embeddings, Hybrid Search, Knowledge Graphs.
Features
- Multiple Adapters - In-memory, Redis (standalone + cluster), PostgreSQL with pgvector, SQLite, MongoDB, Qdrant
- Thread Management - Create (idempotent upsert), update, delete conversation threads
- Stable Ordering - Entries keep insertion order even when saved within the same millisecond
- Threads as Trees - Entries branch from any point, with typed session events, paged branch reads, rewind, checkout and thread forks
- Lineage - Check that derived facts and summaries quote what the conversation actually said
- Token Counting - Estimate token usage without tiktoken dependency
- Context Builder - Token-aware context with
recent,relevant(embedding-ranked) andhybrid(recent history plus recall of older turns and memory items by a multi-channel search) strategies - Scoped Semantic Memory - Embeddings tagged with
metadata.agentId/metadata.threadIdnever leak to other agents - Sessions & Compaction -
SessionManagerfor channel sessions,CompactionServicefor summarizing long histories, hierarchically into grounded atoms that a compaction never loses while they are open - Embedding Services - OpenAI, Ollama and Google embedding integration
- Semantic Search - Vector similarity search with pgvector
- Hybrid Search - BM25 + Vector with Reciprocal Rank Fusion
- Memory Items - What conversations established, extracted by a model, grounded in quotes, trusted by who said them, kept with when they held (a newer state supersedes the old one and keeps it as history), on every built-in adapter
- Memory Retriever - The multi-channel search of
hybridrecall as its own class (MemoryRetriever,analyzeQuery), for tools that search memory the same way - Memory Files - Notes an agent keeps under
/memorieswith the commands of Anthropic's memory tool, every change a version, seen per branch, with optimistic locking, read-only access and redaction, on every built-in adapter - Memory Upkeep - A pre-turn brief of cards that quote their sources (
MemoryBriefer), observations of entities kept as memory files (MemoryObservations), and consolidation that marks repeats stale and folds lessons into a playbook a few edits at a time (MemoryConsolidator) - Lineage Cascades - Deleting, redacting or rewinding entries takes what stands on them out of every store: summaries, memory items, facts, graph nodes and edges, vectors, memory file versions, observations and playbook rules (
MemoryCascade,redactEntryon every built-in adapter) - Facts Storage - Store and retrieve agent knowledge (
FactAdapteron PostgreSQL,CoreFactsStoreon SQLite, both kept as memory items) - Knowledge Graph - Entity-relationship memory with multi-hop traversal
- Zod Schemas - Type-safe configuration validation
Quick Start
import { InMemoryAdapter, ContextBuilder, unwrap } from '@cogitator-ai/memory';
const memory = new InMemoryAdapter();
await memory.connect();
const thread = unwrap(await memory.createThread('agent-1', { topic: 'greeting' }));
await memory.addEntry({
threadId: thread.id,
message: { role: 'user', content: 'Hello!' },
tokenCount: 2,
});
await memory.addEntry({
threadId: thread.id,
message: { role: 'assistant', content: 'Hi there!' },
tokenCount: 3,
});
const builder = new ContextBuilder(
{ maxTokens: 4000, strategy: 'recent' },
{ memoryAdapter: memory }
);
const context = await builder.build({
threadId: thread.id,
agentId: 'agent-1',
systemPrompt: 'You are helpful.',
});
console.log(context.messages);Adapter calls return a MemoryResult — { success: true, data } or { success: false, error } — instead of throwing. unwrap(result) returns the data or throws an Error with the adapter's message; check result.success yourself where a failure is expected:
const result = await memory.getThread('thread_123');
if (!result.success) {
console.error('Memory unavailable:', result.error);
} else if (result.data) {
console.log(result.data.metadata);
}Memory Adapters
In-Memory Adapter
Fast, non-persistent storage for development and testing.
import { InMemoryAdapter } from '@cogitator-ai/memory';
const memory = new InMemoryAdapter({
provider: 'memory',
maxEntries: 1000,
});
await memory.connect();maxEntries (default 10000) counts each thread on its own. A full thread drops the oldest entry of a branch that is not active first, then the oldest of the active branch. healthCheck() returns immediate success.
Redis Adapter
Threads and entries in Redis. Nothing expires by default (ttl: 0), earlier versions expired keys after 24 hours, so pass ttl: 86400 to keep that. With a ttl, every write refreshes the TTL of the thread and its entry index, so active conversations do not expire mid-way, and expired entries are pruned from the index on read. getEntries({ limit }) reads only the newest limit entries. A failed connect() closes its client again, so an unreachable Redis does not keep the process alive.
import { RedisAdapter } from '@cogitator-ai/memory';
const memory = new RedisAdapter({
provider: 'redis',
url: 'redis://localhost:6379',
keyPrefix: 'cogitator:',
ttl: 3600,
});
await memory.connect();Redis Cluster is supported via cluster: { nodes: [{ host, port }] }. In cluster mode the key prefix is wrapped in a hash tag ('{cogitator}:' by default, 'myapp:' becomes '{myapp}:') so all keys of the adapter live in one slot. healthCheck() runs client.ping() to verify connectivity.
PostgreSQL Adapter
Long-term storage with vector search via pgvector.
import { PostgresAdapter } from '@cogitator-ai/memory';
const memory = new PostgresAdapter({
provider: 'postgres',
connectionString: 'postgresql://localhost:5432/cogitator',
schema: 'public',
poolSize: 10,
dimensions: 1536, // vector size of the embedding model, default 768
});
await memory.connect();
memory.vectorStatus(); // { available: true, dimensions: 1536 } or { available: false, reason }On connect() the adapter creates its tables and an HNSW cosine index on the embeddings, which works from the first row on (no training data needed). An ivfflat index left by an earlier version is replaced on the first connect after upgrading, see the adapters docs for building the new index ahead of time on a large table. search() raises hnsw.ef_search to the requested limit and, on pgvector 0.8+, uses iterative scans, so large limits and filters still get every matching row. Vectors of 2001 to 4000 dimensions are indexed as halfvec (pgvector 0.7+), larger ones are searched without an index. The schema version is kept in <schema>.cogitator_schema_migrations, so a connect() changes tables only after an upgrade. Version 3 moves every row of facts into a memory item of its own slot and renames the table facts_legacy. Version 4 adds the memory_file_versions table of memory files, created empty on the first connect() after upgrading, with nothing migrated. Messages, tool calls, tool results and metadata are stored as jsonb.
pgvector is only needed for embeddings. Without it, connect() still succeeds and threads, entries and facts work, while embedding operations return a failed result naming the reason (also logged at connect). An existing embeddings table keeps its vector size: an adapter without dimensions adopts it, one configured for another size reports the mismatch through vectorStatus() and the failed results instead of failing each search with a Postgres error. healthCheck() runs SELECT 1 with a 3-second driver-level query timeout so stalled checks do not occupy connections indefinitely.
SQLite Adapter
import { SQLiteAdapter } from '@cogitator-ai/memory';
const memory = new SQLiteAdapter({
provider: 'sqlite',
path: './cogitator.db', // ':memory:' for an in-memory database
walMode: true,
});
await memory.connect();healthCheck() runs a lightweight SELECT 1 statement.
MongoDB Adapter
import { MongoDBAdapter } from '@cogitator-ai/memory';
const memory = new MongoDBAdapter({
provider: 'mongodb',
uri: 'mongodb://localhost:27017',
database: 'cogitator',
});
await memory.connect();Threads and entries live in the <collectionPrefix>threads and <collectionPrefix>entries collections (prefix memory_ by default), indexed on agentId and (threadId, createdAt). Fields left undefined are omitted from the stored documents, not stored as null. A failed connect() leaves the adapter disconnected, so a later connect() retries. healthCheck() executes { ping: 1 } via db.command().
Qdrant Adapter (Embedding)
import { QdrantAdapter } from '@cogitator-ai/memory';
const embeddings = new QdrantAdapter({
provider: 'qdrant',
url: 'http://localhost:6333',
collection: 'cogitator',
dimensions: 1536,
});
await embeddings.connect();QdrantAdapter is an EmbeddingAdapter (store and search vectors), not a thread store; pair it with one of the adapters above. Points are stored under a deterministic UUID derived from the embedding id (Qdrant accepts only integer and UUID point ids); the emb_… id stays in payload.embeddingId and is what search returns.
Factory Function
import { createMemoryAdapter, createEmbeddingAdapter } from '@cogitator-ai/memory';
const memory = await createMemoryAdapter({ provider: 'memory' });
const redis = await createMemoryAdapter({
provider: 'redis',
url: 'redis://localhost:6379',
});
const postgres = await createMemoryAdapter({
provider: 'postgres',
connectionString: 'postgresql://localhost:5432/db',
});
const sqlite = await createMemoryAdapter({
provider: 'sqlite',
path: './cogitator.db',
});
const qdrant = await createEmbeddingAdapter({
provider: 'qdrant',
dimensions: 1536,
});
await postgres.connect(); // the factories create adapters; connect them before use
await qdrant.connect(); // typed as ConnectableEmbeddingAdapter, no cast neededcreateMemoryAdapter loads the adapter's driver lazily, so only the drivers you use need to be installed.
MemoryAdapter Interface
All adapters implement the MemoryAdapter interface:
interface MemoryAdapter {
readonly provider: MemoryProvider;
connect(): Promise<MemoryResult<void>>;
disconnect(): Promise<MemoryResult<void>>;
healthCheck?(): Promise<MemoryResult<void>>;
createThread(agentId: string, metadata?: Record<string, unknown>): Promise<MemoryResult<Thread>>;
getThread(threadId: string): Promise<MemoryResult<Thread | null>>;
updateThread(threadId: string, metadata: Record<string, unknown>): Promise<MemoryResult<Thread>>;
deleteThread(threadId: string): Promise<MemoryResult<void>>;
listThreads(options?: ListThreadsOptions): Promise<MemoryResult<ThreadPage>>;
addEntry(entry: NewMemoryEntry): Promise<MemoryResult<MemoryEntry>>;
getEntries(options: MemoryQueryOptions): Promise<MemoryResult<MemoryEntry[]>>;
getEntry(entryId: string): Promise<MemoryResult<MemoryEntry | null>>;
deleteEntry(entryId: string): Promise<MemoryResult<void>>;
clearThread(threadId: string): Promise<MemoryResult<void>>;
getPath?(options: PathQueryOptions): Promise<MemoryResult<MemoryEntry[]>>;
rewind?(threadId: string, toEntryId: string): Promise<MemoryResult<Thread>>;
checkout?(threadId: string, entryId: string): Promise<MemoryResult<Thread>>;
forkThread?(
threadId: string,
atEntryId: string,
newThreadId?: string
): Promise<MemoryResult<Thread>>;
redactEntry?(
entryId: string,
redaction: EntryRedaction
): Promise<MemoryResult<MemoryEntry | null>>;
}The branch methods, redactEntry() and healthCheck() are optional on the interface, and every built-in adapter implements them. Custom adapters can implement healthCheck(): Promise<MemoryResult<void>> to participate in server readiness checks (GET /ready). An adapter without healthCheck() reports unchecked and does not fail readiness. Built-in adapters deduplicate concurrent health checks so at most one check is in flight per adapter.
Thread Operations
createThread(agentId, metadata, threadId) with an existing threadId is an upsert: entries and createdAt are kept and only the metadata is updated, so it is safe to call on every run.
const thread = unwrap(
await memory.createThread('agent-1', {
topic: 'support',
user: 'user-123',
})
);
const found = unwrap(await memory.getThread(thread.id)); // Thread | null
await memory.updateThread(thread.id, {
resolved: true,
});
await memory.deleteThread(thread.id);listThreads pages through threads, the most recently updated first, filtered by agent, metadata values (compared by type) and update time. Pass nextCursor back for the next page:
const page = unwrap(
await memory.listThreads({ agentId: 'agent-1', metadata: { user: 'user-123' }, limit: 20 })
);
const next = page.nextCursor
? unwrap(await memory.listThreads({ agentId: 'agent-1', cursor: page.nextCursor }))
: undefined;A thread's updatedAt changes when it is created or its metadata is updated. Every adapter keeps the same contract: addEntry into a missing thread fails, updateThread is atomic, entries with one createdAt keep the order they were added in, fields never set come back absent rather than null, and returned objects are copies. memoryAdapterConformanceCases in @cogitator-ai/test-utils checks it, also for adapters of your own.
Entry Operations
const entry = unwrap(
await memory.addEntry({
threadId: thread.id,
message: { role: 'user', content: 'Hello' },
tokenCount: 10,
})
);
const entries = unwrap(
await memory.getEntries({
threadId: thread.id,
limit: 50, // the most recent 50, oldest first, 0 for all
includeToolCalls: true,
// before / after: Date bounds
})
);
const single = unwrap(await memory.getEntry(entry.id));
await memory.deleteEntry(entry.id);
await memory.clearThread(thread.id);getEntries reads every branch of the thread and returns only message and tool_result entries unless types asks for more (types: 'all'). deleteEntry splices the entry out of its branch: its children go after its parent.
Threads as Trees
A thread's entries form a tree: each entry has a parentId (null for the first one), a seq and a type, and the thread's activeLeafId names the entry its active branch ends at. addEntry goes after the active leaf and moves it there in one step. An explicit parentId writes on another branch, and activate: false leaves the leaf where it is:
const question = unwrap(
await memory.addEntry({
threadId: thread.id,
message: { role: 'user', content: 'Plan a day in Rome' },
tokenCount: 6,
})
);
await memory.addEntry({
threadId: thread.id,
message: { role: 'assistant', content: 'Start at the Colosseum...' },
tokenCount: 6,
});
const retry = unwrap(
await memory.addEntry({
threadId: thread.id,
parentId: question.id,
activate: false,
message: { role: 'assistant', content: 'Start at the Vatican...' },
tokenCount: 6,
})
);
const active = unwrap(await memory.getPath({ threadId: thread.id })); // question, Colosseum
const other = unwrap(await memory.getPath({ threadId: thread.id, leafId: retry.id })); // question, Vatican
await memory.rewind(thread.id, question.id); // the active leaf goes back, nothing is deleted
await memory.checkout(thread.id, retry.id); // any branch becomes the active one
const copy = unwrap(await memory.forkThread(thread.id, retry.id, 'rome-vatican'));getPath({ threadId, leafId?, limit?, before?, types?, includeToolCalls? })reads one branch oldest first, the newestlimitentries of it, and pages back withbefore, without reading the rest of the thread.- Entry types:
message,tool_result, and the session eventsmodel_change,compaction,approval,interrupt,labelandcustom, which carry aneventpayload and are never sent to a model as they are (the summary of acompactionstands in for the entries it covers). Reads return events only whentypesasks for them. forkThreadcopies the branch up to an entry into a new thread, each copy naming its original inmetadata.forkOf, the new thread its origin inmetadata.forkedFrom, and the origin lists it inmetadata.memoryForks. A paused run of the origin is not copied.ContextBuilderbuilds from the active branch,build({ leafId })from another one.
Entries written before trees are chained in the order they were added the first time a store meets them: Postgres moves its schema to version 2 on connect(), SQLite migrates by PRAGMA user_version, Redis and MongoDB chain a thread the first time it is read or written. Upgrade every process that shares a store together, as an older process appends entries without a parent.
Lineage
Fact, GraphNode, GraphEdge and Insight take an optional evidence: Evidence[] ({ entryId?, toolCallId?, fileRange?, quote }, from @cogitator-ai/types) that says what they stand on, word for word:
import { evidenceOnPath, verifyEvidence, verifyEvidenceInMemory } from '@cogitator-ai/memory';
const rome = { entryId: question.id, quote: 'a day in Rome' };
const evidence = [rome];
verifyEvidence(rome, question); // 'verified': the quote is in the entry, whitespace aside
const checked = unwrap(await verifyEvidenceInMemory(memory, evidence)); // { status, evidence }
const onBranch = evidenceOnPath(evidence, new Set(active.map((entry) => entry.id))); // trueA quote that is not found, evidence of an entry that is gone and evidence without an entryId are 'suggested'. Every built-in fact and graph adapter stores evidence, takes it in updates, and mergeNodes keeps the evidence of the merged nodes.
Context Builder
Build token-aware conversation context from memory.
Configuration
interface ContextBuilderConfig {
maxTokens?: number; // without it, build() takes maxTokens from its options
reserveTokens?: number; // default 10% of the budget (at least 100)
strategy: 'recent' | 'relevant' | 'hybrid';
includeSystemPrompt?: boolean;
includeFacts?: boolean;
includeSemanticContext?: boolean;
includeGraphContext?: boolean;
graphContextOptions?: GraphContextOptions;
partitions?: Partial<Record<ContextPartition, ContextPartitionBudget>>; // merged over the defaults
timeZone?: string; // IANA zone of recalled dates and of the time a turn names, default UTC
}Strategies:
recent- the newest messages that fit into the token budget, as one unbroken run back from the newest: a tool result too large for the room left stays as a short note in its place and the run goes on past it, any other message that does not fit ends the run, so a reply never appears without the message it answersrelevant- messages ranked by embedding similarity tocurrentInput(requiresembeddingService; falls back torecentwithout input), returned in chronological orderhybrid- keeps the newest messages as history, likerecent, and recalls older turns and memory items a search of memory finds into the turn's memory context (needscurrentInput, searches without vectors when there is noembeddingService)
The system prompt is always kept and counted: the rest of the context gets what it leaves of maxTokens - reserveTokens, and a prompt larger than that is still sent whole, with a note in warnings. Entries are counted by their text, tool call arguments and images rather than only by their saved tokenCount. Without a messageIndex, relevant scores the newest 200 entries (hybrid the newest 200 older than the history it keeps) and embeds each entry once per builder (vectors are cached by entry id). Give the builder a messageIndex to keep those vectors in an embedding store instead: each entry is embedded once, in the background, and older entries of the thread are found by a search of the store, so recall reaches the whole thread. That is one embedding per message, plus one of the input per turn. vectorsKept bounds an index in process to about that many vectors, of the threads used lately, re-indexing a dropped thread when a build uses it.
build() also takes asOf, the moment the turn is read at (now by default): the time a turn names ("yesterday") is resolved from it and memory items are read as of it, to replay a recorded conversation. Its timeZone is the thread's IANA zone in place of the builder's.
import { MessageIndex } from '@cogitator-ai/memory';
const index = new MessageIndex({ store: postgres, embedding: embeddings });
index.attach(memory); // background embedding of new entries, vectors deleted with their entries
const builder = new ContextBuilder(
{ maxTokens: 8000, strategy: 'relevant' },
{ memoryAdapter: memory, embeddingService: embeddings, messageIndex: index }
);Every built-in adapter reports its changes through onChange(listener) (entry-added, entry-deleted, entry-redacted, thread-cleared, thread-deleted), which is what attach listens to (a deleted or redacted entry loses its vectors), and every built-in embedding store reads vectors by source with getBySources(ids). An adapter of your own reports its changes by extending ChangeReportingMemoryAdapter and passing each write's result through reported(). Vectors carry metadata.index: 'messages' and the thread's agentId and userId.
Recall
With hybrid the turns older than the history the budget keeps and the memory items the run sees are searched for the turn, and what is found goes into the recall partition, best first, under Earlier in this conversation: (each turn dated, with the turn next to it that makes it readable) and From memory: (each item with the interval it held when it no longer holds). An item the facts partition kept is not repeated, and BuiltContext.recalled gives { entries, items } as sent.
The search reads the turn without a model: small talk is not searched, and the question is temporal, aggregate, update, preference or general, which sets the weights of its channels. Turns are found by their vectors (a cosine of 0.45 or more and 0.6 of the best), by BM25 over the thread, by the time the turn names and by the entities of memory it names. Items are found by BM25, by the vectors of their content (each item embedded once), by those entities, by the time they are about, by a personalized PageRank walk over the graph of items and entities, and turns through the evidence of the best items. The channels are fused by weighted reciprocal rank, then reranked by the share of the input's words a hit holds, biased a little toward recent turns and toward items sent to a model more often (timesRetrieved), and laid out for diversity (MMR). ContextBuilderDeps.reranker (any Reranker, such as CohereReranker from @cogitator-ai/rag) reorders the best 30 after that, and one that fails leaves the order as it was, with a relevance error.
import { ContextBuilder, MemoryItems } from '@cogitator-ai/memory';
import { CohereReranker } from '@cogitator-ai/rag';
const reranker = new CohereReranker({ apiKey: process.env.COHERE_API_KEY! });
const builder = new ContextBuilder(
{ maxTokens: 16_000, strategy: 'hybrid', includeFacts: true, timeZone: 'Europe/Lisbon' },
{
memoryAdapter: memory,
memoryItems: new MemoryItems({ adapter: memory }),
embeddingService: embeddings,
messageIndex: index,
reranker,
}
);
const context = await builder.build({
threadId: 'ana-1',
agentId: 'concierge',
userId: 'ana',
currentInput: 'Where did we stay last April?',
asOf: new Date('2026-10-21T15:00:00Z'),
});
context.recalled; // { entries, items }The search is MemoryRetriever (retrieve({ input, anchor, timeZone?, history, held?, cosines?, items?, itemAdapter?, scope?, itemCosines?, agentIds? }), which returns { analysis, hits }, turns and items in one ranked list), and analyzeQuery(input, anchor, timeZone?) reads a turn as it does: whether to search, the kind of question, the time it names and its terms. The memory search tools of @cogitator-ai/core (search_memory) and its recall tool rank with it too.
Partitions and Message Order
A context is assembled by a ContextAssembler from partitions, each with a share of the budget it may take, and some required:
| Partition | Holds | Default |
| ----------- | ------------------------------------------ | ------------------------ |
| system | the system prompt | required, sent whole |
| pinned | pinned texts of build() | required |
| index | the index text of build() | required |
| summaries | compaction summaries | required |
| brief | the brief text of build() | required |
| facts | facts | 15% |
| recall | recall (hybrid), search results, graph | 35% (60% for hybrid) |
| recent | history, selected by the strategy | what the others leave |
| final | currentInput, counted, not in messages | required, always counted |
Required partitions are fitted first. The others split what they leave by their shares, no more than each needs, and what the shares cannot place goes to those still short of room in fill order (facts, recall, recent). The shares of the partitions that are not required may add up to 1 at most, else the constructor throws. A text block that a kept block of a more lasting partition holds in full, word for word (a fact the system prompt states, a search result that repeats a fact), is left out as a duplicate.
messages is ordered for the prompt cache: one system message (the system prompt, pinned, index), the compaction summary and the history, whose last message and the last message of the turn before it carry cacheBreakpoint: true (markTurnEnds), then a user message with what changes every turn (the brief, Known facts:, Relevant context:, graph context, Earlier in this conversation: and From memory:) in a <memory_context> block, which is also memoryContext. A caller that adds the input itself should send memoryContext in front of the input as one user message, as a Cogitator run does, which adds its context and the insights of reflection to the same block. formatTurnContext(blocks) builds that text from texts of your own, extendTurnContext(text, block) adds a block to it, and withoutTurnContext(text) gives the input back without it.
const context = await builder.build({
threadId: 'thread_123',
agentId: 'agent-1',
systemPrompt: 'You are a helpful assistant.',
pinned: ['The user is on the Pro plan.'],
brief: 'Current date and time: Wednesday, October 21, 2026, 17:20.',
currentInput: 'What changed since yesterday?',
});
context.partitions; // tokens per partition, e.g. { system: 9, final: 8, pinned: 9, brief: 15, recent: 2400 }
context.dropped.blocks; // [{ partition: 'recent', reason: 'budget', tokens: 310, sourceId: 'entry_17' }, ...]Every left out block is in dropped.blocks with its partition, reason (budget, duplicate, not-selected for an entry the strategy did not pick, oversized for one larger than its partition could hold), tokens and sourceId. ContextAssembler, DEFAULT_PARTITIONS and fitInOrder are exported for contexts of your own.
Basic Usage
import { ContextBuilder } from '@cogitator-ai/memory';
const builder = new ContextBuilder(
{
maxTokens: 4000,
reserveTokens: 400,
strategy: 'recent',
includeSystemPrompt: true,
},
{ memoryAdapter: memory }
);
const context = await builder.build({
threadId: 'thread_123',
agentId: 'agent-1',
systemPrompt: 'You are a helpful assistant.',
});
console.log(context.messages);
console.log(context.tokenCount);
console.log(context.truncated);
console.log(context.dropped); // what was left out: entries, facts, semanticResults, graphNodes, tokenCount, blocksBudget per Build
Leave maxTokens out of the config and pass it to build() instead, so one builder serves models of different sizes. A Cogitator run does this with the context window of the model it runs on when memory.contextBuilder.maxTokens is not set:
const builder = new ContextBuilder({ strategy: 'recent' }, { memoryAdapter: memory });
const context = await builder.build({
threadId: 'thread_123',
agentId: 'agent-1',
maxTokens: 128_000, // e.g. the model's context window
});A build without a budget from either side throws. reserveTokens defaults to 10% of the budget used (at least 100), and build() takes its own reserveTokens too. A Cogitator run passes reserveTokens: 0, since its budget already leaves room for the answer, and the builder counts the input in the budget.
With Facts and Semantic Search
const builder = new ContextBuilder(
{
maxTokens: 8000,
strategy: 'recent',
includeFacts: true,
includeSemanticContext: true,
},
{
memoryAdapter: memory,
factAdapter: factStore,
embeddingAdapter: vectorStore,
embeddingService: embeddings,
}
);
const context = await builder.build({
threadId: 'thread_123',
agentId: 'agent-1',
systemPrompt: 'You are an expert.',
currentInput: 'What is machine learning?',
});
console.log(context.facts);
console.log(context.semanticResults);Embedding stores also accept filter.metadata (exact metadata values) in search/keywordSearch, and deleteByFilter({ sourceType?, agentId?, threadId?, metadata? }) deletes every matching embedding (a filter without conditions is refused). RAG re-ingest uses it to replace a source's chunks.
Semantic context is scoped by embedding metadata: entries with metadata.agentId set are only visible to that agent, entries without an agentId (shared documents, knowledge bases) are visible to everyone. Adapters filter search/keywordSearch by filter.agentId and filter.threadId through the same metadata fields.
Built Context
interface BuiltContext {
messages: Message[]; // system message, history (cache breakpoints at its end and the turn before), turn context
memoryContext?: string; // brief, facts, recall and graph context, sent with the input
partitions: Partial<Record<ContextPartition, number>>; // tokens per partition, the input included
facts: Fact[];
semanticResults: (Embedding & { score: number })[];
recalled?: { entries: MemoryEntry[]; items: MemoryItem[] }; // what hybrid recalled for the turn
graphContext?: GraphContext; // with includeGraphContext and a graph adapter
tokenCount: number; // tokens of the context, without the input
truncated: boolean; // history entries were left out
dropped: DroppedContext; // what was left out of the context
errors?: ContextBuildError[]; // parts that failed to load and were left out
warnings?: string[]; // budget problems, such as a system prompt over budget
metadata: {
originalMessageCount: number;
includedMessageCount: number;
factsIncluded: number;
semanticResultsIncluded: number;
graphNodesIncluded: number;
graphEdgesIncluded: number;
};
}
interface DroppedContext {
entries: MemoryEntry[]; // history left out, in conversation order (relevant/hybrid: also entries not selected, hybrid: not those recalled)
facts: Fact[]; // facts visible to the user that did not fit
semanticResults: (Embedding & { score: number })[]; // search results that did not fit
graphNodes: GraphNode[]; // graph nodes cut from the graph context
tokenCount: number; // tokens of everything left out
blocks: DroppedBlock[]; // every block left out, with its partition and reason
}
interface ContextBuildError {
source: 'history' | 'facts' | 'semantic' | 'graph' | 'relevance';
error: Error;
}build() does not throw when a part of the context fails to load (an unreachable store, an embedding API answering 429, a vector size mismatch): the part is left out and reported in errors. A failed relevance scoring falls back to the recent strategy for relevant, and to a recall without vectors for hybrid.
Sessions and Compaction
SessionManager maps channel conversations (user + channel + agent) onto memory threads. CompactionService replaces old history with an LLM summary, which a reader of the thread gets before the most recent messages.
import { createLLMBackend } from '@cogitator-ai/core';
import { InMemoryAdapter, SessionManager, CompactionService } from '@cogitator-ai/memory';
const memory = new InMemoryAdapter();
const llm = createLLMBackend('google', {
providers: { google: { apiKey: process.env.GOOGLE_API_KEY! } },
});
const compaction = new CompactionService({
adapter: memory,
summarize: async (messages, { model, prompt } = {}) => {
const response = await llm.chat({
model: model ?? 'gemini-3.5-flash-lite',
messages: [
{ role: 'system', content: prompt ?? 'Summarize this conversation in a few sentences.' },
...messages,
],
});
return response.content;
},
});
const sessions = new SessionManager(memory, { compaction });
const session = await sessions.getOrCreate({
userId: 'user-1',
channelType: 'telegram',
channelId: 'chat-42',
agentId: 'assistant',
});
const all = await sessions.list();
const mine = await sessions.list({ userId: 'user-1', status: 'active', limit: 20 });
await sessions.compact(session.id, {
strategy: 'summary',
threshold: 8000,
keepRecent: 10,
summaryModel: 'gemini-3.5-flash',
summaryPrompt: 'Keep names, decisions and open questions.',
});list() works with or without a userId filter (sessions are tracked in an index thread). Using a session puts it back into the index if the index lost it and rewrites the index at most once per indexRefreshInterval (default one minute), so with Redis and a ttl the index lives as long as any session is active.
A thread is compacted once its conversation holds threshold tokens or messageThreshold entries, whichever comes first (set at least one). On an adapter with branches (every built-in one) nothing is deleted: a compaction entry at the end of the active branch names the first kept entry and holds the summary, and readCompactedHistory(memory, { threadId }) (or compactedHistory(path) on a path read with types: HISTORY_ENTRY_TYPES) gives the conversation as the summary, the kept entries and everything after them. ContextBuilder and the runtime read history this way, other branches and evidence keep the summarized entries, and getEntries still returns all of them. An adapter without getPath gets the summary dated just before the first kept entry and the summarized entries deleted.
compact() requires the compaction option, without it use CompactionService directly. The summarizer (SummarizeFn) is called as summarize(messages, options), where options is a SummarizeOptions { model?, prompt? } filled from the summaryModel / summaryPrompt of the compaction config.
Hierarchical Compaction
strategy: 'hierarchical' keeps the summary as a tree of what the conversation established, so it is never summarized again and loses nothing it still needs:
await compaction.compact(session.id, {
strategy: 'hierarchical',
threshold: 60_000,
keepRecent: 0,
keepRecentTokens: 8000, // newest whole turns kept verbatim
chapterTokens: 4000, // default
chapterConcurrency: 1, // default
chaptersPerArc: 4, // default
arcsPerSaga: 4, // default
});- The entries new since the last compaction, up to the kept tail, are read in chapters of about
chapterTokenstokens, each opening with the end of the one before, one at a time (chapterConcurrencyreads more at once). For each the summarizer gets a numbered transcript andoptions.promptasks for a JSON array of atoms and lists the items still open, which it may close (summaryPromptis added to it), up to three tries for an answer that holds no array. An element that is not an atom is left out, and an answer cut at its length limit keeps the atoms it finished and is continued after the last one, up to four times.parseBriefreads its array the same way, keeping the cards of a cut answer without asking again, while consolidation takes only a whole answer, since its decisions are made as a set. - A
CompactionAtomhas akind(fact,task,decision,constraint,error,file,thread), astatus(open,closed), atextandevidence. Every quote is checked against its entry, and an atom whose quotes do not all hold is notverified. Files tool calls worked on, failed tool calls and open task lines (TODO:,- [ ]) are taken from the entries with no model. - Chapters beyond
chaptersPerArcfold into arcs and arcs beyondarcsPerSagainto the saga, with no model. An open task, decision, constraint, error or question is closed, or opened again once done, only by an atom whose evidence holds. The saga keeps every open item and the newest 200 facts and files, so the handoff stops growing. - The summary is written only when it still holds every open item of the one before, else
compact()throws and the thread stays as it was. - The whole tree is one
compactionentry,event.levels(CompactionLevels:saga,arcs,chapters, each aCompactionNodewithfrom,toandatoms), and its message is the handoff:Open tasks,Constraints,Decisions,Unresolved errors,Open questions,Files,FactsandDone, in the same order every time so it stays in the prompt cache. keepRecentTokenskeeps the newest tokens in whole turns that open with a user message, so a tool call keeps its result. Without it the newestkeepRecententries stay.
chapterAtoms(window, config, { own, open }) returns the checked atoms of one window, told the open items it may close. anthropicCompactionSummarizer({ model }) from @cogitator-ai/core is a SummarizeFn that has Anthropic's compaction on request write the answer, checked like any other. A Cogitator compacts a run's thread this way after the run with memory.compaction, see Compaction After a Run.
Memory Items
A memory item is one thing a conversation established: a fact, event, preference, instruction, opinion or lesson, with its quotes, its trust (user, system, model, tool, web), its scope (agent, and optionally user, project, thread) and its times. Every built-in adapter stores items (MemoryItemAdapter), and MemoryItems keeps the rules on top:
import { InMemoryAdapter, MemoryItems, MemoryExtractor } from '@cogitator-ai/memory';
const memory = new InMemoryAdapter();
await memory.connect();
const items = new MemoryItems({ adapter: memory });
const scope = { agentId: 'concierge', userId: 'ana' };
const extractor = new MemoryExtractor({ items, summarize }); // summarize: your SummarizeFn
await extractor.extract(entries, { scope, timeZone: 'Europe/Lisbon' });
await items.query({ scope }); // what holds now
await items.query({ scope, asOf: new Date('2026-06-01') }); // what held then
await items.history(scope, slot); // every value of a slot, closed ones included- Writing only adds, and one agent's writes go one after another within a process, recorded at least a millisecond apart. An item said again merges into the newest open one no other value followed since (else it is a new state), the trust and
verifiedcoming together from the better source: the more trusted one first, then the one whose quotes hold, so asystemitem stayssystemand a user's unverified claim stays the user's butsuggested. Values of a slot compare by text (sameValue): spacing aside, case and signs count. Stores filter items by scope themselves (MemoryItemFilter.visibleToandscope). After every write of a state, theactiveandsupersededitems of its slot (subject, linked to an entity the scope sees, andattribute) and exact scope are laid out by time, each closed (superseded,validToset) when the next one starts and the last one open, so racing writers, a state told late and a failed close converge, and a state set to start later leaves the current one in place until it starts. A state let go keeps its place, so the one before does not come back. - An item becomes
activeon its own only fromsystem, or fromuserwith quotes that hold atactivationConfidence(default 0.7). Everything else stayssuggestedand out of every context, so text a model, a tool or a page wrote cannot plant itself. An opinion said again gains 0.1 when the new statement has quotes that hold, and one a less trusted source contradicts loses 0.2. - Times are bitemporal:
validFrom/validToin the world,recordedAt/expiredAtin memory, queried withasOf(defaultknownAt) andknownAt(default now), by default overactiveandsupersededitems with the state that holds read from the times.resolveTime(expression, sessionAt, timeZone)turns time words a model marked up (date,relative,weekday) into an interval, end exclusive, with its precision. MemoryExtractorreads entries in windows ofwindowTokens(default 4000), one model call each (EXTRACTION_PROMPT, each message shown with its date), checks every quote against its entry (parseExtraction, which reads anullor blank field as absent and drops an item that does not fit) and writes the items throughMemoryItems. An item's trust is that of the least trusted entry a holding quote is in (trustOfEntry(entry, userId), where another user's message counts asmodel), so what a page, the assistant or another user said is never the user's, and its dates are resolved from the message that said them. The JSON array of items is found past any text and brackets around it (such as a message number,[3]), and an element that is not JSON is left out instead of the answer. Each call asks for up to 8000 tokens (SummarizeOptions.maxTokens): an answer cut at that limit inside the array keeps the items it finished, and the model is asked to go on after the last one (extractionContinuationPrompt), up toEXTRACTION_CONTINUATIONS(4) times, so a window dense with facts is read whole. A window whose answer holds no array after three tries throwsExtractionAnswerError(WindowErrorfromextract).- With
contextBuilder.includeFacts, the context builder reads the items the run sees that hold now (as ofasOf) as its facts (ContextBuilderDeps.memoryItems), one state per slot (the one that started last, the narrowest scope on a tie), instructions and preferences first, newest first, a replaced value shown as(previously: X)when the run still sees it and memory did not let it go. Withhybridthe items that match the turn are recalled underFrom memory:too. timesRetrievedandlastRetrievedAtcount how often and when an item went to a model.noteRetrieved(itemIds, at?)counts them, in one step of the store where the adapter hascountRetrieved(every built-in one does), else read and written back under the agent's lock, and a Cogitator run counts the facts and recalled items it sent once it finishes or pauses, in the background.
A Cogitator extracts items after each run with memory.extraction, off by default. Start with mode: 'shadow', which writes every new item as suggested, out of every context, until you review it (items.query({ scope, statuses: ['suggested'] })) and activate it. See Memory Items.
Memory Files
Memory files are notes an agent keeps under /memories and edits with the memory tool: view, create, str_replace, insert, delete and rename, with the answers and errors of Anthropic's memory tool. Every built-in adapter keeps them (MemoryFileAdapter, isMemoryFileAdapter(adapter) tells), and MemoryFiles keeps the rules on top:
import { InMemoryAdapter, MemoryFiles } from '@cogitator-ai/memory';
const memory = new InMemoryAdapter();
await memory.connect();
const files = new MemoryFiles({ adapter: memory });
const access = { scope: { agentId: 'concierge', userId: 'ana' } };
await files.run(access, {
command: 'create',
path: '/memories/user.md',
file_text: 'Allergic to peanuts.',
});
await files.run(access, { command: 'view', path: '/memories' }); // the directory, two levels deep
await files.list(access); // [{ path, content, sha256, size, version }]
await files.history(access, '/memories/user.md'); // every version, oldest first
await files.index(access); // the list of files a run's context gets
await files.redact(access, ['/memories/user.md'], 'erasure request'); // versions redacted- Every change adds a version (
MemoryFileVersion:scope,path,sequence,contentornullfor a deletion,sha256,previousId,place,renamedFrom,author,createdAt), so a file's history stays.redact(access, paths, reason?)replaces the content of every version of the files, and of the paths they were renamed from and to, with[redacted]and keeps the rest, every scope of the agent with{ everyScope: true }. The conversation keeps the tool calls that wrote the text. - An access reads the files of the scopes it sees (
visibleTo, itsscopeby default), each from the version of the narrowest scope that holds the path, and writes into itsscopewith every limit (user, project, thread) of the version it changes and, for a moved file, of its source, so a change stands in front and a file is never shared wider than it was. With aplace(the branch of a thread it stands on), a version written in that thread is seen only on the branches holding the entry it was written after. strReplace,insert,deleteandrenametake thesha256the caller last read (observeon the access hears of the version each command showed or wrote), and fail when another writer changed the file since. Two writers never add the same version: the store refuses a second version of one scope and path at the samesequence(MEMORY_FILE_CONFLICT), and the change is made again on what stands, up to three tries. A change of many files, such as a renamed directory, is added whole or not at all. A change that finds, once kept, a file of its own under another file or with files under it, which writers in two processes can make at the same time, is taken back, its own versions withdrawn so the history stays whole however others wrote on top (a deletion whose path another writer built on since is kept), and refused (Another writer changed the memory around <path> at the same time. View it again and try again.). Reads of the files that stand go throughfindFileHeadswhere the store has it, so they read no history.readOnlymakes every change fail, paths outside/memories, control characters and names past 255 characters are refused, a file cannot sit under another file, NUL characters are refused, a file holds at most 1 MiB (MAX_FILE_BYTES) and one change 8 MiB (MAX_CHANGE_BYTES), and a refused command throws aMemoryFileErrorwith the message the model reads.reserved(a list of paths) makes every change of those paths and of anything under them fail, a rename into one included, with<path> is kept by memory itself and is read-only here, while they are viewed as usual. The memory tool of@cogitator-ai/corereservesMEMORY_RESERVED_PATHS: the playbook, its draft and/memories/observations/, which memory writes itself.
A Cogitator with memory.files gives every agent the memory tool over these files, native memory_20250818 on Claude 4 and later, and lists them in each run's context. See Memory Files.
files.put(access, path, text, expectedSha256?) writes a whole file, creating it when it does not exist, for code that keeps a file of its own: expectedSha256 names the version the text was made from (null for a file that did not exist), and the write fails when the file stands otherwise.
Between Runs
Three pieces keep memory useful between runs. Each asks a model through a SummarizeFn and checks what the model answers before anything is kept. A Cogitator runs them with memory.brief, memory.observations and memory.consolidation, see Between Runs.
Pre-Turn Brief
MemoryBriefer hands a model the turn's input and what memory found for it (BriefSource: ref as turn:<id> or item:<id>, text, date), and keeps the cards whose quotes hold:
import { MemoryBriefer, briefBlock } from '@cogitator-ai/memory';
const briefer = new MemoryBriefer({ summarize, maxCards: 3 });
const cards = await briefer.brief('What should I cook for Ana?', [
{ ref: 'item:mem_2', text: 'Ana is allergic to peanuts', date: '2026-03-01' },
]);
// [{ kind: 'knowledge', text: 'No peanuts.', quote: 'allergic to peanuts', source: 'item:mem_2' }]
briefBlock(cards); // the block a turn's memory context gets, marked as a hint- A card is a
constraint,knowledge,consequenceoropen_thread, at mostmaxCards(1 to 4,MAX_BRIEF_CARDS), each with aquoteof the source it names.parseBriefkeeps a card only when its source is one of those given and its text holds the quote word for word, spacing and case aside, one card per quote. A quote must hold at least three words, and the prompt tells the model the sources and the message are data. - Nothing is asked when memory found nothing.
maxTokens(default 600) bounds the answer andpromptis added toBRIEF_PROMPT.
Observations
MemoryObservations keeps, for each entity memory items are about, what a model makes of the facts that hold now, as a memory file under /memories/observations/ (OBSERVATIONS_DIRECTORY):
import { MemoryFiles, MemoryItems, MemoryObservations } from '@cogitator-ai/memory';
const observations = new MemoryObservations({
files: new MemoryFiles({ adapter: memory }),
items: new MemoryItems({ adapter: memory }),
itemAdapter: memory,
});
const { written, deleted } = await observations.refresh(
{ agentId: 'concierge', userId: 'ana' },
{ summarize, maxEntities: 3, minItems: 3 }
);
await observations.read({ agentId: 'concierge', userId: 'ana' }); // [{ entityId, name, aliases, path, text, itemIds, basedOn, file }]- A file holds the entity's name, its other names and statements, each ending with the items it stands on as
[item:<id>]. A statement naming an item the model was not given is dropped, and an answer with no statement left writes nothing. - The file keeps a fingerprint of the ids of the items it was written from.
refreshwrites again only the observations whose facts changed since (a fact added, closed or marked stale), the entities with the newest facts first, at mostmaxEntities(default 3), and deletes the observations of the scope whose entity no longer hasminItems(default 3) facts that hold. Lessons are left out. Facts that gave no observation are not asked about again in this process until they change. - Observations are memory files like any other: every rewrite is a version, and the agent may read them with the memory tool. A version of the playbook or of an observation a model wrote (
author: 'model') is never shown to runs or read as an observation (parseObservationreturnsundefinedfor it), only those memory itself or a user wrote. ContextBuildertakesobservationsamong its deps: a turn whose input names an entity (its name or one of its other names, as words of their own) or that recalls one of its items gets up to three observations next to the facts.
Consolidation
MemoryConsolidator looks over one scope's memory, in the spirit of sleep-time compute:
import { MemoryConsolidator, PLAYBOOK_PATH } from '@cogitator-ai/memory';
const consolidator = new MemoryConsolidator({ items, itemAdapter: memory, files });
const scope = { agentId: 'concierge', userId: 'ana' };
await consolidator.dedupe(scope, { summarize, since: lastPass, maxChanges: 10, maxSubjects: 10 });
// { stale: [{ itemId, keptId, reason: 'duplicate' | 'contradiction' }], complete: true }
await consolidator.foldLessons(scope, { summarize }); // { path, edits, lessonIds } or undefined
await consolidator.readPlaybook(scope); // the file at /memories/playbook.mddedupeshows a model theactiveitems of exactly the scope, grouped by the entity they are about, and marks asstale(never deletes) the items it finds to repeat or contradict another one. Code checks every group: an item is never dropped for a less trusted one, a contradiction drops only items said before the one kept, and the reason goes into the item'smetadata.staleBecause.sincelooks only at entities with an item recorded after it, the entities whose newest item is oldest first.maxChanges(default 10) bounds the items one call marks andmaxSubjects(default 10) the entities it asks about. It returns{ stale, complete, through? }:completeis false when a cap stopped it, andthroughthen says it looked at every entity whose newest item was recorded up to then, so a later call goes onsinceit.MemoryItems.markStale(itemIds, because?, onlyFrom?)keepsbecausethe same way for code of your own, and withonlyFromleaves an item whose status is no longer one of those as it is. A group that names an item the model was not shown is refused whole. An answer the model left unfinished stops the pass before that subject, so a later pass asks again.markStalereturns the ids it marked, anddedupereports only those.foldLessonsfolds theactivelessons the scope sees (items of kindlesson), recorded since the playbook last took one, oldest first and 20 a fold, into/memories/playbook.md(PLAYBOOK_PATH) in at most three edits (MAX_PLAYBOOK_EDITS): each adds a rule or rewrites one rule it quotes exactly, so the playbook is never written over whole and its rules cannot collapse into a summary of themselves. Every rule names its lessons as[lesson:<id>], holds at most 300 characters, and the playbook at most 60 rules (MAX_PLAYBOOK_RULES), past which lessons only rewrite rules. A lesson a model wrote stayssuggesteduntilMemoryItems.activatemakes itactive, and a lesson from a tool or a page is never folded, even when active, so neither can reach the playbook every run reads on its own. An answer the model did not finish (no JSON array) folds nothing and leaves its lessons for the next fold. A playbook every user of the agent shares (a scope without a user, project or thread, as withmemory.extraction.scope: 'agent') takes onlysystemlessons inapplymode, since one user's lesson would reach every other user, andshadowdrafts the rest for review. Lessons are ordered and folded by when they were activated (MemoryItems.activatekeepsmetadata.activatedAt), else by when they were recorded, so a lesson activated after a fold is still folded. A fold builds on the newest version of the playbook memory or a person wrote, skipping one a model wrote, andpromotePlaybookrefuses a draft a model wrote.- Every fold is a new version of the file, so its diff and the way back are in the file's history. With
mode: 'shadow'the edited playbook goes to/memories/playbook.next.md(PLAYBOOK_DRAFT_PATH) instead, andpromotePlaybook(scope)makes the draft the playbook after review, only when the playbook did not change since the draft was made. parsePlaybook,playbookFile,parsePlaybookEditsandapplyPlaybookEditsread and write the file and its edits.
Forgetting, Redacting and Rewinding
What memory derives from a conversation keeps the entries it stands on: the evidence of items, facts and graph nodes and edges, a compaction's firstKeptEntryId and atoms, a memory file version's place, the items an observation or a playbook rule names. MemoryCascade follows that lineage when entries go, so a fact written from a turn does not outlive the turn:
import { MemoryCascade } from '@cogitator-ai/memory';
const cascade = new MemoryCascade({
memory,
items: memory,
files: memory,
graph,
facts,
embeddings,
});
const who = { agentIds: ['assistant'] }; // whose items, files, facts and graph to go through
await cascade.deleteEntries([locker.id], who);
await cascade.redactEntries([passport.id], { at: new Date(), reason: 'the user asked' }, who);
await cascade.deleteThread('chat', who);
await cascade.deleteUser('ana', who);
await cascade.rewind('chat', earlier.id, who);
// { entries: { deleted, redacted }, threads, threadsChanged, summaries, items: { purged, trimmed, stale },
// entities, facts: { deleted, trimmed }, graph: { nodes, edges, trimmed }, fileVersions, playbookRules }- With deleted or redacted entries go the compaction summaries that cover them (the entries before
firstKeptEntryIdon their branch, or, when that entry is gone, those at or before the last entry left out) or quote them in an atom, and the next compaction writes them again. A legacy summary kept as a message (metadata.compactionSummary, on an adapter without branches) goes when its atoms quote what went. The memory items, facts and graph nodes and edges that stand only on them are purged, those that stand on more lose the quotes of them (MemoryItems.reviseEvidence(itemIds, keep), which returns the ids it changed), and the entities no item is about any more are deleted. Facts and the graph are reached only whenfactsandgraphare given. The vectors of the entries, summaries and facts that go are deleted fromembeddings, and the memory file versions written at the entries are redacted. - Every version of an observation or a playbook that names an item or a lesson that went is redacted. A playbook in front is first written again without the rules that stood on such a lesson, so the other rules stay, and an observation is written again by the next refresh. A memory file whose version in front a cascade redacted is then deleted, so its path, which may name what was forgotten, leaves the listing, and its history keeps the redacted versions.
- A user message takes its replies, the entries after it on every branch up to the next user message, events such as a model change among them, as an answer may say again what it said.
keepReplies: trueleaves them. - The copies of the entries and their replies in threads forked from theirs go too: a thread lists its forks in
metadata.memoryForks(forkThreadwrites it,FORKS_KEY), each copy names its original inmetadata.forkOf, and the forks of forks are followed. A fork does not copy the paused run of its origin. redactEntriesneedsredactEntryon the adapter. A redacted entry keeps its place and its parent, its tool calls lose their arguments, replay state and argument error, and a user message loses its sendername. An event entry among the ids or their replies (model_change,approval,interrupt,label,custom, or acompactionamong the ids) is deleted, not redacted, and listed inentries.deleted.deleteThreadalso purges the items and entities and redacts the memory file versions kept for the thread alone (their files then deleted), and takes the copies of its entries in its forks.deleteUserdeletes every thread whosemetadata.userIdis the user whole, the messages others wrote in it too, and the messages the user wrote in threads of others (those that list the user inmetadata.memoryUserIds,MEMORY_USERS_KEY) with their replies, does the same for the user's scope (its files deleted too), and deletes the facts and graph nodes and edges whosemetadata.userIdis the user and the embeddings with that metadata (deleteByFilter, where the store has it). It lists every thread of the store to find them.rewinddeletes nothing: the entries aftertoEntryIdstay on a branch of their own, and the items that stood only on them becomestalewithstaleBecause: { reason: 'rewound', threadId, toEntryId, at }. A rewind marks the items stale before it moves the thread, so one that failed can be made again (a rewind the store refuses leaves them as they were), and a thread that stands attoEntryIdalready is left as it is.- Derived memory goes before the entries it stands on, so a cascade that fails part of the way throws the store's error and can be made again. Once the entries went it looks for derived memory again, so an item written meanwhile goes too.
CompactionServicerefuses to write a summary whose entries were deleted or redacted while it was written, andMemoryExtractor.extracttakesbeforeWrite(items), through which a Cogitator drops the items whose entries went while the model answered.
redactEntry(entryId, { at, reason? }) on every built-in adapter replaces the text of a message or tool_result entry, the arguments of its tool calls and the results of its tools with [redacted], keeps its place, its parent, its role and its call ids, drops the sender name of a user message and the replay state and argument error of its tool calls, records metadata.redacted, gives null for a missing entry and refuses an event entry. Listeners hear entry-redacted. An adapter of your own builds the entry with redactedEntry(entry, redaction) and REDACTED_TEXT. A Cogitator runs the cascades as cog.forget, cog.redactEntries and cog.rewind, reads the agents to go through from metadata.memoryAgentIds and drops the paused runs of the threads it touches, see Forgetting, Redacting and Rewinding.
Core Facts
CoreFactsStore keeps a small set of key/value facts about the user in SQLite (with history), formatted for a system prompt. Each key is the slot core:<key> of system memory items in its scope ({ agentId: 'core' } by default):
import { CoreFactsStore } from '@cogitator-ai/memory';
const facts = new CoreFactsStore({ path: './facts.db' }); // or { db } to share a better-sqlite3 database
await facts.initialize();
await facts.set('name', 'Alice');
await facts.get('name'); // 'Alice'
await facts.getAll(); // { name: 'Alice' }
await facts.getHistory('name'); // [{ value, setAt }]
const prompt = await facts.formatForPrompt();
await facts.close();A new value supersedes the current one, and the same value again changes nothing. delete(key) lets the current value go and keeps the history. The core_facts and core_facts_history tables of an earlier version are moved into memory items on initialize() and renamed with a _legacy suffix.
Token Counting
Simple token estimation without tiktoken dependency.
import {
countTokens,
countMessageTokens,
countMessagesTokens,
countToolCallsTokens,
countEntryTokens,
truncateToTokens,
} from '@co