memory

js/ai/memory.ts

fino:ai/memory — durable thread memory, working memory, and vector recall.

This module stores information that should outlive a single model context. It is deliberately separate from MessageHistory: history is the current model-facing sequence owned by a HistoryStrategy, while memory stores durable conversation messages, resource chunks, embeddings, and structured working-memory patches that sessions or strategies may recall.

Storage model

SqliteMemory stores three kinds of state in one sqlite database: thread messages in chronological order, working memory as a single JSON object per thread, and ingested documents as text chunks with embeddings. Semantic recall requires the sqlite vector extension (vec0) and a non-zero embedding dimension; when either is missing the memory still works for chronological history and working memory and reports semanticAvailable: false, with recall() degrading to an empty recalled list.

Memory is scoped by threadId for conversation state and by resourceId for cross-thread resource ingestion. thread(id) creates another view over the same database with a different thread scope; only the instance returned by memory() / SqliteMemory.open() owns the database handle, so closing a thread() view is a no-op. Close (or await using) the owning memory when the application is done with it.

retriever() wraps a memory in the narrow Retriever contract for RAG call sites that only need semantic hits, not the full recalled context.

import { memory } from 'fino:ai/memory';
import { openai } from 'fino:ai/model';

const embedder = openai({ dimensions: 1536 });
const mem = await memory({
  path: './agent-memory.db',
  embedder,
  threadId: 'support-thread',
});

await mem.append({ role: 'user', content: 'Prefers concise answers.' });
await mem.ingest([{ text: 'Refund policy: refunds are available for 30 days.' }]);
const recalled = await mem.recall({ text: 'Can I get a refund?', topK: 3 });
await mem.close();

Types

type Embedder = { embed(texts: string[]): Promise<Float32Array[]>; readonly dimensions: number; }

Embedding provider used by SqliteMemory.

This is the minimal contract memory needs: batch-embed texts into vectors of a fixed, known dimension. Any EmbeddingModel from fino:ai/model satisfies it structurally, and hand-rolled embedders work too — useful for tests or local models. dimensions sizes the sqlite vector table; a value of 0 disables semantic recall entirely.

embed() must return one vector per input text, in input order. ingest() substitutes a zero vector for any missing entry rather than failing.

import type { Embedder } from 'fino:ai/memory';

const embedder: Embedder = {
  dimensions: 384,
  async embed(texts) {
    return texts.map((text) => localModel.encode(text));
  },
};

Interfaces

interface MemoryMessage {

Message persisted in durable memory.

Returned by Memory.append(), Memory.history(), and inside RecalledContext.messages. The id, threadId, and createdAt fields are assigned by the store at append time; callers only supply role and content. Content round-trips through JSON, so structured multi-part content is preserved exactly.

import { memory } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const stored = await mem.append({ role: 'user', content: 'hello' });
console.log(stored.id, new Date(stored.createdAt).toISOString());

Properties

id: string

Unique identifier assigned when the message was appended.

threadId: string

Thread the message belongs to; stamped from the memory's active scope.

role: ModelMessage['role']

Conversation role, matching the ModelMessage role union.

content: ModelMessage['content']

Message content as appended — a string or structured content parts, round-tripped through JSON.

createdAt: number

Append timestamp in milliseconds since the Unix epoch.

interface MemoryQuery {

Query used to recall memory for an agent turn.

All fields are optional. Without text, recall() skips semantic search and only returns conversation history and working memory. With text, the query is embedded and matched against ingested chunks by vector distance.

import type { MemoryQuery } from 'fino:ai/memory';

const query: MemoryQuery = {
  text: 'what is the refund window?',
  topK: 3,
  last: 20,
  filter: { metadata: { topic: 'billing' } },
};
const ctx = await mem.recall(query);

Properties

text?: string

Natural-language query for semantic recall. Omit to fetch only history and working memory.

topK?: number

Maximum number of semantic hits to return. Defaults to 5.

last?: number

Limit the returned conversation history to the most recent N messages. Passed through to history().

scope?: 'thread' | 'resource'

Which chunk scope to search: 'thread' (default) searches chunks ingested under the current thread; 'resource' searches the memory's resource scope, which falls back to the thread id when no resourceId was configured.

filter?: { metadata?: Record<string, unknown>; }

Post-search filter. metadata requires strict equality on every listed top-level key of a chunk's metadata; chunks without metadata never match.

interface RecallHit {

Semantic recall hit returned from ingested memory chunks.

Hits are ordered best-first. score is derived from vector distance as 1 / (1 + distance), so it falls in (0, 1] with higher meaning closer. The citation mirrors the hit's id and metadata so applications can carry source attribution into model prompts or UI without reshaping the hit.

import { memory } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const { recalled } = await mem.recall({ text: 'refund window' });
for (const hit of recalled) {
  console.log(hit.score.toFixed(3), hit.text, hit.citation?.id);
}

Properties

id?: string

Identifier of the stored chunk that matched.

text: string

The chunk text to feed back into the model context.

score: number

Similarity in (0, 1]; computed as 1 / (1 + distance), higher is closer.

metadata?: Record<string, unknown>

Metadata stored with the chunk at ingest time, if any.

citation?: { id?: string; metadata?: Record<string, unknown>; }

Source attribution for the hit — the chunk id and its metadata — suitable for citing recalled content in prompts or UI.

interface RecalledContext {

Combined memory context returned by Memory.recall().

Bundles everything a strategy typically needs to rebuild model context for a turn: recent conversation history, semantically recalled chunks, and the thread's working memory. recalled is empty when the query had no text or when semantic recall is unavailable; workingMemory is null before the first setWorkingMemory() write.

import { memory } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const ctx = await mem.recall({ text: 'deployment steps', last: 10 });
const preamble = ctx.recalled.map((hit) => hit.text).join('\n');
const facts = ctx.workingMemory ?? {};

Properties

messages: MemoryMessage[]

Recent thread messages in chronological order, subject to the query's last limit and the memory's history token budget.

recalled: RecallHit[]

Semantic hits ordered best-first; empty without query text or when semantic recall is unavailable.

workingMemory: Record<string, unknown> | null

The thread's working-memory object, or null if none has been written.

interface MemoryIngestProgress {

Retained progress for the most recent Memory.ingest() call.

Published through the Memory.ingestProgress signal. The documents and chunks totals are set up front when ingestion starts; embedded and stored count up as work completes. active flips back to false when the ingest call finishes, even if it failed partway.

import { memory } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const dispose = mem.ingestProgress.subscribe(({ stored, chunks, active }) => {
  if (active) console.log(`ingested ${stored}/${chunks} chunks`);
});
await mem.ingest([{ text: manualText }]);
dispose();

Properties

active: boolean

True while an ingest() call is running.

documents: number

Number of documents in the current or most recent ingest call.

chunks: number

Total chunks the documents were split into.

embedded: number

Chunks whose embeddings have been computed so far.

stored: number

Chunks written to the database so far.

interface Retriever {

Query helper over Memory.recall().

A retriever is the narrow interface RAG call sites depend on when they only need semantic hits — not conversation history or working memory. Create one with retriever().

import { memory, retriever } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const docs = retriever(mem, { topK: 3 });
const hits = await docs.retrieve('how do I rotate credentials?');

Methods

retrieve(text: string, opts?: Omit<MemoryQuery, 'text'>): Promise<RecallHit[]>

Recall semantic hits for text. Per-call opts override the defaults the retriever was created with.

interface ChunkOptions {

Text chunking options for Memory.ingest().

Documents are split into fixed-size character windows before embedding. Consecutive chunks overlap so that sentences spanning a boundary remain recallable from either side.

import { memory } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
await mem.ingest([{ text: longDocument }], {
  chunk: { size: 800, overlap: 200 },
});

Properties

size?: number

Maximum chunk length in characters. Defaults to 1000. Text at or under this length is stored as a single chunk.

overlap?: number

Characters shared between consecutive chunks. Defaults to 100 and is clamped to size - 1.

interface Memory {

Durable memory interface used by sessions and strategies.

Application code should depend on this interface rather than the concrete SqliteMemory so stores can be swapped in tests or future backends. All operations are scoped to the memory's current threadId; thread(id) produces a re-scoped view over the same underlying store.

Memory implements async disposal, so await using closes it automatically at scope exit.

import { memory } from 'fino:ai/memory';
import type { Memory } from 'fino:ai/memory';

async function rememberTurn(mem: Memory, user: string, reply: string) {
  await mem.append({ role: 'user', content: user });
  await mem.append({ role: 'assistant', content: reply });
}

await using mem = await memory({ path: './memory.db', embedder });
await rememberTurn(mem, 'What is fino?', 'A JS runtime built on V8.');

Readonly Properties

readonly threadId: string

Identifier of the conversation thread this view reads and writes.

readonly semanticAvailable: boolean

Whether semantic (vector) recall is available. When false, ingest() still stores chunk text but recall() returns no semantic hits.

readonly workingMemory: ReadonlySignal<Record<string, unknown> | null>

Retained signal of the working-memory object as written through this instance. Starts as null; it is not preloaded from storage, so use getWorkingMemory() to read state persisted by earlier processes.

readonly ingestProgress: ReadonlySignal<MemoryIngestProgress>

Retained signal reporting progress of the current or most recent ingest() call.

Methods

append(msg: Omit<MemoryMessage, 'id' | 'createdAt' | 'threadId'>): Promise<MemoryMessage>

Persist a message to the thread, assigning its id and timestamp, and return the stored MemoryMessage.

history(opts?: { last?: number; before?: number; }): Promise<MemoryMessage[]>

Fetch thread messages in chronological order. last caps the count from the newest end; before excludes messages at or after the given millisecond timestamp. Implementations may additionally trim to a token budget when last is omitted.

recall(query?: MemoryQuery): Promise<RecalledContext>

Assemble the combined context for a turn: recent history, semantic hits for query.text (when available), and working memory.

ingest(docs: { text: string; metadata?: Record<string, unknown>; }[], opts?: { scope?: 'thread' | 'resource'; chunk?: ChunkOptions; }): Promise<void>

Chunk, embed, and store documents for later semantic recall. scope selects the thread (default) or resource chunk namespace; chunk controls splitting. Progress is published on ingestProgress.

getWorkingMemory(): Promise<Record<string, unknown> | null>

Read the thread's persisted working-memory object, or null if none has been written.

setWorkingMemory(patch: Record<string, unknown>, mode?: 'merge' | 'replace'): Promise<void>

Write working memory. 'merge' (default) shallow-merges patch over the existing object; 'replace' discards prior state entirely.

thread(id: string): Memory

Return a view over the same store scoped to a different thread id.

close(): Promise<void>

Release the underlying store if this instance owns it.

interface SqliteMemoryOptions {

Options for opening sqlite-backed memory.

Only path and embedder are required. Semantic recall activates when the effective embedding dimension is greater than zero and the sqlite build has the vector extension; check semanticAvailable on the opened memory.

import { memory } from 'fino:ai/memory';

const mem = await memory({
  path: '/var/data/agent.db',
  embedder,
  threadId: 'ticket-4821',
  resourceId: 'kb-articles',
  historyTokenBudget: 4000,
});

Properties

path: string

Filesystem path of the sqlite database. Created if it does not exist.

embedder: Embedder

Embedding provider used for ingest() and semantic recall().

threadId?: string

Initial thread scope. A random id is generated when omitted, so pass a stable id to resume an existing conversation.

resourceId?: string

Scope id used for chunks ingested or recalled with scope: 'resource'. Falls back to the thread id when omitted.

dimensions?: number

Embedding dimension override. Defaults to embedder.dimensions; the effective value sizes the vector table, and 0 disables semantic recall.

historyTokenBudget?: number

Approximate token budget applied to history() calls that do not pass last. Oldest messages are dropped once the estimate (about four characters per token) exceeds the budget.

fs?: object

Filesystem provider forwarded to Database.open(), for virtual or sandboxed storage backends.

Functions

function retriever(memory: Memory, defaults: Omit<MemoryQuery, 'text'> = {}): Retriever

Create a retriever over a Memory instance.

The helper keeps RAG call sites concise when an application only needs semantic hits rather than the full recalled conversation and working-memory context. defaults are merged into every query, with per-call options overriding them; each retrieve() call delegates to memory.recall() and returns only the recalled hits.

import { memory, retriever } from 'fino:ai/memory';

const mem = await memory({ path: './memory.db', embedder });
const billingDocs = retriever(mem, {
  topK: 3,
  filter: { metadata: { topic: 'billing' } },
});
const hits = await billingDocs.retrieve('refund window');
const context = hits.map((hit) => hit.text).join('\n');

function memory(opts: SqliteMemoryOptions): Promise<Memory>

Open sqlite-backed memory.

This is the factory-first equivalent of SqliteMemory.open() and the usual entry point: it returns the Memory interface so callers stay decoupled from the concrete store. The returned instance owns the database handle — close it when done, or bind it with await using.

import { memory } from 'fino:ai/memory';

await using mem = await memory({
  path: './agent.db',
  embedder,
  threadId: 'daily-standup',
  historyTokenBudget: 4000,
});
const ctx = await mem.recall({ text: 'open action items' });

Classes

class SqliteMemory implements Memory {

Sqlite-backed memory implementation with optional vector recall.

One database file holds every thread's messages, working memory, and ingested chunks; instances are cheap views scoped by thread id. Construct with the static open() (or the module-level memory() factory) — the constructor is private.

Vector search uses the sqlite vec0 virtual table when the database build supports it and the embedding dimension is non-zero. When unavailable the instance still provides chronological history and working memory, and semanticAvailable reports false.

import { SqliteMemory } from 'fino:ai/memory';

const mem = await SqliteMemory.open({
  path: './agent.db',
  embedder,
  threadId: 'onboarding',
});
await mem.append({ role: 'user', content: 'My name is Ada.' });
await mem.setWorkingMemory({ userName: 'Ada' });
if (mem.semanticAvailable) {
  await mem.ingest([{ text: handbook, metadata: { source: 'handbook' } }]);
}
await mem.close();

Getters

get threadId(): string

Identifier of the conversation thread this instance reads and writes.

get semanticAvailable(): boolean

Whether vector recall is active — requires the sqlite vector extension and a non-zero embedding dimension, and that the vector table could be created at open time.

get workingMemory(): ReadonlySignal<Record<string, unknown> | null>

Retained signal of working memory as written through this instance. Starts as null even when the database already holds working memory; read persisted state with getWorkingMemory().

get ingestProgress(): ReadonlySignal<MemoryIngestProgress>

Retained signal reporting progress of the current or most recent ingest() call.

Static Methods

static async open(opts: SqliteMemoryOptions): Promise<SqliteMemory>

Open (creating if necessary) a sqlite database and return a memory view over it.

The schema — message, working-memory, and chunk tables — is created idempotently, so reopening an existing database preserves all prior state. When vector support is present a vec0 virtual table sized to the embedding dimension is also created; failure to create it silently disables semantic recall rather than failing the open.

The returned instance owns the database handle: close() it when done, or bind it with await using.

import { SqliteMemory } from 'fino:ai/memory';

await using mem = await SqliteMemory.open({
  path: './agent.db',
  embedder,
  threadId: 'support-thread',
});

Methods

async append(msg: Omit<MemoryMessage, 'id' | 'createdAt' | 'threadId'>): Promise<MemoryMessage>

Persist a message to the current thread.

Assigns a unique id and a Date.now() timestamp, serializes the content as JSON, and returns the complete stored MemoryMessage.

async history(opts: { last?: number; before?: number; } = {}): Promise<MemoryMessage[]>

Fetch the thread's messages in chronological order.

last caps the result to the most recent N messages (default 1000); before excludes messages created at or after the given millisecond timestamp, which supports paging backwards through long threads.

When last is omitted and the memory was opened with historyTokenBudget, older messages are dropped once the running estimate (about four characters per token, newest first) exceeds the budget — so the most recent messages always survive trimming.

async recall(query: MemoryQuery = {}): Promise<RecalledContext>

Assemble the combined memory context for a turn.

Always returns recent history (honouring query.last) and the thread's working memory. When query.text is set and semantic recall is available, the text is embedded and matched against stored chunks by vector distance: the search over-fetches (topK * 3 nearest neighbours), filters to the requested scope and metadata, then returns the best topK hits (default 5). Without query text, without vector support, or when the embedder returns an empty vector, recalled is an empty array.

const ctx = await mem.recall({
  text: 'what did we decide about caching?',
  topK: 3,
  last: 20,
});
async ingest(docs: { text: string; metadata?: Record<string, unknown>; }[], opts?: { scope?: 'thread' | 'resource'; chunk?: ChunkOptions; }): Promise<void>

Chunk, embed, and store documents for semantic recall.

Each document is split per opts.chunk (1000-character chunks with 100-character overlap by default), embedded one document at a time, and written under the thread scope or — with scope: 'resource' — the memory's resource scope. Chunk text and metadata are stored even when vector search is unavailable; vectors are additionally indexed only when semanticAvailable is true. A missing embedding falls back to a zero vector rather than failing the ingest.

Progress is published on the ingestProgress signal throughout, and active is reset to false even if embedding or storage throws.

await mem.ingest(
  [
    { text: policyDoc, metadata: { source: 'policy', topic: 'billing' } },
    { text: runbookDoc, metadata: { source: 'runbook', topic: 'ops' } },
  ],
  { scope: 'resource', chunk: { size: 800, overlap: 150 } },
);
async getWorkingMemory(): Promise<Record<string, unknown> | null>

Read the thread's persisted working-memory object.

Returns null before the first setWorkingMemory() write. Unlike the workingMemory signal, this always reflects the database, including state written by earlier processes.

async setWorkingMemory( patch: Record<string, unknown>, mode: 'merge' | 'replace' = 'merge' ): Promise<void>

Write working memory for the thread and publish it on the workingMemory signal.

In 'merge' mode (the default) patch is shallow-merged over the existing object — top-level keys in the patch win, other keys are preserved. 'replace' discards prior state and stores patch as-is.

await mem.setWorkingMemory({ userName: 'Ada', plan: 'pro' });
await mem.setWorkingMemory({ plan: 'enterprise' });          // merge keeps userName
await mem.setWorkingMemory({ reset: true }, 'replace');      // drops everything else
thread(id: string): SqliteMemory

Create a view over the same database scoped to a different thread.

The view shares the connection, embedder, and configuration but has its own signals and does not own the database handle — closing it is a no-op, and it becomes unusable once the original owning memory is closed.

const support = await memory({ path: './agent.db', embedder });
const ticketA = support.thread('ticket-1001');
const ticketB = support.thread('ticket-1002');
await ticketA.append({ role: 'user', content: 'Printer is on fire.' });
async close(): Promise<void>

Close the underlying database if this instance owns it.

Views created with thread() do not own the handle, so calling close() on them does nothing; close the instance returned by open() instead.