Docs menu
Conversation memory (provenance-backed)
Semantic recall, a recent window, schema working memory and replayable compaction — plus a journal record of what memory actually put in front of the model, and what it left out.
What it is for#
A prompt is not a transcript; it is a composition. Memory assembles it from a recent window, semantic recall, condensed observations and working memory — and by the time the model answers, nobody can see which of those carried the answer. That is where most memory implementations stop.
Two things follow from putting memory on the journal. First, the composition freezes: loadContext runs before the input is persisted, so the exact prompt is replayable. Second, the LLM calls memory itself makes — summarizing history into observations, condensing observations — are journaled through durableProcessorStep, so calling again with the same sequence does not re-run the model and produces the identical summary. Compaction is replayable, not merely repeatable.
Storage is split across two ports on purpose. MemoryStore (storage.memory) holds threads, messages, working memory and observations — the derived, queryable side. RunJournal (storage.runs) holds the memoized LLM calls and the durable progress.
Install / import#
import { AgentMemory } from '@gnldev/memory';
import { SqliteStorage } from '@gnldev/durable/sqlite';AgentMemory implements the Memory interface runDurable expects, and adds a richer loadContext hook on top. It needs a storage that provides the memory capability — the in-memory, SQLite and Postgres adapters all do.
Recall and the recent window#
recentN is the tail that is always included. recall is the query-driven half, and its options reach the store end to end: topK, threshold, scope, messageRange (expand each hit with its neighbours by seq, deduped) and filter with the usual operators — $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin.
const storage = new SqliteStorage('app.db');
const memory = new AgentMemory({
storage,
embed, // a real embedding model for real recall
recentN: 10, // the tail that is always included
recall: { topK: 5, threshold: 0.7, messageRange: 1 },
});
const gnl = createGnl({ storage, agents, memory }); // top level, not per agent
await gnl.run('support', {
runId: 'ticket-9',
threadId: 'user-42', // the conversation
resourceId: 'user-42', // recall may reach across this user's threads
prompt: 'What did we decide about the refund?',
});Pass a threadId per conversation and a resourceId when recall should reach across a user's threads.
Working memory#
Working memory is the structured state the agent keeps about the conversation — a zod schema or a free-form template. When it is on, updateWorkingMemory is registered as a tool and the current state is rendered into the system message. The tool is wrapped by durableTool inside runDurable, so the merge is journaled: a resume does not apply the same patch twice.
readOnly injects the state without registering the update tool, and scope chooses whether the state belongs to the thread or to the resource.
new AgentMemory({
storage,
workingMemory: {
schema: z.object({ plan: z.string(), openQuestions: z.array(z.string()) }),
scope: 'thread', // or 'resource'
// readOnly: true // inject the state, do not register the update tool
},
});Observational memory — replayable compaction#
When a thread outgrows the window, the observer distils earlier history into observations and the reflector condenses those observations further. Both are model calls, and both are journaled by sequence: called again with the same seq, the model does not run and the summary is byte-identical.
Each observation records the message range it was distilled from (fromSeq/toSeq/threadId), so a condensed memory can still be traced back to what produced it. Retrieval over observations (recallObservations, createOmRecallTool) is opt-in — no preset registers that tool for you, so turning observational memory on never silently hands the model a new tool.
new AgentMemory({
storage,
observationalMemory: { model, tokenThreshold: 4000 },
});
// the same seq -> the model is NOT called again and the summary is identical
// each observation keeps { threadId, fromSeq, toSeq } — traceable to its sourceProvenance — what actually went to the model#
Every run that used memory writes a :memctx record. It answers the question counts alone cannot: not only how many messages were injected, but which ones — each as a ref with thread, seq, role, a short preview and, for recall hits, the score.
// journal key: <runId>:memctx
{
recalled: [
{ threadId: 'user-42', seq: 18, role: 'user', preview: 'the refund was approved…', score: 0.83 },
{ threadId: 'user-42', seq: 19, role: 'assistant', preview: '→ refund({"id":"r-7"})', reason: 'repair' },
],
recentCount: 10,
recent: [ /* capped at PROVENANCE_RECENT_CAP = 24 */ ],
droppedCount: 1,
dropped: [ { threadId: 'user-42', seq: 11, role: 'assistant', preview: '→ charge({…})' } ],
observationCount: 3,
workingMemoryChars: 412,
}The field worth reading twice is dropped. Recall sometimes selects a message that cannot be injected — a recalled tool-call whose tool-result cannot be reproduced would make the provider refuse the whole prompt. Leaving it out is right; leaving it out silently is indistinguishable from recall finding nothing, and that difference matters to whoever is asking why the model forgot something.
It is deliberately kept out of recalled. That list is consumed as "what was provably injected" — the counterfactual replay in replay-regression subtracts it from the prompt, so a message listed there that never reached the model would have it subtract something that was never added.
API#
AgentMemoryImplements the Memory interface plus a loadContext hook. Config: storage, embed?, recentN?, recall?, workingMemory?, observationalMemory?, generateTitle?.
LoadedContext{ messages, system?, tools?, provenance? } — what memory composed for this turn.
MemoryContextProvenance{ recalled[], recentCount, recent?[], droppedCount?, dropped?[], observationCount?, workingMemoryChars? } — the ':memctx' read model. Exported from @gnldev/durable, not @gnldev/memory.
RecalledMessageRef{ threadId, seq, role, preview, score?, reason? } — reason: 'repair' marks a row pulled in to answer a recalled tool-call rather than chosen by similarity. Exported from @gnldev/durable, not @gnldev/memory.
createWorkingMemoryToolThe updateWorkingMemory AI SDK tool; apply performs the merge and returns the new state.
observeDistils history into observations; the model call is journaled by sequence.
reflectCondenses observations further; journaled the same way.
createOmRecallToolWraps a caller-supplied recall closure as a tool — opt-in, never auto-registered.
memoryPresetA ready AgentMemory configuration; defaultEmbed is not auto-wired, so it cannot silently change recall.
createGnl({ storage, agents, memory }). AgentConfig has no memory field, so a memory object placed next to model and tools inside an agent does nothing — and the symptom is a thread with zero stored messages rather than an error.createDefaultEmbed is a deterministic token-hash embedding with no dependencies — L2-normalised, so it works with cosine similarity and is useful in tests and examples. It is not a semantic model. Real recall needs a real embedding model, which is also why the presets do not wire it in for you.