An external memory for LLMs — a bi-temporal knowledge graph with hybrid (keyword + vector) retrieval and a self-improving reflection loop.
Language models are stateless between sessions and their training data goes stale. The usual fix — stuffing documents into a vector store — retrieves blobs of prose and has no notion of how knowledge changes over time.
c0 takes a different approach. It stores knowledge as a graph of concepts and the relationships between them, retrieves the relevant subgraph on demand, and tracks how each fact evolves. The result is a persistent, correctable memory layer you can query in natural language and grow as you work.
query ──▶ ❶ exact match ─▶ ❷ keyword (BM25) ─▶ ❸ hybrid (BM25 + vector, fused by RRF)
│
▼
resolve to a concept node in Neo4j
│
▼
traverse the graph for related context ──▶ answer
│
(no match) ▼
reflection loop: learn from the miss
- Graph storage (Neo4j). Knowledge lives as
Conceptnodes and typed relationships, not as text chunks — so retrieval can traverse from one idea to related ones. - Hybrid retrieval. A tiered cascade: exact match → keyword (Lucene/BM25) → hybrid, which runs keyword and vector search and merges them with Reciprocal Rank Fusion (RRF). Keyword nails exact names and identifiers; vectors catch synonyms and paraphrase; fusion gets the best of both without normalizing incompatible score scales.
- Bi-temporal. Every concept carries two independent timestamps — when it was recorded (transaction time) and when it is true (valid time) — so you can run point-in-time ("as-of") queries, supersede a concept when it evolves, or invalidate it with a causal audit trail. Nothing is deleted; it's time-bounded.
- Self-improving reflection loop. When a lookup finds nothing, the dead end is queued, and an LLM classifies it: commit a genuinely new, reusable concept, discard noise, or queue the uncertain ones for human review. Run it continuously (
c0 reflector run) and c0 fills its own memory gaps as you work — details below.
Correct stale training data. A pre-2026 model insists you create a Shopify app in Admin → "Develop apps". c0 walks its graph and overrides that with a patch — current knowledge wins.
Hybrid retrieval — keyword + vector, fused by RRF. One paraphrased question, three ways: keyword (BM25) misses it, vector understands intent, hybrid fuses both.
Bi-temporal — ask "as of" any point in time. The same question returns the era-correct answer: the Pages Router in 2022, the App Router today, with a dated supersession trail.
Self-improving — a dead end becomes a new concept. A lookup misses, the reflection loop classifies it, and c0 commits the new knowledge to its own graph — no human in the loop.
"It helps" is easy to assert. c0 bench makes it falsifiable: it seeds a
synthetic knowledge world (invented entities no model saw in training), then
answers the same questions three ways — a bare model, a naive flat vector
store (embed → cosine top-k), and c0 — and grades every answer with an LLM
judge. Because the facts are invented, the score isolates what the memory layer
adds, not the model's prior knowledge.
10 questions, 4 categories, 3 trials each (majority vote):
| category | bare model | flat vector RAG | + LLM reranker | c0 |
|---|---|---|---|---|
| simple recall | 0/3 (0%) | 3/3 (100%) | 3/3 (100%) | 3/3 (100%) |
| multi-hop | 0/2 (0%) | 1/2 (50%) | 0/2 (0%) | 2/2 (100%) |
| correction | 0/2 (0%) | 0/2 (0%) | 0/2 (0%) | 2/2 (100%) |
| temporal | 0/3 (0%) | 0/3 (0%) | 0/3 (0%) | 3/3 (100%) |
| overall | 0/10 (0%) | 4/10 (40%) | 3/10 (30%) | 10/10 (100%) |
A vector store handles simple recall and not much else: it can't tell a corrected fact from the stale one it replaced (correction) and has no notion of an effective date (temporal) — and adding an LLM reranker doesn't help, because reranking reorders passages without synthesizing the date/supersession metadata that isn't in the text. Those are exactly what c0's temporal graph represents natively.
c0 bench --seed --arms bare,flat_rag,flat_rerank,c0 --trials 3Full methodology, the synthetic corpus, and honest limitations: BENCH.md.
c0 bench measures end-to-end answer quality; c0 eval measures the narrower
thing the retrieval cascade is responsible for: given a natural-language query,
does the right concept rank in the top k? It scores the cascade over the
same synthetic fixture with the standard IR metrics — recall@k and MRR —
so a change to HybridSearchConfig, the RRF fusion, or the fulltext query builder
that silently degrades retrieval shows up as a number instead of a feeling.
c0 eval --seed --k 3 # full cascade (exact → fulltext → hybrid, temporal)
c0 eval --seed --no-embeddings # fulltext-only; no Ollama/API needed (the CI path)
c0 eval --judge # + opt-in LLM-as-judge context-relevance passThe fulltext-only path is local-first (Neo4j only), so CI runs it as a gate
(--min-recall) and fails the build on a regression — no model dependency
required. Details and the golden set: EVAL.md.
- Rust (2024 edition — 1.85+)
- Neo4j 5 — a
docker-compose.ymlis included - Ollama for local embeddings and the reflection loop's classifier (defaults:
nomic-embed-textfor embeddings,hermes3:8bfor classification) — the whole loop runs locally, no key required - (optional) Claude for the background LLM — opt in with
[claude] enabled = trueto use Claude instead of a local model for classification/extraction, either via an Anthropic API key or your Claude subscription (theclaudeCLI). See Configuration
# 1. Start Neo4j (binds to localhost only)
docker compose up -d
# 2. Pull the embedding model
ollama pull nomic-embed-text
# 3. Build & install
cargo install --path .
# 4. Point c0 at Neo4j (defaults shown; the bundled compose uses no auth)
export NEO4J_URI="bolt://localhost:7687"
export NEO4J_USER="" # empty for the bundled docker-compose
export NEO4J_PASSWORD=""
# export ANTHROPIC_API_KEY="sk-..." # optional; only for [claude] enabled = true (defaults are local Ollama)
# 5. Create indexes (vector + fulltext), then a namespace
c0 migrate
c0 init --namespace my-project
# 6. Add knowledge and recall it
c0 add concept "reciprocal rank fusion" -d "Rank-based fusion of multiple result lists; score = weight/(k+rank)."
c0 add concept "hybrid search" -d "Keyword (BM25) + vector retrieval, fused by RRF." --force
c0 relate "reciprocal rank fusion" USED_BY "hybrid search" # both endpoints must exist
c0 walk "reciprocal rank fusion" # traverses outgoing edges -> "hybrid search"
--forceon the second concept skips the similar-concept guard: closely related ideas often score as near-duplicates, andrelaterequires both endpoints to already exist.
| Command | What it does |
|---|---|
c0 walk <topic> |
Recall: resolve a concept (exact → keyword → hybrid) and traverse for context |
c0 walk <topic> --as-of <date> |
Point-in-time recall (bi-temporal) |
c0 search <query> |
Hybrid search without traversal (--vector-only / --keyword-only) |
c0 add concept <name> -d "<desc>" |
Add a concept (embedded on write) |
c0 add patch <name> --content "<text>" |
Add a knowledge patch that corrects/augments a concept |
c0 relate <a> <TYPE> <b> |
Create a typed relationship |
c0 supersede <old> --with <new> |
Mark a concept evolved into a newer one |
c0 invalidate concept <name> --reason "<why>" |
Retract a concept with a causal trail |
c0 describe <concept> "<new desc>" |
Update a description (and re-embed) |
c0 reflector run |
Run the learning loop: classify dead ends → commit new concepts (see below) |
c0 health --fix |
Check Neo4j / Ollama / indexes |
c0 audit enrich |
Reconnect orphaned concepts to nearest neighbours (--dry-run, --rollback) |
c0 export · c0 audit · c0 move |
Maintenance utilities |
Run c0 --help for the full set.
Beyond the core commands, c0 includes a few subsystems worth knowing about (fuller docs are on the way):
- Live sources —
c0 link source add <name> --url <url>fetches and embeds a page;c0 fetch <query>andc0 link source searchretrieve over them, so external references stay fresh in the graph. - Triggers —
c0 trigger add <regex>(or--semantic) decide when a prompt should consult c0; pair one with a hook for hands-off recall. - Sessions — index your assistant transcripts (build with
--features sessions), thenc0 sessions search,c0 sessions resume, and track spend withc0 sessions cost. - Raw queries & history —
c0 find "<cypher>"runs Cypher directly against the graph;c0 invalidation-chain <name>reads a concept's causal history. - Maintenance —
c0 backfill embeddings,c0 audit,c0 move,c0 export,c0 status,c0 config show.
Run c0 <command> --help for flags.
c0 reads connection details from the environment, with a per-namespace .c0/config.toml for local settings:
| Variable | Default | Purpose |
|---|---|---|
NEO4J_URI |
bolt://localhost:7687 |
Neo4j connection |
NEO4J_USER / NEO4J_PASSWORD |
empty | Neo4j auth |
ANTHROPIC_API_KEY |
— | Optional; used only when [claude] enabled = true. By default, classification & extraction run on a local Ollama model — no key needed |
Embedding host/model (Ollama) default to http://localhost:11434 and nomic-embed-text, and are configurable.
The bundled docker-compose.yml runs Neo4j with auth disabled and binds its ports to
127.0.0.1 only. This is fine for the intended default: a single-user local machine, where
the graph is trusted memory for one person.
Be aware of the tradeoff: with auth off, any local process or user on the machine can read or rewrite the entire memory graph — and because this graph is explicitly positioned to override the model's training data, poisoning it is high-value. If you share the machine, run untrusted local code, or expose Neo4j beyond loopback, enable auth:
# Generate a password once and start Neo4j with it
export NEO4J_AUTH="neo4j/$(openssl rand -hex 16)"
docker compose up -d
# Point the CLI at the same credentials
export NEO4J_USER="neo4j"
export NEO4J_PASSWORD="<the password from NEO4J_AUTH>"The reflection loop, concept extraction, and session enrichment use a chat LLM. By default that's local Ollama — keyless and offline. To use Claude instead, set [claude] in .c0/config.toml to one of:
Anthropic API — billed per token, needs ANTHROPIC_API_KEY:
[claude]
enabled = true
provider = "claude"Your Claude subscription, via the Claude Code CLI — no API key, no per-token cost (it shells out to claude -p, using whatever that CLI is logged into):
[claude]
enabled = true
provider = "claude-cli"
binaries = { claude = "claude" } # path to your `claude` binaryEither way, you can route per task — e.g. local extraction but Claude classification — with classification_provider, extraction_provider, enrichment_provider, and concept_extraction_provider. (droid, codex, and gemini are supported as providers too.)
Unattended setups: the
claude-cliprovider only works where that CLI is authenticated. Interactive/desktop use is fine, but a headlesscron/systemddaemon needs the CLI logged in in that environment or classification will fail — for always-on servers the API key (provider = "claude") is the robust choice.
c0's core recall path — walk / search / add — needs almost nothing. The only moving parts are Neo4j and a single small embedding model (nomic-embed-text, ~140M parameters, well under 1 GB), and both run comfortably CPU-only. No GPU, no cloud. A few gigabytes of free RAM cover the graph, the embedder, and Neo4j's page cache for a personal-scale knowledge base.
The heavier work is optional and runs in the background. The reflection loop, concept extraction, and session enrichment use a chat LLM — and by default that's a local Ollama model (e.g. qwen2.5:7b/:14b for extraction/enrichment, hermes3:8b for classification), so the whole thing runs keyless and offline. Opt into Claude ([claude] enabled = true) only if you want its higher-quality haiku/sonnet judgment — via the Anthropic API or your Claude subscription's claude CLI (Configuration). Because none of this is on the recall hot path, CPU inference is fine — a slow background tick never affects how fast walk feels.
| If you want… | You need | Notes |
|---|---|---|
| Core recall (walk / search / add) | CPU + ~2–4 GB free RAM | Neo4j + nomic-embed-text. No GPU. |
| + run the loop's classifier on-device (no API key) | ~8 GB RAM for a 7B model | local qwen2.5:7b instead of the API; background, so CPU speed is fine |
| Faster / higher-quality local LLM | a GPU (optional) | ~6 GB VRAM runs the embedder + a 7B model fast; 12–24 GB unlocks 14B–32B |
The short version: the embedding hot path is light enough for any laptop, and the only reason to add a GPU is to make the optional local LLM work faster — never to make c0 usable in the first place.
On a slow CPU-only host, watch the enrichment timeout. Recall stays fast (embeddings are tiny), but enriching a large session can take minutes — and if a single call exceeds the per-request timeout (default 600 s) it fails with
TimedOut. If you hit that: raise it withC0_ENRICH_TIMEOUT_SECS, do less per call (C0_ENRICH_MAX_CONCEPTS,C0_ENRICH_TEXT_BUDGET, or a smaller--limit), or use a faster/smaller model. And if you schedule several LLM jobs (enrich, extract, the reflector), serialize them — e.g. wrap each in a sharedflock— so they don't dogpile Ollama's single-threaded queue, where a waiting request still burns its timeout.
How enrichment picks what to read. Each session is enriched from a
C0_ENRICH_TEXT_BUDGET-sized window (default 8,000 chars). Rather than the first N characters, c0 fills that budget with the most representative turns (ranked by similarity to the session's embedding centroid, boosting turns that ran tools and the opening turn), prefixed with a signal block of the files touched and commands run — where library and framework names show up most reliably. SetC0_ENRICH_FULL=1to instead map-reduce over the entire session in budget-sized chunks and merge the concepts (full coverage, more LLM calls — costs scale with session length).
c0 pays off most when you treat the graph as where knowledge lives and keep your CLAUDE.md for protocol — the instruction to consult c0, plus your own house rules. Project facts, API shapes, and architecture decisions go stale fast and bloat every prompt when hard-coded into CLAUDE.md; put them in the graph instead and let the model pull the relevant subgraph on demand, then correct it over time with patches and supersessions.
The one thing CLAUDE.md does need is an instruction to actually reach for c0. A minimal version:
## Memory
Before answering questions about this project's stack, architecture, or APIs,
run `c0 walk "<topic>"` first and use what it returns — it patches stale
training knowledge. As you learn durable facts, write them back with
`c0 add concept` / `c0 relate` so the next session inherits them.Keep your preferences and conventions in CLAUDE.md as usual — just stop hand-maintaining knowledge there. For hands-off recall, wire c0 walk into a Claude Code hook so the lookup happens automatically on matching prompts.
The conceptual version above is enough to get value. Here's the concrete wiring I use day to day.
1. Send memory to the graph, not to files. Claude Code ships a built-in file memory (memory/, MEMORY.md). Run it alongside c0 and you get two unsynced stores. One rule in CLAUDE.md routes everything to the graph instead, where c0 walk can find it:
## Memory → c0, not files
Don't use the built-in file memory (`memory/`, `MEMORY.md`). The graph is the only
persistent store. When you'd save a durable fact, route it to c0 —
`c0 add concept <name> -d "..."` or `c0 add patch <name> --content "..."`.
To recall, `c0 walk "<topic>"`. This rule overrides the default memory behavior.2. Three hooks make recall and reflection automatic, so I never have to remember to use c0. Register them in ~/.claude/settings.json:
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/c0-session-start.sh" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/c0-memory-check.sh" }] }],
"PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/dead-end-reflect.sh" }] }]
}
}c0-memory-check.sh — on every prompt, ask c0 whether the topic is one it knows; if so, tell Claude to walk it before answering:
#!/usr/bin/env bash
input=$(cat); prompt=$(echo "$input" | jq -r '.prompt // empty')
[[ ${#prompt} -lt 15 ]] && exit 0
topic=$(c0 trigger match "$prompt" 2>/dev/null)
if [[ -n "$topic" ]]; then
echo "🧠 Before responding, run: c0 walk \"$topic\""
echo " (loads memory; patches correct stale training data)"
fi
# falls back to `c0 extract-concepts` to catch known concepts no trigger covereddead-end-reflect.sh — when any command prints a DEAD_END, classify the miss right then instead of waiting for the hourly loop:
#!/usr/bin/env bash
out=$(cat | jq -r '.tool_response.stdout // ""')
[[ "$out" == *"DEAD_END:"* ]] || exit 0
q=$(echo "$out" | sed -n 's/.*DEAD_END:[^:]*:\(.*\)/\1/p' | head -1)
[[ -n "$q" ]] && jq -n --arg q "$q" '{hookSpecificOutput:{hookEventName:"PostToolUse",
additionalContext:("Dead end: \""+$q+"\". Classify now: commit a real concept with `c0 add concept`, queue if unsure, discard if noise.")}}'c0-session-start.sh — opens a c0 session and surfaces any dead ends the previous session left unresolved, so misses don't fall through the cracks between sittings.
3. Close the loop unattended. The hooks notice gaps; the reflection loop fills them. Run it on a schedule — see The reflection loop for c0 reflector run and a cron/systemd setup.
This is a workflow suggestion, not a setup requirement — c0 is a plain CLI and works with any assistant (or none). Adopt as much of the pattern as suits you.
If you use Claude Code, an optional feature indexes your session transcripts into the graph so you can semantically search past conversations and jump back into the right one. It's off by default (it couples to Claude Code's transcript format); enable it explicitly:
cargo install --path . --features sessionsThis is the reference example of c0's source-adapter pattern — the same shape any "fill the graph from
This is the part that makes c0 memory rather than a database: it learns from what it fails to find. Every time c0 walk comes up empty, that query is recorded as a dead end (~/.c0/reflector/inbox.jsonl). The reflection loop turns those misses into knowledge — an LLM classifies each one:
- COMMIT — a genuinely new, reusable concept worth adding to the graph
- DISCARD — noise (typos, test queries, local paths)
- QUEUE — uncertain; leave it for a human to judge
Run unattended, this is a flywheel: the more you use c0, the more the gaps in its memory get noticed, classified, and filled — so the next lookup that would have missed now hits.
The loop itself isn't optional — it's what makes c0 a memory that improves rather than a static store. What's optional is how you run it. Recall (walk) and reflection are the two halves of the same flywheel: one reads the graph, the other grows it. Skip the reflection half and the graph only ever holds what you typed in by hand. So the real question isn't whether to close the loop but how — and that's the part that takes tuning: too eager and it spams the graph with noise, too passive and it never learns. Pick a mechanism that fits your workflow and commit to it; everything below is just options.
The simplest is the built-in watch mode — it classifies the inbox, applies the commits, sleeps, and repeats:
c0 reflector run # tick every hour, auto-commit the COMMITs
c0 reflector run --interval 15m # tick more often
c0 reflector run --no-apply # classify only; hold COMMITs for human reviewFor an always-on, unattended setup, prefer discrete scheduled runs over the long-lived
c0 reflector run loop. A systemd timer (or cron) firing process → apply each hour avoids clock
drift, gets journald logging, and — with OnUnitInactiveSec — won't stack a new run on top of a
slow one. Ready-to-edit user units ship in scripts/systemd/:
mkdir -p ~/.config/systemd/user
cp scripts/systemd/c0-reflector.{service,timer} ~/.config/systemd/user/
# edit the Environment= / ExecStart= paths, then:
systemctl --user daemon-reload
systemctl --user enable --now c0-reflector.timerCron works too:
# Classify dead ends every hour and auto-commit. Keep a human in the loop?
# Drop the "&& c0 reflector apply" and run `c0 reflector review` when you check in.
0 * * * * c0 reflector process && c0 reflector applyThe loop is intentionally two-staged (process → apply) so nothing touches the graph until you say so:
c0 reflector status # how many dead ends are waiting, proposed, or queued
c0 reflector process # LLM classifies the inbox (writes pending commits + review queue)
c0 reflector proposed # preview the concepts it wants to COMMIT
c0 reflector apply # add the pending concepts to the graph
c0 reflector review # walk the QUEUE'd items interactively
c0 reflector inbox # show the raw dead-end inbox
c0 reflector notify # emit a summary of the queue state
c0 reflector clear # empty the inbox / pending queuesDraining a backlog by hand — when dead ends have piled up and you want them in the graph now:
c0 reflector status # see how many are waiting
c0 reflector process # classify them (local Ollama by default)
c0 reflector proposed # eyeball what it wants to COMMIT
c0 reflector apply # commit the good ones
c0 reflector review # walk anything it left QUEUE'd for youEasier still: hand the backlog to your terminal agent and let it drive these commands while you decide. Something like "walk me through the reflector inbox interactively" turns the chore into a conversation — the agent runs process, reads back what it wants to COMMIT, and you approve or redirect each call without memorizing the sequence.
Classification runs on a local Ollama model by default (
hermes3:8b) — no key, no cloud, reusing the Ollama you already run for embeddings. Want Claude's higher-quality judgment instead? Point[claude]at the Anthropic API or your Claude subscription'sclaudeCLI — see Configuration.
The retrieval core lives in src/graph.rs (Cypher queries, the BM25/vector/RRF functions, temporal filters) and src/embeddings.rs (Ollama client + cosine similarity). The reflection loop is in src/reflector.rs. Hybrid search defaults — alpha = 0.4 (keyword vs. vector weight), k = 60 (the canonical RRF constant), a 0.3 vector threshold — are defined in HybridSearchConfig.
Issues and PRs welcome. The codebase forbids unsafe and lints with Clippy pedantic; please keep new code warning-clean and run cargo build --features sessions as well as the default build.




