Skip to content

Memory system

Most frameworks treat memory as one undifferentiated bag. Graphorin treats it as six layers, each with its own lifecycle, conflict-resolution strategy, and privacy posture. Together they give your assistant a memory it can actually live with — for years.

The six tiers

TierWhat it storesRead surfaceWrite surface
workingShort structured blocks holding what the assistant is doing right now — persona, current task, immediate context.list, read, compiledefine, write, patch, attach, detach
sessionThe rolling message log of the current conversation.list, search, attributedForpush, flushImportant, compact
episodicThings that happened — decisions, events, milestones — captured with proper bi-temporal validity.recent, searchrecord
semanticFacts about you, the world, the task. Conflicts resolved through a multi-stage pipeline.searchremember, supersede, forget
proceduralHow to do things — workflows, recipes, learned patterns.list, activatedefine, remove
sharedCommon knowledge across multiple agents in the same household, team, or organisation.listForattach, detach

The facade

Every tier is wired through one entry point — createMemory({ ... }):

ts
import { createSqliteStore } from '@graphorin/store-sqlite';
import { createTransformersJsEmbedder } from '@graphorin/embedder-transformersjs';
import { createMemory } from '@graphorin/memory';

const sqlite = await createSqliteStore({ path: './assistant.db' });
await sqlite.init();

const memory = createMemory({
  store: sqlite.memory,
  embeddings: sqlite.embeddings,
  embedder: createTransformersJsEmbedder(),
});

await memory.semantic.remember(
  { userId: 'alex' },
  { text: 'Loves mountain hiking and fresh espresso.' },
);

const hits = await memory.semantic.search(
  { userId: 'alex' },
  'mountain trip ideas',
);

The nine memory tools

memory.tools is a typed Tool[] ready to register with @graphorin/tools. Every entry exposes typed input / output schemas, the right memory-modification guard tier, and the right sideEffectClass so that the agent runtime can sandbox and audit it.

ToolTierPurpose
block_appendworkingAppend text to a working memory block.
block_replaceworkingReplace a unique substring inside a block.
block_rethinkworkingReplace a block's value entirely.
fact_remembersemanticPersist a new fact through the multi-stage conflict pipeline.
fact_searchsemanticHybrid (vector + FTS5 + RRF) search over facts.
fact_supersedesemanticMark an old fact superseded by a new one.
fact_forgetsemanticSoft-delete a fact (kept for replay).
recall_episodesepisodicTriple-signal episode retrieval.
conversation_searchsessionFTS5 search over the active session messages.

Semantic memory composes dense-vector results with full-text (FTS5) results and fuses them through the built-in Reciprocal Rank Fusion reranker (k=60 by default). The fusion is deterministic, requires no extra model, and rarely needs tuning.

ts
import { RRFReranker } from '@graphorin/memory/search';

memory.semantic.setReranker(new RRFReranker({ k: 60 }));

Plug in any ReRanker implementation — a cross-encoder model, an LLM judge, a custom scoring function — when the default is not sharp enough. The optional @graphorin/reranker-transformersjs and @graphorin/reranker-llm packages ship reference implementations.

Multi-stage conflict resolution

Every semantic.remember(...) call flows through five stages in order:

  1. Exact dedup. MD5 hash on the canonical (lowercase, collapsed-whitespace, trimmed) candidate body short-circuits on a hit.
  2. Embedding three-zone. Top-K neighbours from searchVector classify the candidate into HOT (>= 0.95), NEAR-DUP (>= 0.85), CONFLICT-CHECK (> 0.4), or COLD. HOT zone always dedups (semantic identity outranks every other signal).
  3. Heuristic regex. The active locale pack's supersede + negation markers fire when the candidate has an explicit change signal (moved to, no longer, got promoted, …).
  4. Subject / predicate. Naive (subject, predicate, object) split using the locale pack's predicate normalisers; matching subject + predicate with a different object is a strong supersede signal.
  5. Defer to deep LLM judge. Stages 1–4 yielded no decision but the candidate sits in CONFLICT-CHECK zone — the row is admitted pending and queued for the consolidator's deep phase.

Every decision lands one row in the fact_conflicts table with the producing stage, the detection zone, the cosine similarity (where applicable), and a reason string. A memory.conflict span is emitted per call. The English locale pack ships by default; additional locales plug in via defineLocalePack({...}).

Bi-temporal storage

Fact writes set validFrom = now and leave validTo = null; supersede chains are kept intact for replay. Old facts are superseded, never silently overwritten — every change is auditable.

ts
const decision = await memory.semantic.rememberWithDecision(scope, {
  text: 'I just moved to Tbilisi for the new gig.',
});
console.log(decision.kind);
// 'supersede' | 'dedup' | 'pending' | 'admit'

Background consolidator

A separate background process (Consolidator) distils long conversations into long-term knowledge. It runs in three phases with a built-in cost budget so it can never run away with your bill:

PhaseWhat it does
LightSummarisation + conflict-resolution flush of pending rows.
StandardEpisode formation, semantic promotion, cross-conflict resolution.
DeepCross-session pattern detection, procedural extraction, shared-tier promotion.

Per-tier defaults from CONSOLIDATOR_TIER_DEFAULTS:

TierPhases enabledmaxTokensPerDaymaxCostPerDay (USD)onExceed
'free' (default)light only0 (effectively no-op)0'pause'
'cheap'light + standard50 0000.20'pause'
'standard'light + standard + deep200 0001.00'log'
'full'light + standard + deep1 000 0005.00'log'
'custom'operator-definedoperator must setoperator must setoperator must set

The default 'free' tier registers the light phase but pins both ceilings to zero, so consolidation effectively does nothing until you opt in. Override when you want memory to feel alive after the first conversation:

ts
createMemory({
  store: sqlite.memory,
  embeddings: sqlite.embeddings,
  embedder: createTransformersJsEmbedder(),
  consolidator: { tier: 'cheap', enabled: true, provider },
});

'custom' requires explicit ceilings.maxTokensPerDay + ceilings.maxCostPerDay (and cheapModel / deepModel if those phases are enabled) — CustomTierMisconfiguredError is thrown otherwise. The full CONSOLIDATOR_TIER_DEFAULTS table is exported from @graphorin/memory.

Embedder migration

Switching embedders silently is a footgun — old vectors are not comparable to new ones. The runner in @graphorin/memory/migration makes the change explicit:

ts
import { migrateEmbedder } from '@graphorin/memory/migration';
import { createTransformersJsEmbedder } from '@graphorin/embedder-transformersjs';

const target = createTransformersJsEmbedder({ model: 'Xenova/multilingual-e5-large' });

for await (const progress of migrateEmbedder({
  store: sqlite,
  embeddings: sqlite.embeddings,
  source: memory.embedder,
  target,
  strategy: 'auto-migrate',
})) {
  console.log(`${progress.processed}/${progress.total} (${progress.kind})`);
}
StrategyBehaviour
lock-on-first (default)Refuses any silent embedder swap with an actionable error pointing at the planned migration.
multi-activeKeeps both vec0 tables alive — reads union, writes go to the active embedder.
auto-migrateRe-embeds existing rows in resumable batches (checkpointed via migration_state; cancellable with AbortSignal).

Privacy levels

Every memory row carries a Sensitivity tag — public, internal, or secret. The tag flows through traces, exports, and the provider middleware. Sensitive content is redacted by default; you cannot accidentally turn redaction off.

Next steps


Graphorin · v0.1.0 · MIT License · © 2026 Oleksiy Stepurenko