Most AI memory is RAG over PDFs — a search index over text humans wrote about their work. InvariantDB stores the work itself: every access, every context, every reasoning step, every decision, with bitemporal provenance. Replay any past state, ask what did the agent know when it acted, train new agents from real behavior. Memory without amnesia.
A graph database is a brain. A vector database is recall. The difference matters the moment your agent has to answer why did I think that? or what did I know at 9am yesterday? — questions similarity search literally cannot answer.
gq_... key.
Get yours →
·
Then either set INVARIANTDB_API_KEY=gq_... in your environment or pass it to the SDK constructor.
A vector database holds chunks of text and finds similar ones. That's recall. It can't tell you:
InvariantDB stores every property along two time axes — VALID (the real-world effective date) and RECORDED (the moment the database wrote it). It records the agent's working memory as a structured graph. It keeps a hash-chained history of node and relationship writes. And it natively understands "this belief was derived from these episodes."
Vector search is still here — built in — so RAG patterns work too. But the graph + bitemporal layer underneath is what lets you replay any past decision.
db.recordEpisode / db.replayEpisodesPer-agent sequential event log. One row per observation / intermediate thought / tool call / decision. Time-ordered, queryable, audit-grade.
// Record an event in the agent's session
CALL db.recordEpisode(
'conv-abc-123', // session id
'observation', // kind: observation | thought | toolCall | decision
'User asked about Q3 forecast',
'{"topic": "forecast", "source": "user_message"}' // payload: a string; a map or list is refused
)
YIELD eventId, ts;
// Replay the session — the first 50 events, oldest first
CALL db.replayEpisodes('conv-abc-123', 50)
YIELD eventId, ts, kind, text, payload
RETURN ts, kind, text, payload
ORDER BY ts;
Events of every session are stored in the same graph. A credential that can read the graph can read every session.
db.recordBelief / db.reviseBelief / db.beliefLineageWhen an agent forms a belief from evidence, record the derivation. When it later revises the belief, you don't lose the prior version — the lineage walks back through every revision.
// "I believe customer acme will churn, based on these episodes"
CALL db.recordBelief(
'churn-risk-acme',
'High churn risk',
0.85,
['event-id-99', 'event-id-104', 'event-id-112']
)
YIELD beliefId;
// Later: revise after new evidence
CALL db.reviseBelief(
'churn-risk-acme',
'Moderate churn risk',
0.55,
'saved by upsell'
)
YIELD oldBeliefId, newBeliefId, revisedAt;
// Walk every version + its evidence (hops=3 populates depth + evidence)
CALL db.beliefLineage('churn-risk-acme', 3)
YIELD beliefId, claim, confidence, createdAt, supersededAt, depth, evidence;
You can replay not just what the agent believes, but why — and what it believed before.
db.consolidateSession / db.sessionSummariesRoll a session's raw episodes into a structured summary. Useful for context-window compression: keep last N raw events + a running summary of everything older.
// Fold a session into a summary. An optional third argument, sinceTs
// (nanoseconds since the Unix epoch), limits the summary to events
// from that time on. Events are never deleted.
CALL db.consolidateSession(
'conv-abc-123',
'Q3 forecast conversation: user asked about revenue, agent walked forecast + risks.'
)
YIELD summaryId, eventCount, firstTs, lastTs;
// List all summaries oldest first
CALL db.sessionSummaries('conv-abc-123')
YIELD summaryId, text, eventCount, createdAt, lastTs;
The summary is a first-class node — searchable, walkable, and itself versioned bitemporally. Re-consolidate later, the previous summary stays in history.
db.applySalienceDecay / db.reinforceSalienceNot every memory is equally important. Salience is a node property that decays with time and reinforces with each access.
// Reinforce when a memory is used in a successful response
CALL db.reinforceSalience($episodeId, 1.0)
YIELD strId, oldSalience, newSalience;
// Periodically decay by label (halve every 6h since last access, floor 0.05)
CALL db.applySalienceDecay('Event', 21600, 0.05)
YIELD nodeId, oldSalience, newSalience;
// Recall by salience
MATCH (:Session {id: 'conv-abc-123'})-[:HAD_EVENT]->(e:Event)
RETURN e ORDER BY e.salience DESC LIMIT 20;
Decay + reinforcement give you a memory that prioritizes without you hand-coding eviction logic.
An admin can issue a short-lived token bound to one graph, a principal and a scope list:
curl -fsS -X POST -H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
--data '{
"graph": "memory",
"principal": "agent-deepthought",
"scope": ["read"],
"ttl_seconds": 300
}' \
https://invariantdb.com/capabilities/issue
Scopes include read, write and admin. ttl_seconds can be at most 300 by default; a longer value is refused. Send the token as Authorization: Capability <token>. A token issued this way is not limited to one session: it can read every session in the graph. To keep conversations apart, use a separate graph for each and bind each token or API key to its graph.
gds.semanticMatchExpandMost RAG returns top-K vector hits and stops. This procedure starts there — top-K semantic seeds — then expands through the graph for N hops along configured edge types.
// Semantic search + 2-hop graph expansion in one call CALL gds.semanticMatchExpand( 'Event', // label 'embedding', // vector property $query_embedding, // query vector 10, // k: number of seed matches 2 // hops: expansion depth ) YIELD nodeId, distance, score RETURN nodeId, distance, score ORDER BY distance, score DESC;
distance is 0 for seed vector hits and 1..N for nodes reached by expansion. The score propagates from the seed so each result keeps its parent similarity. One Cypher call; no app-layer expansion loop.
db.embeddingModelDriftWhen you switch embedding models — text-embedding-3-large → voyage-3 → some-new-thing — old vectors are silently incompatible. InvariantDB stamps each vector with its model identifier and surfaces drift:
CALL db.embeddingModelDrift() YIELD label, prop, lockedModel, currentDefault, driftKind, builtAt, vectorCount RETURN label, prop, lockedModel, currentDefault, driftKind, vectorCount ORDER BY builtAt DESC;
The cure is a re-embed migration. The signal is what you have to ask for elsewhere.
| You want to… | Use |
|---|---|
| Log every step the agent took | db.recordEpisode (1) |
| Compress old context into running summaries | db.consolidateSession (3) |
| Track what the agent believes and why | db.recordBelief + db.beliefLineage (2) |
| Make some memories matter more | db.applySalienceDecay + db.reinforceSalience (4) |
| Give an agent short-lived access to one graph | Capability tokens (5) |
| Replace your vector DB + RAG loop | gds.semanticMatchExpand (6) |
| Detect a stale embedding pipeline | db.embeddingModelDrift (7) |
| Replay what an agent knew at time T | MATCH ... AT VALID '<ts>' (universal — bitemporal) or {query, atTime} body field on /cypher (new 2026-06-27) |
| One-call ranked recall for an agent turn | POST /graphs/{name}/memory/recall (new 2026-06-27) — BM25 + graph-expand + salience rerank server-side, top-k snippets in one round-trip |
/memory/recall — one call replaces 3Before today, an MCP integration that did "find what the agent should remember for this turn" orchestrated 3 cypher calls client-side: gds.fulltextSearch → graph-expand → salience rerank. That's 3 round-trips + your own ranking glue. The new endpoint does it server-side and returns top-k snippets in one call.
curl -H "Authorization: Bearer $INVARIANTDB_API_KEY" -H "content-type: application/json" \
-X POST https://invariantdb.com/e/graphs/$GRAPH/memory/recall \
-d '{"query":"what did the user ask about pricing yesterday","k":5,"hops":1,"salience_weight":0.3}'
Response shape: {snippets:[{id,str_id,score,labels,content,expanded_from}], total_candidates_scored, took_ms}. The response also has mode, the search mode that ran. With the default salience_weight=0, salience does not change the ranking. Defaults: label Memory, property content; to search events written by db.recordEpisode, pass "label": "Event", "property": "text". Cap is k≤50, hops≤3. See quickstart for the per-turn hook pattern.
atTime body field on /cypherThe bitemporal storage was always sound — db.propertyHistory + MATCH ... AT VALID 'ts' have worked since v1. What was missing: an ad-hoc atTime body field on the HTTP endpoint so SDKs could time-travel a regular query without rewriting Cypher. Now it works:
curl -H "Authorization: Bearer $INVARIANTDB_API_KEY" -H "content-type: application/json" \
-X POST https://invariantdb.com/e/graphs/$GRAPH/cypher \
-d '{"query":"MATCH (e:Event) RETURN count(e)","atTime":"2025-01-01T00:00:00Z"}'
Each top-level MATCH in the query is evaluated as of atTime. The request is refused with HTTP 400 if the query already contains a time clause such as AT VALID or AT RECORDED, or has no top-level MATCH. For ad-hoc UIs / replays this is much cleaner than string-concatenating the AT VALID clause yourself.
gq_ and are bearer tokens you pass in the Authorization header.
from invariantdb import Client
db = Client("https://invariantdb.com", api_key="gq_...")
session = "conv-abc-123"
def on_user_message(text):
# 1. Record the inbound observation
db.cypher(graph="memory", query="""
CALL db.recordEpisode($s, 'observation', $text)
""", parameters={"s": session, "text": text})
# 2. Recall: semantic + graph-expansion for what's relevant
relevant = db.cypher(graph="memory", query="""
CALL gds.embedText($text) YIELD embedding
CALL gds.semanticMatchExpand('Event', '_embedding_v1', embedding, 5, 2)
YIELD nodeId, distance, score
MATCH (e:Event) WHERE id(e) = nodeId
RETURN e.text AS text, distance, score
ORDER BY distance, score DESC
""", parameters={"text": text}).rows
# 3. Run your LLM with `relevant` in the prompt
response = call_llm(text, relevant)
# 4. Record the model's thought + decision
db.cypher(graph="memory", query="""
CALL db.recordEpisode($s, 'thought', $reasoning) YIELD eventId AS e1
CALL db.recordEpisode($s, 'decision', $response) YIELD eventId AS e2
RETURN e1, e2
""", parameters={"s": session, "reasoning": response.reasoning,
"response": response.text})
# 5. If the response asserted a belief, record its derivation
for belief in response.beliefs:
db.cypher(graph="memory", query="""
CALL db.recordBelief($key, $content, $confidence, $evidence)
""", parameters={"key": belief.key, "content": belief.content,
"confidence": belief.confidence,
"evidence": belief.evidenceEventIds})
return response
def replay(session, at_ns):
"""Events recorded up to a time. at_ns is nanoseconds since the Unix epoch."""
return db.cypher(graph="memory", query="""
CALL db.replayEpisodes($s, 1000)
YIELD ts, kind, text WHERE ts <= $at
RETURN ts, kind, text ORDER BY ts
""", parameters={"s": session, "at": at_ns}).rows
The procedures above record episodes, beliefs and lineage and replay a session. The example embeds text with the server’s embedding model, so it needs one configured.
db.applySalienceDecay from cron or the worker pool; the engine doesn't sweep automatically.Quickstart → · Features → · Regulated industries → · Demo → · Walkthrough →