Cookbook / Agent memory · Intermediate
Long-term memory for an agent
An agent turns each chat message into entities, facts, and decisions, writes them to a graph, and exits. A later session starts with an empty prompt and answers "what do you remember about the orders service?" from the graph alone. When a decision changes, the new one supersedes the old one, and recall shows both what is true now and what it replaced.
The graph looks like this:
A slot names what a fact or decision is about, such as
orders service/owner. When a new item fills a slot that already holds a
current item, the old one gets a supersededAt timestamp and a
SUPERSEDES edge from its replacement. Nothing is deleted.
In the real program, session 1 and session 2 are two Python processes that
share one agent.rl file. On this page everything runs in one in-memory
database, so the two sessions are consecutive steps against the same graph.
Set up the schema
Entity keys are unique, so MERGE finds the same entity whichever message
mentions it. Session ids are unique too.
Show the setup step
CREATE CONSTRAINT entity_key IF NOT EXISTS FOR (e:Entity) REQUIRE e.key IS UNIQUE;
CREATE CONSTRAINT session_id IF NOT EXISTS FOR (s:Session) REQUIRE s.id IS UNIQUE(no rows) · constraints added: 2
Session 1: extract entities, facts, and decisions
Session 1 hears four messages about a new orders service. For each one, the
agent asks an LLM for structured output: the entities mentioned, durable
facts, and decisions with their rationale. Each fact and decision names its
slot and the entities it is about.
import json
from openai import OpenAI
SYSTEM_PROMPT = """You maintain an engineering team's long-term memory.
From the user's message extract:
- entities: people, teams, services, technologies, events mentioned (canonical names).
- facts: durable statements. `slot` is a stable key for what the fact is about, e.g.
"orders service/owner"; reuse an existing slot when the message updates it, else "".
- decisions: choices the team made, with rationale; slot like "orders service/database".
`about` lists entity names each fact/decision concerns. Known slots: {slots}"""
def extract(text: str, slots: list[str]) -> dict:
reply = OpenAI().chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT.replace("{slots}", json.dumps(slots))},
{"role": "user", "content": text},
],
# EXTRACTION_SCHEMA requires entities [{name, kind}], facts [{text, slot, about}],
# and decisions [{text, rationale, slot, about}]
response_format={"type": "json_schema", "json_schema": {
"name": "memory_extraction", "schema": EXTRACTION_SCHEMA, "strict": True}},
)
return json.loads(reply.choices[0].message.content)This page does not call a model. The session-1 data block (in the Data
section at the end) holds session 1's four messages, each with its extraction and a fixed timestamp. The
extractions are hand-written stand-ins for model output, the same ones the
program replays when it runs offline.
Session 1: remember
The agent's remember step is five statements:
- Record the session and its messages, and
MERGEevery entity mentioned. The key is the lowercased name, so "Postgres" and "postgres" meet. - Create each fact, linked
FROMits message andABOUTits entities. - The same for decisions, which also carry a rationale.
- Supersede. For each new item with a slot, a
CALLsubquery finds the newest older item of the same label in that slot. The old item getssupersededAt, and the new one points at it withSUPERSEDES. A fact only replaces a fact, and a decision only a decision. Session 1 has nothing to replace yet. - Report what each message stored.
MERGE (s:Session {id: $session}) ON CREATE SET s.startedAt = datetime($startedAt)
WITH s
UNWIND $messages AS msg
CREATE (m:Message {seq: msg.seq, text: msg.text,
at: datetime(msg.at)})-[:IN_SESSION]->(s)
WITH m, msg
UNWIND msg.entities AS ent
MERGE (e:Entity {key: toLower(ent.name)})
ON CREATE SET e.name = ent.name, e.kind = ent.kind
MERGE (m)-[:MENTIONS]->(e);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
UNWIND msg.facts AS f
CREATE (x:Fact {text: f.text, slot: f.slot, seq: m.seq, at: m.at})-[:FROM]->(m)
WITH x, f
UNWIND f.about AS name
MATCH (e:Entity {key: toLower(name)})
MERGE (x)-[:ABOUT]->(e);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
UNWIND msg.decisions AS d
CREATE (x:Decision {text: d.text, rationale: d.rationale, slot: d.slot,
seq: m.seq, at: m.at})-[:FROM]->(m)
WITH x, d
UNWIND d.about AS name
MATCH (e:Entity {key: toLower(name)})
MERGE (x)-[:ABOUT]->(e);
UNWIND $messages AS msg
MATCH (new:Fact|Decision {seq: msg.seq})
WHERE new.slot <> ''
CALL (new) {
MATCH (old:Fact|Decision {slot: new.slot})
WHERE old.seq < new.seq AND labels(old) = labels(new)
RETURN old ORDER BY old.seq DESC LIMIT 1
}
SET old.supersededAt = new.at
CREATE (new)-[:SUPERSEDES]->(old);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
RETURN m.seq AS seq,
COUNT { (m)-[:MENTIONS]->() } AS entities,
COUNT { (:Fact)-[:FROM]->(m) } AS facts,
COUNT { (:Decision)-[:FROM]->(m) } AS decisions
ORDER BY seq| seq | entities | facts | decisions |
|---|---|---|---|
| 1 | 3 | 2 | 0 |
| 2 | 2 | 0 | 1 |
| 3 | 4 | 2 | 0 |
| 4 | 3 | 2 | 0 |
4 rows · nodes created: 20, relationships created: 39, properties set: 67, labels added: 20
| seq | entities | facts | decisions | |-----|----------|-------|-----------| | 1 | 3 | 2 | 0 | | 2 | 2 | 0 | 1 | | 3 | 4 | 2 | 0 | | 4 | 3 | 2 | 0 | 4 rows · nodes created: 20, relationships created: 39, properties set: 67, labels added: 20
Timestamps come from the fixture through datetime(msg.at), so this page's
results never change. The program stamps datetime(), the current time,
when it writes.
What session 1 left behind
MATCH (n)
UNWIND labels(n) AS label
RETURN label, count(*) AS nodes
ORDER BY label| label | nodes |
|---|---|
| "Decision" | 1 |
| "Entity" | 8 |
| "Fact" | 6 |
| "Message" | 4 |
| "Session" | 1 |
5 rows
| label | nodes | |------------|-------| | "Decision" | 1 | | "Entity" | 8 | | "Fact" | 6 | | "Message" | 4 | | "Session" | 1 | 5 rows
In the program, session 1 exits here. Everything it learned is in one file,
and session 2 is a new process that opens it. A path ending in .rl is a
single-file database:
import relate
# session1.py
with relate.connect("agent.rl") as db:
for statement in SCHEMA:
db.execute(statement)
for statement in REMEMBER: # the five statements above
db.execute(statement, session_1) # the session-1 data block
# the process exits; agent.rl holds the memory
# session2.py, later, in a new process
with relate.connect("agent.rl") as db:
rows = db.execute(RECALL, {"question": "What do you remember about the orders service?"}).fetchall()Session 2: recall
Session 2 starts with nothing in its prompt. It finds the entity the
question names, then reads what the graph says about it. toLower($question) CONTAINS e.key matches every entity named in the question, and the longest
key wins. The path through FROM and IN_SESSION says which session
learned each item.
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
MATCH (e)<-[:ABOUT]-(x:Fact|Decision)-[:FROM]->(:Message)-[:IN_SESSION]->(s:Session)
WHERE x.supersededAt IS NULL
RETURN labels(x)[0] AS kind, x.text AS memory, x.rationale AS because, s.id AS session,
COLLECT { MATCH (x)-[:SUPERSEDES]->{1,5}(old)
RETURN old.text ORDER BY old.seq DESC } AS replaced
ORDER BY x.seq, memoryParameters
| kind | memory | because | session | replaced |
|---|---|---|---|---|
| "Fact" | "Alice owns the orders service." | null | "session-1" | [] |
| "Fact" | "The orders service is built for the checkout team." | null | "session-1" | [] |
| "Decision" | "Use Postgres as the orders service database" | "the team already runs Postgres in production" | "session-1" | [] |
| "Fact" | "The billing service consumes OrderPlaced events from the orders service." | null | "session-1" | [] |
| "Fact" | "The orders service publishes OrderPlaced events to Kafka." | null | "session-1" | [] |
5 rows
| kind | memory | because | session | replaced | |------------|----------------------------------------------------------------------------|------------------------------------------------|-------------|----------| | "Fact" | "Alice owns the orders service." | null | "session-1" | [] | | "Fact" | "The orders service is built for the checkout team." | null | "session-1" | [] | | "Decision" | "Use Postgres as the orders service database" | "the team already runs Postgres in production" | "session-1" | [] | | "Fact" | "The billing service consumes OrderPlaced events from the orders service." | null | "session-1" | [] | | "Fact" | "The orders service publishes OrderPlaced events to Kafka." | null | "session-1" | [] | 5 rows
replaced is empty for now. It fills in once session 2 hears about
changes.
The memory reaches past the entity itself. Follow ABOUT out to an entity
that shares a current fact or decision with the orders service, then back in
to that entity's own facts:
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
MATCH (e)<-[:ABOUT]-(x)-[:ABOUT]->(o:Entity)
WHERE x.supersededAt IS NULL AND o <> e
MATCH (o)<-[:ABOUT]-(f:Fact)
WHERE f.supersededAt IS NULL AND NOT (f)-[:ABOUT]->(e)
RETURN DISTINCT o.name AS via, f.text AS fact
ORDER BY via, factParameters
| via | fact |
|---|---|
| "billing service" | "Bob is on call for the billing service." |
| "checkout team" | "Bob is on the checkout team." |
2 rows
| via | fact | |-------------------|-------------------------------------------| | "billing service" | "Bob is on call for the billing service." | | "checkout team" | "Bob is on the checkout team." | 2 rows
Nobody told the agent that Bob matters to the orders service. The graph connects him through the checkout team and the billing service. Try another question, such as "What do you remember about the billing service?".
Session 2: new information arrives
Before extracting anything, the agent asks which slots are in use and passes
them to the model (Known slots: {slots} in the prompt above):
MATCH (x:Fact|Decision)
WHERE x.slot <> '' AND x.supersededAt IS NULL
RETURN DISTINCT x.slot AS slot
ORDER BY slot| slot |
|---|
| "billing service/on-call" |
| "orders service/database" |
| "orders service/owner" |
| "orders service/team" |
4 rows
| slot | |---------------------------| | "billing service/on-call" | | "orders service/database" | | "orders service/owner" | | "orders service/team" | 4 rows
That is how the model can map "Carol is taking over" onto
orders service/owner instead of inventing a new slot. Session 2 then hears
two updates and runs the same remember statements on their extractions,
from the session-2 data block:
MERGE (s:Session {id: $session}) ON CREATE SET s.startedAt = datetime($startedAt)
WITH s
UNWIND $messages AS msg
CREATE (m:Message {seq: msg.seq, text: msg.text,
at: datetime(msg.at)})-[:IN_SESSION]->(s)
WITH m, msg
UNWIND msg.entities AS ent
MERGE (e:Entity {key: toLower(ent.name)})
ON CREATE SET e.name = ent.name, e.kind = ent.kind
MERGE (m)-[:MENTIONS]->(e);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
UNWIND msg.facts AS f
CREATE (x:Fact {text: f.text, slot: f.slot, seq: m.seq, at: m.at})-[:FROM]->(m)
WITH x, f
UNWIND f.about AS name
MATCH (e:Entity {key: toLower(name)})
MERGE (x)-[:ABOUT]->(e);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
UNWIND msg.decisions AS d
CREATE (x:Decision {text: d.text, rationale: d.rationale, slot: d.slot,
seq: m.seq, at: m.at})-[:FROM]->(m)
WITH x, d
UNWIND d.about AS name
MATCH (e:Entity {key: toLower(name)})
MERGE (x)-[:ABOUT]->(e);
UNWIND $messages AS msg
MATCH (new:Fact|Decision {seq: msg.seq})
WHERE new.slot <> ''
CALL (new) {
MATCH (old:Fact|Decision {slot: new.slot})
WHERE old.seq < new.seq AND labels(old) = labels(new)
RETURN old ORDER BY old.seq DESC LIMIT 1
}
SET old.supersededAt = new.at
CREATE (new)-[:SUPERSEDES]->(old);
UNWIND $messages AS msg
MATCH (m:Message {seq: msg.seq})
RETURN m.seq AS seq,
COUNT { (m)-[:MENTIONS]->() } AS entities,
COUNT { (:Fact)-[:FROM]->(m) } AS facts,
COUNT { (:Decision)-[:FROM]->(m) } AS decisions
ORDER BY seqParameters
| seq | entities | facts | decisions |
|---|---|---|---|
| 5 | 3 | 0 | 1 |
| 6 | 3 | 1 | 0 |
2 rows · nodes created: 7, relationships created: 16, properties set: 25, labels added: 7
| seq | entities | facts | decisions | |-----|----------|-------|-----------| | 5 | 3 | 0 | 1 | | 6 | 3 | 1 | 0 | 2 rows · nodes created: 7, relationships created: 16, properties set: 25, labels added: 7
Both updates landed in a slot that already had a current item, so the
supersede statement linked each one to the item it replaces. The slot is
what makes this work: reset the page, set the slot of Carol's fact in
the session-2 parameters to "", and run this step again. The new fact then
sits beside Alice's, and recall reports two owners.
Note The program runs
rememberonce per message. Replaying a batch, the supersede statement still links each new item to its immediate predecessor, because the subquery takes the newest older item rather than every current one. Two updates to one slot in a batch form a chain, not a fan.
Recall again
The same query, the same question. Carol owns the service now, CockroachDB
is the database, and each replacement lists what it superseded, newest
first. -[:SUPERSEDES]->{1,5} follows up to five replacements back.
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
MATCH (e)<-[:ABOUT]-(x:Fact|Decision)-[:FROM]->(:Message)-[:IN_SESSION]->(s:Session)
WHERE x.supersededAt IS NULL
RETURN labels(x)[0] AS kind, x.text AS memory, x.rationale AS because, s.id AS session,
COLLECT { MATCH (x)-[:SUPERSEDES]->{1,5}(old)
RETURN old.text ORDER BY old.seq DESC } AS replaced
ORDER BY x.seq, memoryParameters
| kind | memory | because | session | replaced |
|---|---|---|---|---|
| "Fact" | "The orders service is built for the checkout team." | null | "session-1" | [] |
| "Fact" | "The billing service consumes OrderPlaced events from the orders service." | null | "session-1" | [] |
| "Fact" | "The orders service publishes OrderPlaced events to Kafka." | null | "session-1" | [] |
| "Decision" | "Use CockroachDB as the orders service database" | "Postgres failover was too slow in the multi-region test" | "session-2" | ["Use Postgres as the orders service database"] |
| "Fact" | "Carol owns the orders service." | null | "session-2" | ["Alice owns the orders service."] |
5 rows
| kind | memory | because | session | replaced | |------------|----------------------------------------------------------------------------|-----------------------------------------------------------|-------------|-------------------------------------------------| | "Fact" | "The orders service is built for the checkout team." | null | "session-1" | [] | | "Fact" | "The billing service consumes OrderPlaced events from the orders service." | null | "session-1" | [] | | "Fact" | "The orders service publishes OrderPlaced events to Kafka." | null | "session-1" | [] | | "Decision" | "Use CockroachDB as the orders service database" | "Postgres failover was too slow in the multi-region test" | "session-2" | ["Use Postgres as the orders service database"] | | "Fact" | "Carol owns the orders service." | null | "session-2" | ["Alice owns the orders service."] | 5 rows
To see the shape, return the chains themselves. Open the graph view:
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
MATCH path = (e)<-[:ABOUT]-(x:Fact|Decision)-[:SUPERSEDES]->{1,5}(:Fact|Decision)
WHERE x.supersededAt IS NULL
RETURN pathParameters
// 2 rows
(n5:Entity {key: "orders service", kind: "service", name: "orders service"})<-[r51:ABOUT]-(n26:Decision {at: "2026-03-16T14:02:00Z", rationale: "Postgres failover was too slow in the multi-region test", seq: 5, slot: "orders service/database", text: "Use CockroachDB as the orders service database"})-[r53:SUPERSEDES]->(n19:Decision {at: "2026-03-02T09:04:00Z", rationale: "the team already runs Postgres in production", seq: 2, slot: "orders service/database", supersededAt: "2026-03-16T14:02:00Z", text: "Use Postgres as the orders service database"})
(n5)<-[r48:ABOUT]-(n25:Fact {at: "2026-03-16T14:07:00Z", seq: 6, slot: "orders service/owner", text: "Carol owns the orders service."})-[r54:SUPERSEDES]->(n14:Fact {at: "2026-03-02T09:00:00Z", seq: 1, slot: "orders service/owner", supersededAt: "2026-03-16T14:07:00Z", text: "Alice owns the orders service."})What was true on a given day?
Because nothing is deleted, at and supersededAt answer point-in-time
questions. On 9 March, a week after session 1, Alice still owned the service
and the team was on Postgres:
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
WITH e, datetime($asOf) AS t
MATCH (e)<-[:ABOUT]-(x:Fact|Decision)
WHERE x.at <= t AND (x.supersededAt IS NULL OR x.supersededAt > t)
RETURN labels(x)[0] AS kind, x.text AS memory, x.at AS since
ORDER BY x.seq, memoryParameters
| kind | memory | since |
|---|---|---|
| "Fact" | "Alice owns the orders service." | "2026-03-02T09:00:00Z" |
| "Fact" | "The orders service is built for the checkout team." | "2026-03-02T09:00:00Z" |
| "Decision" | "Use Postgres as the orders service database" | "2026-03-02T09:04:00Z" |
| "Fact" | "The billing service consumes OrderPlaced events from the orders service." | "2026-03-02T09:11:00Z" |
| "Fact" | "The orders service publishes OrderPlaced events to Kafka." | "2026-03-02T09:11:00Z" |
5 rows
| kind | memory | since | |------------|----------------------------------------------------------------------------|------------------------| | "Fact" | "Alice owns the orders service." | "2026-03-02T09:00:00Z" | | "Fact" | "The orders service is built for the checkout team." | "2026-03-02T09:00:00Z" | | "Decision" | "Use Postgres as the orders service database" | "2026-03-02T09:04:00Z" | | "Fact" | "The billing service consumes OrderPlaced events from the orders service." | "2026-03-02T09:11:00Z" | | "Fact" | "The orders service publishes OrderPlaced events to Kafka." | "2026-03-02T09:11:00Z" | 5 rows
Edit asOf to 2026-03-16T14:05:00Z, between the two updates: CockroachDB
is in, and Alice still owns the service.
One query for the recall tool
The agent's recall tool wants one document per question, not several
result sets. The program folds the steps above into one query with a
COLLECT {} subquery per section: current facts, current decisions,
superseded facts as history, and facts two hops away. The decisions
subquery nests another COLLECT {} for the chain each decision replaced.
MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key
WITH e ORDER BY size(e.key) DESC LIMIT 1
RETURN e.name AS entity, e.kind AS kind,
COLLECT { MATCH (e)<-[:ABOUT]-(f:Fact) WHERE f.supersededAt IS NULL
MATCH (f)-[:FROM]->(:Message)-[:IN_SESSION]->(s)
RETURN {fact: f.text, session: s.id} ORDER BY f.seq, f.text } AS facts,
COLLECT { MATCH (e)<-[:ABOUT]-(d:Decision) WHERE d.supersededAt IS NULL
MATCH (d)-[:FROM]->(:Message)-[:IN_SESSION]->(s)
RETURN {decision: d.text, rationale: d.rationale, session: s.id,
replaced: COLLECT { MATCH (d)-[:SUPERSEDES]->{1,5}(old)
RETURN old.text ORDER BY old.seq DESC }}
ORDER BY d.seq, d.text } AS decisions,
COLLECT { MATCH (e)<-[:ABOUT]-(f:Fact) WHERE f.supersededAt IS NOT NULL
RETURN f.text ORDER BY f.seq, f.text } AS history,
COLLECT { MATCH (e)<-[:ABOUT]-(x)-[:ABOUT]->(o:Entity)
WHERE x.supersededAt IS NULL AND o <> e
MATCH (o)<-[:ABOUT]-(f2:Fact) WHERE f2.supersededAt IS NULL
AND NOT (f2)-[:ABOUT]->(e)
RETURN DISTINCT {entity: o.name, fact: f2.text} } AS nearbyParameters
| entity | kind | facts | decisions | history | nearby |
|---|---|---|---|---|---|
| "orders service" | "service" | [{fact: "The orders service is built for the checkout team.", session: "session-1"}, {fact: "The billing service consumes OrderPlaced events from the orders service.", session: "session-1"}, {fact: "The orders service publishes OrderPlaced events to Kafka.", session: "session-1"}, {fact: "Carol owns the orders service.", session: "session-2"}] | [{decision: "Use CockroachDB as the orders service database", rationale: "Postgres failover was too slow in the multi-region test", replaced: ["Use Postgres as the orders service database"], session: "session-2"}] | ["Alice owns the orders service."] | [{entity: "billing service", fact: "Bob is on call for the billing service."}, {entity: "checkout team", fact: "Bob is on the checkout team."}] |
1 row
| entity | kind | facts | decisions | history | nearby |
|------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| "orders service" | "service" | [{fact: "The orders service is built for the checkout team.", session: "session-1"}, {fact: "The billing service consumes OrderPlaced events from the orders service.", session: "session-1"}, {fact: "The orders service publishes OrderPlaced events to Kafka.", session: "session-1"}, {fact: "Carol owns the orders service.", session: "session-2"}] | [{decision: "Use CockroachDB as the orders service database", rationale: "Postgres failover was too slow in the multi-region test", replaced: ["Use Postgres as the orders service database"], session: "session-2"}] | ["Alice owns the orders service."] | [{entity: "billing service", fact: "Bob is on call for the billing service."}, {entity: "checkout team", fact: "Bob is on the checkout team."}] |
1 rowThe table clips long values; the tool gets the whole map, with the same content as the steps above. The program hands the map to the LLM, which answers from it and nothing else. Offline, a template turns it into prose instead: "… Carol owns the orders service. Decision: Use CockroachDB as the orders service database (Postgres failover was too slow in the multi-region test); this replaced: Use Postgres as the orders service database. No longer true: Alice owns the orders service."
Session 2 as an agent's tool calls
An agent host that drives relate over MCP sends the same statements as
tools/call requests to relate serve. The trace below replays session 2
that way, on the store the steps build up to slots: the slots query, the
five remember statements of the session-2 step (one call each, the four
writes as write_cypher and the report as read_cypher), and the recall
tool's query. Every statement and every parameter object is one this page
already runs, from the session-2 and question data blocks; the steps are
in the session-2-calls data block. No model chose these calls: the trace
replays the recipe's statements, in the recipe's order. The last step repeats
the recall-tool step, and its rows are that step's.
The trace starts on the database the cells up to slots build. Each step below ran when the page was recorded, one after another on that database, through the mapping the MCP tryout uses; each result is what relate serve's tool returns for that call, built from the engine's result. No agent and no server took part in the recording.
Step 1 of 7: read_cypher. The known slots, which the agent reads before it extracts anything: the statement of the slots step.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (x:Fact|Decision)\nWHERE x.slot <> '' AND x.supersededAt IS NULL\nRETURN DISTINCT x.slot AS slot\nORDER BY slot"}}}| slot |
|---|
| billing service/on-call |
| orders service/database |
| orders service/owner |
| orders service/team |
The rows match cell slots's recording.
Step 2 of 7: write_cypher. Statement 1 of the session-2 step: the session, its two messages, and every entity they mention, with the session-2 data block as the parameters.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"MERGE (s:Session {id: $session}) ON CREATE SET s.startedAt = datetime($startedAt)\nWITH s\nUNWIND $messages AS msg\nCREATE (m:Message {seq: msg.seq, text: msg.text,\n at: datetime(msg.at)})-[:IN_SESSION]->(s)\nWITH m, msg\nUNWIND msg.entities AS ent\nMERGE (e:Entity {key: toLower(ent.name)})\n ON CREATE SET e.name = ent.name, e.kind = ent.kind\nMERGE (m)-[:MENTIONS]->(e)","parameters":{"session":"session-2","startedAt":"2026-03-16T14:02:00Z","messages":[{"seq":5,"at":"2026-03-16T14:02:00Z","text":"Update: Postgres failover was too slow in the multi-region test, so the orders service is switching to CockroachDB.","entities":[{"name":"orders service","kind":"service"},{"name":"Postgres","kind":"technology"},{"name":"CockroachDB","kind":"technology"}],"facts":[],"decisions":[{"text":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","slot":"orders service/database","about":["orders service","CockroachDB"]}]},{"seq":6,"at":"2026-03-16T14:07:00Z","text":"Carol is taking over ownership of the orders service from Alice.","entities":[{"name":"Carol","kind":"person"},{"name":"orders service","kind":"service"},{"name":"Alice","kind":"person"}],"facts":[{"text":"Carol owns the orders service.","slot":"orders service/owner","about":["orders service","Carol"]}],"decisions":[]}]}}}}Counters: nodes_created 5, relationships_created 8, properties_set 14, labels_added 5; rows is empty, as write_cypher returns counters.
Step 3 of 7: write_cypher. Statement 2 of the session-2 step: Carol's fact, linked FROM its message and ABOUT its entities.
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"UNWIND $messages AS msg\nMATCH (m:Message {seq: msg.seq})\nUNWIND msg.facts AS f\nCREATE (x:Fact {text: f.text, slot: f.slot, seq: m.seq, at: m.at})-[:FROM]->(m)\nWITH x, f\nUNWIND f.about AS name\nMATCH (e:Entity {key: toLower(name)})\nMERGE (x)-[:ABOUT]->(e)","parameters":{"session":"session-2","startedAt":"2026-03-16T14:02:00Z","messages":[{"seq":5,"at":"2026-03-16T14:02:00Z","text":"Update: Postgres failover was too slow in the multi-region test, so the orders service is switching to CockroachDB.","entities":[{"name":"orders service","kind":"service"},{"name":"Postgres","kind":"technology"},{"name":"CockroachDB","kind":"technology"}],"facts":[],"decisions":[{"text":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","slot":"orders service/database","about":["orders service","CockroachDB"]}]},{"seq":6,"at":"2026-03-16T14:07:00Z","text":"Carol is taking over ownership of the orders service from Alice.","entities":[{"name":"Carol","kind":"person"},{"name":"orders service","kind":"service"},{"name":"Alice","kind":"person"}],"facts":[{"text":"Carol owns the orders service.","slot":"orders service/owner","about":["orders service","Carol"]}],"decisions":[]}]}}}}Counters: nodes_created 1, relationships_created 3, properties_set 4, labels_added 1; rows is empty, as write_cypher returns counters.
Step 4 of 7: write_cypher. Statement 3 of the session-2 step: the CockroachDB decision, with its rationale.
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"UNWIND $messages AS msg\nMATCH (m:Message {seq: msg.seq})\nUNWIND msg.decisions AS d\nCREATE (x:Decision {text: d.text, rationale: d.rationale, slot: d.slot,\n seq: m.seq, at: m.at})-[:FROM]->(m)\nWITH x, d\nUNWIND d.about AS name\nMATCH (e:Entity {key: toLower(name)})\nMERGE (x)-[:ABOUT]->(e)","parameters":{"session":"session-2","startedAt":"2026-03-16T14:02:00Z","messages":[{"seq":5,"at":"2026-03-16T14:02:00Z","text":"Update: Postgres failover was too slow in the multi-region test, so the orders service is switching to CockroachDB.","entities":[{"name":"orders service","kind":"service"},{"name":"Postgres","kind":"technology"},{"name":"CockroachDB","kind":"technology"}],"facts":[],"decisions":[{"text":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","slot":"orders service/database","about":["orders service","CockroachDB"]}]},{"seq":6,"at":"2026-03-16T14:07:00Z","text":"Carol is taking over ownership of the orders service from Alice.","entities":[{"name":"Carol","kind":"person"},{"name":"orders service","kind":"service"},{"name":"Alice","kind":"person"}],"facts":[{"text":"Carol owns the orders service.","slot":"orders service/owner","about":["orders service","Carol"]}],"decisions":[]}]}}}}Counters: nodes_created 1, relationships_created 3, properties_set 5, labels_added 1; rows is empty, as write_cypher returns counters.
Step 5 of 7: write_cypher. Statement 4 of the session-2 step: each new item supersedes the newest older item in its slot, here Alice's fact and the Postgres decision.
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"UNWIND $messages AS msg\nMATCH (new:Fact|Decision {seq: msg.seq})\nWHERE new.slot <> ''\nCALL (new) {\n MATCH (old:Fact|Decision {slot: new.slot})\n WHERE old.seq < new.seq AND labels(old) = labels(new)\n RETURN old ORDER BY old.seq DESC LIMIT 1\n}\nSET old.supersededAt = new.at\nCREATE (new)-[:SUPERSEDES]->(old)","parameters":{"session":"session-2","startedAt":"2026-03-16T14:02:00Z","messages":[{"seq":5,"at":"2026-03-16T14:02:00Z","text":"Update: Postgres failover was too slow in the multi-region test, so the orders service is switching to CockroachDB.","entities":[{"name":"orders service","kind":"service"},{"name":"Postgres","kind":"technology"},{"name":"CockroachDB","kind":"technology"}],"facts":[],"decisions":[{"text":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","slot":"orders service/database","about":["orders service","CockroachDB"]}]},{"seq":6,"at":"2026-03-16T14:07:00Z","text":"Carol is taking over ownership of the orders service from Alice.","entities":[{"name":"Carol","kind":"person"},{"name":"orders service","kind":"service"},{"name":"Alice","kind":"person"}],"facts":[{"text":"Carol owns the orders service.","slot":"orders service/owner","about":["orders service","Carol"]}],"decisions":[]}]}}}}Counters: relationships_created 2, properties_set 2; rows is empty, as write_cypher returns counters.
Step 6 of 7: read_cypher. Statement 5 of the session-2 step, the report of what each message stored: a read, so it goes as read_cypher.
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"UNWIND $messages AS msg\nMATCH (m:Message {seq: msg.seq})\nRETURN m.seq AS seq,\n COUNT { (m)-[:MENTIONS]->() } AS entities,\n COUNT { (:Fact)-[:FROM]->(m) } AS facts,\n COUNT { (:Decision)-[:FROM]->(m) } AS decisions\nORDER BY seq","parameters":{"session":"session-2","startedAt":"2026-03-16T14:02:00Z","messages":[{"seq":5,"at":"2026-03-16T14:02:00Z","text":"Update: Postgres failover was too slow in the multi-region test, so the orders service is switching to CockroachDB.","entities":[{"name":"orders service","kind":"service"},{"name":"Postgres","kind":"technology"},{"name":"CockroachDB","kind":"technology"}],"facts":[],"decisions":[{"text":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","slot":"orders service/database","about":["orders service","CockroachDB"]}]},{"seq":6,"at":"2026-03-16T14:07:00Z","text":"Carol is taking over ownership of the orders service from Alice.","entities":[{"name":"Carol","kind":"person"},{"name":"orders service","kind":"service"},{"name":"Alice","kind":"person"}],"facts":[{"text":"Carol owns the orders service.","slot":"orders service/owner","about":["orders service","Carol"]}],"decisions":[]}]}}}}| seq | entities | facts | decisions |
|---|---|---|---|
| 5 | 3 | 0 | 1 |
| 6 | 3 | 1 | 0 |
Step 7 of 7: read_cypher. The recall tool's one query and the question: the statement and data of the recall-tool step.
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (e:Entity) WHERE toLower($question) CONTAINS e.key\nWITH e ORDER BY size(e.key) DESC LIMIT 1\nRETURN e.name AS entity, e.kind AS kind,\n COLLECT { MATCH (e)<-[:ABOUT]-(f:Fact) WHERE f.supersededAt IS NULL\n MATCH (f)-[:FROM]->(:Message)-[:IN_SESSION]->(s)\n RETURN {fact: f.text, session: s.id} ORDER BY f.seq, f.text } AS facts,\n COLLECT { MATCH (e)<-[:ABOUT]-(d:Decision) WHERE d.supersededAt IS NULL\n MATCH (d)-[:FROM]->(:Message)-[:IN_SESSION]->(s)\n RETURN {decision: d.text, rationale: d.rationale, session: s.id,\n replaced: COLLECT { MATCH (d)-[:SUPERSEDES]->{1,5}(old)\n RETURN old.text ORDER BY old.seq DESC }}\n ORDER BY d.seq, d.text } AS decisions,\n COLLECT { MATCH (e)<-[:ABOUT]-(f:Fact) WHERE f.supersededAt IS NOT NULL\n RETURN f.text ORDER BY f.seq, f.text } AS history,\n COLLECT { MATCH (e)<-[:ABOUT]-(x)-[:ABOUT]->(o:Entity)\n WHERE x.supersededAt IS NULL AND o <> e\n MATCH (o)<-[:ABOUT]-(f2:Fact) WHERE f2.supersededAt IS NULL\n AND NOT (f2)-[:ABOUT]->(e)\n RETURN DISTINCT {entity: o.name, fact: f2.text} } AS nearby","parameters":{"question":"What do you remember about the orders service?"}}}}| entity | kind | facts | decisions | history | nearby |
|---|---|---|---|---|---|
| orders service | service | [{"fact":"The orders service is built for the checkout team.","session":"session-1"},{"fact":"The billing service consumes OrderPlaced events from the orders service.","session":"session-1"},{"fact":"The orders service publishes OrderPlaced events to Kafka.","session":"session-1"},{"fact":"Carol owns the orders service.","session":"session-2"}] | [{"decision":"Use CockroachDB as the orders service database","rationale":"Postgres failover was too slow in the multi-region test","replaced":["Use Postgres as the orders service database"],"session":"session-2"}] | ["Alice owns the orders service."] | [{"entity":"billing service","fact":"Bob is on call for the billing service."},{"entity":"checkout team","fact":"Bob is on the checkout team."}] |
The rows match cell recall-tool's recording.
Live: Step sends the next call and Play all sends the rest, each to the page's database in your browser on the store the cells build up to the trace's starting cell, and each step shows the result relate serve's tool returns for that call, built from the engine's live result; a trace that would write twice starts on an emptied database, and the database is emptied again when the trace ends, so the page's cells run on what they write.
About the data
- Dataset
- Recorded extractions for 6 chat messages in two sessions, 10 entities, 7 facts, 2 decisions (4 KB)
- Source
- Hand-written stand-ins for the LLM extraction step, from the examples project's 01-agent-memory/data/extractions.json
- License
- Apache-2.0
Every data block is published beside this page as data/<name>.json, and the whole recipe as markdown.
More in Agent memory
- Agent trajectory timelineLog every tool call an agent makes as a timestamped step, then read the last N, the next page, and everything since a cutoff, with constraints that keep newest-first reads correct.
- Bi-temporal decisionsA decision log with two time axes answers both "what was true on this date?" and "what did the agent believe on this date?", through late corrections, a rejection, and a planned change.
- Tool-call provenance for an agentRecord every memory tool call an agent makes in the graph it touched, then audit which call wrote a fact, who read it since, and what the failed calls left behind.