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.