Docs
View as markdown

Keep a diffable memory in the repo

Commit an agent's memory as a directory store whose checkpoint writes readable JSON, so a session's writes show up as a diff to review before merge.

cliagents

Before you start: Install relate

This guide shows you how to keep an agent's memory in the repository as a store a reviewer can read. A directory store created with the json codec The on-disk encoding of a store's snapshot: Parquet (the default), JSON or markdown, fixed when the store is created. Read more in the glossary writes the whole graph to one graph.json at every checkpoint, so the writes of an agent's session show up in git diff as added and removed node An entity in the graph: it carries zero or more labels and a map of properties, and relationships connect it to other nodes. Read more in the glossary and relationship A directed, typed connection between two nodes, with properties of its own; every relationship has exactly one type and a start and an end node. Read more in the glossary, and in a pull request as a change to graph.json. A bad fact is then caught in review, before it is merged.

Prerequisites

  • relate on your PATH (Install relate).
  • git, and jq to print graph.json readably in a diff. GitHub's Ubuntu 24.04 runner image includes jq 1.7.
  • A new, empty directory to work in. The steps create a scratch repository there.

Start a repository

The steps work in a new repository with a local identity, so git commit runs anywhere. In your own repository, skip this step:

git init -q
git config user.name "relate docs"
git config user.email "[email protected]"

Create the memory store with the json codec

--snapshot-format json picks the codec when the store is created, and only then: it applies to an empty directory, and later opens read the codec from the store. This creates memory/ and writes the shared example graph into it. The CLI checkpoints when a run that opened the store for writing exits:

relate query --database ./memory --snapshot-format json --mode envelope <<'EOF'
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace);
EOF
ls -A memory memory/wal memory/.cypherlite/schema
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":3.681333,"execute_ms":3.3569590000000002,"lex_parse_ms":0.013166,"lower_ms":0.017207999999999998,"parse_ms":0.306375,"plan_ms":0.011708999999999999,"result_generation_ms":0.000209,"rewrite_ms":0.0005},"truncated":false}
memory:
.cypherlite
.cypherlite.lock
.cypherlite.writer
graph.json
manifest.json
wal

memory/.cypherlite/schema:
schema.cypher

memory/wal:
.cypherlite.lock
.cypherlite.writer
00000002.log
FileHoldsIn a diff
graph.jsonEvery node with its id, labels and properties, every relationship with its type, its start and end node ids and its properties, then the index and constraint catalogThe facts that changed
manifest.jsonThe codec and the position of the last checkpointTwo counters that move with each checkpoint
.cypherlite/schema/schema.cypherOne CREATE INDEX or CREATE CONSTRAINT statement per entry: the schema folderA schema change: a line added, or one removed
wal/<n>.logThe WAL; after a clean exit it holds only its 8-byte headerRenamed by each session that commits
.cypherlite.lock, .cypherlite.writer, and the same two in wal/Markers that make the directory a storeNever change

Commit the whole directory. A read-only open of a fresh clone needs the markers and the WAL segment: without them it fails with read-only open requires an existing database at <path> or read-only open requires an existing WAL at <path>/wal.

Each node in graph.json is one object, and a relationship points at its nodes by id:

jq -c '.nodes[0], .relationships[0]' memory/graph.json
{"id":0,"labels":["Person"],"properties":{"name":"Ada","role":"engineer"}}
{"id":0,"type":"WORKS_ON","start_node":0,"end_node":3,"properties":{"since":2024}}

Make the diff readable

relate writes graph.json as one line, so a plain line diff marks the whole file as changed. A git textconv driver prints it through jq before diffing, which puts each property on its own line. The driver changes what git diff shows, not what is stored:

printf 'memory/graph.json diff=relate-json\n' > .gitattributes
git config diff.relate-json.textconv "jq ."

.gitattributes is committed with the store; the git config line is per clone, so each reviewer runs it once. Without it, git still shows the change, as one replaced line. A hosted pull request view, such as GitHub's, has no clone's git config, so it does not run the driver either and shows the one-line file as replaced; reviewers read the change with git diff or git show in a clone that has the driver configured.

Commit the store

git add .gitattributes memory
git commit -q -m "Seed the team memory"

Reading the store changes no file. A read-only open writes nothing and runs no checkpoint, so the working tree stays clean:

relate --database ./memory --read-only --mode envelope "MATCH (n) RETURN count(n) AS nodes"
git status --short
{"columnTypes":["INTEGER"],"columns":["nodes"],"notifications":[],"planCache":"miss","rows":[{"nodes":5}],"timings":{"elapsed_ms":0.335791,"execute_ms":0.169542,"lex_parse_ms":0.03125,"lower_ms":0.043125,"parse_ms":0.107042,"plan_ms":0.050708,"result_generation_ms":0.000292,"rewrite_ms":0.008208},"truncated":false}

Record a session's writes

An agent's session is any run that opens the store for writing. Build its writes on MERGE, so a retried session matches what is already there and writes nothing. This one adds a project Ada works on, and the second run of the same statement changes nothing:

relate --database ./memory --mode envelope "MATCH (ada:Person {name: 'Ada'}) MERGE (docs:Project {name: 'docs', language: 'Markdown'}) MERGE (ada)-[:WORKS_ON {since: 2026}]->(docs)"
relate --database ./memory --mode envelope "MATCH (ada:Person {name: 'Ada'}) MERGE (docs:Project {name: 'docs', language: 'Markdown'}) MERGE (ada)-[:WORKS_ON {since: 2026}]->(docs)"
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":1.5647499999999999,"execute_ms":1.341834,"lex_parse_ms":0.008166,"lower_ms":0.010416,"parse_ms":0.210459,"plan_ms":0.00725,"result_generation_ms":0.000167,"rewrite_ms":0.0005},"truncated":false}
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":0.523666,"execute_ms":0.304166,"lex_parse_ms":0.007209,"lower_ms":0.009000000000000001,"parse_ms":0.206875,"plan_ms":0.007459,"result_generation_ms":0.00020800000000000001,"rewrite_ms":0.000334},"truncated":false}
note: write statement changed nothing; the graph is unchanged

A SET that writes a value a property already holds still counts as a write: that session checkpoints and renames the WAL segment, though graph.json stays the same. A MERGE pattern that carries the values, as here, keeps a replayed session silent.

Review the diff

Three files changed: the graph, the manifest's counters, and the WAL segment, which the session's checkpoint replaced with a new empty one:

git status --short
git diff --stat
 M memory/graph.json
 M memory/manifest.json
 D memory/wal/00000002.log
?? memory/wal/00000003.log
 memory/graph.json       | 2 +-
 memory/manifest.json    | 4 ++--
 memory/wal/00000002.log | 1 -
 3 files changed, 3 insertions(+), 4 deletions(-)

The diff of graph.json, through the driver, is what a reviewer reads. The new node is id 5, and the new relationship runs from node 0 (Ada) to node 5:

git diff memory/graph.json
diff --git a/memory/graph.json b/memory/graph.json
index 60b1684..fd6b7d3 100644
--- a/memory/graph.json
+++ b/memory/graph.json
@@ -50,6 +50,16 @@
         "language": "Gherkin",
         "name": "opencypher-tck"
       }
+    },
+    {
+      "id": 5,
+      "labels": [
+        "Project"
+      ],
+      "properties": {
+        "language": "Markdown",
+        "name": "docs"
+      }
     }
   ],
   "relationships": [
@@ -93,6 +103,15 @@
       "start_node": 0,
       "end_node": 1,
       "properties": {}
+    },
+    {
+      "id": 5,
+      "type": "WORKS_ON",
+      "start_node": 0,
+      "end_node": 5,
+      "properties": {
+        "since": 2026
+      }
     }
   ],
   "indexes": [],
@@ -123,8 +142,8 @@
     },
     "derived_constraints": {}
   },
-  "next_node_id": 5,
-  "next_rel_id": 5,
+  "next_node_id": 6,
+  "next_rel_id": 6,
   "graph_type": null,
   "read_only": false
 }

Commit the three files together, git add memory, so the graph, the manifest and the WAL always describe the same checkpoint. A schema change shows up in the same diff, in graph.json's catalog and as a line of schema.cypher; Keep the schema as files covers that folder.

Know when it stops fitting

Each checkpoint rewrites the whole graph.json, and git keeps every version. Measured with relate 0.1.2, nodes with three short properties and one relationship each take about 170 bytes per pair: 167,702 bytes for 1,000 nodes and 999 relationships, 1,733,704 bytes for 10,000 nodes and 9,999 relationships. The file grows with the graph; the diff grows with the session.

WhenKeep
A session changes a few dozen facts a person can readThe json codec, as on this page
Reviewers want one file per entityThe markdown codec: one note per node with its properties and outgoing relationships in YAML frontmatter (Open a store as an Obsidian vault). Its manifest records each note's modification time, so every checkpoint changes the manifest too
The graph is too large to read as textThe default parquet codec, or a single-file store (a .rl file), which git treats as binary; review the schema folder, and check facts with read-only statements

relate clone --to-snapshot-format json <source> <destination> converts an existing store, and the reverse goes back to parquet; Clone and convert a store has the details.

Verify

After the commit, a read-only open of the store reads the new fact, and the tree stays clean:

git add memory
git commit -q -m "Ada works on docs"
relate --database ./memory --read-only --mode envelope "MATCH (:Person {name: 'Ada'})-[w:WORKS_ON]->(p:Project) RETURN p.name AS project, w.since AS since ORDER BY since"
git status --short
{"columnTypes":["STRING","INTEGER"],"columns":["project","since"],"notifications":[],"planCache":"miss","rows":[{"project":"relate","since":2024},{"project":"docs","since":2026}],"timings":{"elapsed_ms":0.471167,"execute_ms":0.25670800000000005,"lex_parse_ms":0.04,"lower_ms":0.044583000000000005,"parse_ms":0.13733399999999998,"plan_ms":0.067,"result_generation_ms":0.00033299999999999996,"rewrite_ms":0.009166},"truncated":false}

If it goes wrong

What you seeCauseRemedy
A merge conflict in graph.json and manifest.jsonTwo branches each wrote the memory; git cannot merge two versions of a one-line fileKeep one side's memory/ whole, then replay the other session's statements on it; MERGE writes only what is missing
Failed to open Relate: storage error: read-only open requires an existing WAL at <path>/wal on a fresh clonewal/ was left out of the commit, or ignoredCommit the whole store directory
Failed to open Relate: storage error: snapshot codec mismatch: directory is json, requested parquet, exit code 1--snapshot-format given for a store that already existsDrop the flag; to change the codec, relate clone --to-snapshot-format
Failed to open Relate: storage error: schema mirror .cypherlite/schema/<file> differs from the schema this store's checkpoint persisted; …A file in the schema folder was edited, or resolved by hand in a mergeDelete .cypherlite/schema/ and open the store for writing once (Keep the schema as files)