npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cogitator-ai/memory

v0.29.0

Published

Memory adapters for Cogitator AI agents

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 adapter

Database 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) and hybrid (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.threadId never leak to other agents
  • Sessions & Compaction - SessionManager for channel sessions, CompactionService for 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 hybrid recall as its own class (MemoryRetriever, analyzeQuery), for tools that search memory the same way
  • Memory Files - Notes an agent keeps under /memories with 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, redactEntry on every built-in adapter)
  • Facts Storage - Store and retrieve agent knowledge (FactAdapter on PostgreSQL, CoreFactsStore on 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 needed

createMemoryAdapter 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 newest limit entries of it, and pages back with before, without reading the rest of the thread.
  • Entry types: message, tool_result, and the session events model_change, compaction, approval, interrupt, label and custom, which carry an event payload and are never sent to a model as they are (the summary of a compaction stands in for the entries it covers). Reads return events only when types asks for them.
  • forkThread copies the branch up to an entry into a new thread, each copy naming its original in metadata.forkOf, the new thread its origin in metadata.forkedFrom, and the origin lists it in metadata.memoryForks. A paused run of the origin is not copied.
  • ContextBuilder builds 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))); // true

A 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 answers
  • relevant - messages ranked by embedding similarity to currentInput (requires embeddingService; falls back to recent without input), returned in chronological order
  • hybrid - keeps the newest messages as history, like recent, and recalls older turns and memory items a search of memory finds into the turn's memory context (needs currentInput, searches without vectors when there is no embeddingService)

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, blocks

Budget 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 chapterTokens tokens, each opening with the end of the one before, one at a time (chapterConcurrency reads more at once). For each the summarizer gets a numbered transcript and options.prompt asks for a JSON array of atoms and lists the items still open, which it may close (summaryPrompt is 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. parseBrief reads 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 CompactionAtom has a kind (fact, task, decision, constraint, error, file, thread), a status (open, closed), a text and evidence. Every quote is checked against its entry, and an atom whose quotes do not all hold is not verified. Files tool calls worked on, failed tool calls and open task lines (TODO:, - [ ]) are taken from the entries with no model.
  • Chapters beyond chaptersPerArc fold into arcs and arcs beyond arcsPerSaga into 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 compaction entry, event.levels (CompactionLevels: saga, arcs, chapters, each a CompactionNode with from, to and atoms), and its message is the handoff: Open tasks, Constraints, Decisions, Unresolved errors, Open questions, Files, Facts and Done, in the same order every time so it stays in the prompt cache.
  • keepRecentTokens keeps the newest tokens in whole turns that open with a user message, so a tool call keeps its result. Without it the newest keepRecent entries 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 verified coming together from the better source: the more trusted one first, then the one whose quotes hold, so a system item stays system and a user's unverified claim stays the user's but suggested. Values of a slot compare by text (sameValue): spacing aside, case and signs count. Stores filter items by scope themselves (MemoryItemFilter.visibleTo and scope). After every write of a state, the active and superseded items of its slot (subject, linked to an entity the scope sees, and attribute) and exact scope are laid out by time, each closed (superseded, validTo set) 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 active on its own only from system, or from user with quotes that hold at activationConfidence (default 0.7). Everything else stays suggested and 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/validTo in the world, recordedAt/expiredAt in memory, queried with asOf (default knownAt) and knownAt (default now), by default over active and superseded items 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.
  • MemoryExtractor reads entries in windows of windowTokens (default 4000), one model call each (EXTRACTION_PROMPT, each message shown with its date), checks every quote against its entry (parseExtraction, which reads a null or blank field as absent and drops an item that does not fit) and writes the items through MemoryItems. 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 as model), 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 to EXTRACTION_CONTINUATIONS (4) times, so a window dense with facts is read whole. A window whose answer holds no array after three tries throws ExtractionAnswerError (WindowError from extract).
  • With contextBuilder.includeFacts, the context builder reads the items the run sees that hold now (as of asOf) 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. With hybrid the items that match the turn are recalled under From memory: too.
  • timesRetrieved and lastRetrievedAt count how often and when an item went to a model. noteRetrieved(itemIds, at?) counts them, in one step of the store where the adapter has countRetrieved (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, content or null for 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, its scope by default), each from the version of the narrowest scope that holds the path, and writes into its scope with 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 a place (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, delete and rename take the sha256 the caller last read (observe on 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 same sequence (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 through findFileHeads where the store has it, so they read no history.
  • readOnly makes 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 a MemoryFileError with 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/core reserves MEMORY_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, consequence or open_thread, at most maxCards (1 to 4, MAX_BRIEF_CARDS), each with a quote of the source it names. parseBrief keeps 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 and prompt is added to BRIEF_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. refresh writes again only the observations whose facts changed since (a fact added, closed or marked stale), the entities with the newest facts first, at most maxEntities (default 3), and deletes the observations of the scope whose entity no longer has minItems (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 (parseObservation returns undefined for it), only those memory itself or a user wrote.
  • ContextBuilder takes observations among 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.md
  • dedupe shows a model the active items of exactly the scope, grouped by the entity they are about, and marks as stale (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's metadata.staleBecause. since looks 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 and maxSubjects (default 10) the entities it asks about. It returns { stale, complete, through? }: complete is false when a cap stopped it, and through then says it looked at every entity whose newest item was recorded up to then, so a later call goes on since it. MemoryItems.markStale(itemIds, because?, onlyFrom?) keeps because the same way for code of your own, and with onlyFrom leaves 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. markStale returns the ids it marked, and dedupe reports only those.
  • foldLessons folds the active lessons the scope sees (items of kind lesson), 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 stays suggested until MemoryItems.activate makes it active, 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 with memory.extraction.scope: 'agent') takes only system lessons in apply mode, since one user's lesson would reach every other user, and shadow drafts the rest for review. Lessons are ordered and folded by when they were activated (MemoryItems.activate keeps metadata.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, and promotePlaybook refuses 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, and promotePlaybook(scope) makes the draft the playbook after review, only when the playbook did not change since the draft was made.
  • parsePlaybook, playbookFile, parsePlaybookEdits and applyPlaybookEdits read 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 firstKeptEntryId on 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 when facts and graph are given. The vectors of the entries, summaries and facts that go are deleted from embeddings, 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: true leaves them.
  • The copies of the entries and their replies in threads forked from theirs go too: a thread lists its forks in metadata.memoryForks (forkThread writes it, FORKS_KEY), each copy names its original in metadata.forkOf, and the forks of forks are followed. A fork does not copy the paused run of its origin.
  • redactEntries needs redactEntry on 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 sender name. An event entry among the ids or their replies (model_change, approval, interrupt, label, custom, or a compaction among the ids) is deleted, not redacted, and listed in entries.deleted.
  • deleteThread also 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. deleteUser deletes every thread whose metadata.userId is 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 in metadata.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 whose metadata.userId is the user and the embeddings with that metadata (deleteByFilter, where the store has it). It lists every thread of the store to find them.
  • rewind deletes nothing: the entries after toEntryId stay on a branch of their own, and the items that stood only on them become stale with staleBecause: { 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 at toEntryId already 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. CompactionService refuses to write a summary whose entries were deleted or redacted while it was written, and MemoryExtractor.extract takes beforeWrite(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