Give Your Claude Code Subagents Memory Without a Database
Full graph indexing costs about a thousand times what plain vector indexing costs, and frequently loses. Here is the three-layer file store that replaces it, with a pointer that walks any claim back to the sentence it came from.
>This is the memory layer. Claude Code In Parallel builds the graph that reads and writes it, and the meter that tells you when re-deriving those facts started costing real money.

Hello builders,
Every run of my Claude Code graph starts from nothing. The scoper subagent rediscovers the shape of the repository, the searchers re-derive what the modules are, yesterday’s judgement about a file is gone, and I pay the full per-agent floor again to reach a conclusion I already reached on Tuesday. So I went looking for AI agent memory without a database, found a wall of posts recommending a graph database, and nearly lost a quarter to it.
Here is the version that is four directories and no signup.

Why not a graph database
I’m not going to tell you graph databases are bad. Here are the published numbers instead, because they are startling. Microsoft Research built GraphRAG, then built LazyGraphRAG and measured it against their own earlier system:
LazyGraphRAG data indexing costs are identical to vector RAG and 0.1% of the costs of full GraphRAG.
The same LazyGraphRAG configuration also shows comparable answer quality to GraphRAG Global Search for global queries, but more than 700 times lower query cost.
For 4% of the query cost of GraphRAG global search, LazyGraphRAG significantly outperforms all competing methods on both local and global query types.
Those percentages are easy to read backwards, so here they are the other way round. Full graph indexing cost roughly a thousand times what plain vector indexing cost, and the cheap approach still came out ahead on answer quality. (Microsoft Research, LazyGraphRAG)
While we’re here: you will see a claim that graph approaches deliver “18% better accuracy and 85% lower costs” attributed to Microsoft. I went to the page. Neither number is on it. If you see that pair cited, the citation is decorative.
So I use files, and not because files are elegant. I use them because the thing they get compared against costs three orders of magnitude more to index and frequently loses on quality anyway. If our memory outgrows files we’ll know, because we’ll have measurements.
Base definitions: the five types
What kills these projects is usually the schema, not the storage, and it does it before anybody writes a line of code. Somebody who lived it put it plainly: I overthought the ontology. This froze projects for months.
The antidote is a schema small enough to finish this afternoon, and there is a good one already: POLE+O. Person, Object, Location, Event, Organization. Five types, and the rule attached to it is the important half: extend only on collisions. We add a sixth type when we hit something that genuinely won’t fit and we can name the two things it would be confused with. Not because it feels tidier.
For a graph running over a codebase the five map cleanly enough. Person is an author or a reviewer. Object is a file, module, service, dependency. Location is a path or a repository. Event is a run, a merge, a release, a failure. Organization is a team or an owner.
Add one thing POLE+O doesn’t have, because a practitioner named the gap precisely: you need somewhere to record “tried X, failed because Y” that’s structurally different from facts/preferences. Facts are what’s true. Reasoning traces are what we already tried, and they get read at different times, facts on the way in and traces when something fails. A tried/ directory alongside entities/ costs nothing and saves re-running a failed approach every Tuesday.
The three layers
The layering that works, again from somebody running it: L0 extracted facts, L2 raw sentences, never loaded by default, only fetched when you need to trace something back. As directories:
memory/
entities/ # L0 - the facts. Small, typed, loaded every run.
object/payments-api.json
edges/ # L0 - typed relationships between entities.
payments-api--owned-by--acme-corp.json
raw/ # L2 - the source text. Never loaded by default.
r1785343267-scoper.txt
tried/ # reasoning traces. "X failed because Y."
payments-api-perf.md
An entity file, deliberately boring:
{ "id": "object/payments-api",
"type": "Object",
"name": "payments-api",
"facts": [
{ "k": "owner", "v": "org/acme-corp",
"src": "raw/r1785343267-scoper.txt#L4", "seen_run": "r1785343267" }
] }
The field doing the work is src. If a fact has no pointer back to a source line, there is nothing to check it against later, so the graph goes on repeating it run after run with no way for you to find out where it came from. With the pointer, “why does the system think Acme owns payments-api” is answerable by opening one file and reading one line, which is all the audit trail we’re going to get and all we need.
Walk it once by hand so you believe it. The entity says src: raw/r1785343267-scoper.txt#L4. Line 4 of that file, out of millions never loaded, reads:
payments-api is owned by Acme Corp and on-call is platform-team.
From a fact the graph asserts, to the sentence a node actually saw, in two commands. And the read path is deliberately not a model call. Here it is, whole:
#!/usr/bin/env bash
# recall.sh <query> - L0 facts plus their provenance. No model, no network.
set -uo pipefail
Q=$(printf '%s' "$1" | tr 'A-Z' 'a-z')
for f in memory/entities/*/*.json; do
[ -e "$f" ] || continue
hay=$(jq -r '[.name, (.aliases[]?)] | @tsv' "$f" | tr 'A-Z' 'a-z')
case "$hay" in *"$Q"*) ;; *) continue ;; esac
jq -r '"\(.id) (\(.type))
" + ([.facts[] | " \(.k) = \(.v)\n src: \(.src)"] | join("\n"))' "$f"
done
$ ./recall.sh payments-api
owner = org/acme-corp
src: raw/r1785343267-scoper.txt#L4
0.014 s, nothing billed
Fourteen milliseconds. That is the point of keeping L0 small: the recall a node makes before it starts work has to be free, or nodes will skip it and we’re back to re-deriving everything. Keeping the raw text out of the default load is what makes it affordable, because every node that loads it pays for it again.
One warning. Injected memory is input the node will trust, and a stale fact coming out of the store looks the same to that node as a fresh one. This is the strongest argument for src: when a node produces something odd, the first question is what we fed it, and the answer is one command away.
When is Acme Corp the same as Acme Corporation?
Here is the hardest problem in this, and where I found the most confident advice with the least evidence behind it. When our graph sees “Acme Corp” and already knows “Acme Corporation”, is that one entity or two? A false split leaves two entities where there should be one, and you tend to find that out fairly quickly because answers come back incomplete. A false merge is the expensive one, because the two records become a single record, every fact about either is from then on filed against both, and no part of the system reports that it happened.
There is a rule circulating, stated with great confidence in several places: 0.95 and above auto-merges, above 0.85 triggers human review, and 0.85 or below creates a new node. I couldn’t find a source for those numbers anywhere. Not a paper, not a vendor page, not a repository. And there is a bigger problem than the missing citation: a similarity threshold is meaningless without the similarity function that produced it, and nobody quoting these numbers says what theirs was, so we’re comparing two things that aren’t comparable. A 0.85 from an embedding model and a 0.85 from string distance are not the same 0.85.
So settle it on data. The scorer is six lines, no embeddings and no service:
#!/usr/bin/env bash
# sim.sh "a" "b" -> token-set Jaccard, 0.00-1.00
norm() { printf '%s' "$1" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9' ' ' \
| tr ' ' '\n' | grep -vE '^(inc|llc|ltd|the|co|corp)?$' | sort -u; }
a=$(norm "$1"); b=$(norm "$2")
inter=$(comm -12 <(printf '%s\n' "$a") <(printf '%s\n' "$b") | grep -c .)
union=$(printf '%s\n%s\n' "$a" "$b" | sort -u | grep -c .)
awk -v i="$inter" -v u="$union" 'BEGIN{ printf "%.2f", (u==0?0:i/u) }'
I labelled eight pairs from names in my own repositories and swept the threshold across them. Applied to that scorer, the circulating rule splits three of my eight pairs that should have merged. The best cut was 0.66, and that is a fact about token-set Jaccard on short names, not a fact about entity resolution.
Now look at two rows from that table. Acme Corp versus Acme Corporation is the same company, and it scores 0.50. Acme Corp versus Acme Logistics Ltd is a different company, and it also scores 0.50. Same score, opposite answer, so there is no cut anywhere on that scale that gets both of them right.
So use three bands and not one cut. Auto-merge above the line where you get no false merges, create a new node below the line where you get no false splits, and send everything between them to a person, because in that middle range the string does not contain the answer. The three-band shape of the circulating rule is fine. Its actual numbers came off somebody else’s scorer, so replace them with two you measured.
Two last cheap decisions. Put memory/ in git, which buys a full history of every fact the system ever believed, git blame on a claim that turned out wrong, and git revert as the recovery when a bad run poisons the store. And keep secrets out of memory/raw/, because that directory is written by machines and nobody reviews it.
Then expect drift. Most stores of this kind load up fine on day one and are out of date about two weeks later, once normal development has moved things around, and the version that hurts is a stale entity, not a missing one. A missing entity returns nothing at all, which you will notice, whereas a stale one returns a perfectly formatted answer about a module that got deleted last sprint, and nothing in the output distinguishes the two cases for you. Three policies, all cheap, and you should pick at least two:
- Timestamp everything and decay it.
last_seen_runis already in the entity file. An entity not seen in five runs is not necessarily wrong, but it should stop being loaded by default. - Re-derive what is cheap to recompute; do not accumulate it. Anything recomputable from the repository in under a second should be recomputed, not remembered. Memory is for what costs real money to derive: judgements, outcomes, things a person said, what you already tried and why it failed.
- Fail loudly on contradiction. When a run produces a fact that contradicts a stored one, do not overwrite silently. Write both, flag the entity, and let the log record it. A contradiction usually means something changed underneath the store, and catching it early is cheaper than tracing it back later.
Now go build something this weekend!
John Cook