Five Memory Types, One Store: I Merged and Split Agent Memory and Measured the Damage A first-person experiment merging five memory types into one SQLite table and then splitting them by type found that a typed-store merge cut ENGRAM's LoCoMo score from 77.55% to 46.56%, a figure the author attributes secondhand to Jaroslaw Wasowski's writing on memory types and has not independently verified. The author ran the test on a small synthetic set he wrote himself, comparing one merged retrieval pool against type-split retrieval with per-type read rules over the same rows, embeddings and questions, and also cites Letta's self-reported 74.0% versus Mem0's 68.5% on LoCoMo and Paul Bruffett's procedural-graph result of 17 of 19 surviving at $0.84 versus $0.09 per month. The author frames the finding as evidence that agents must decide memory type on the write path, since the cost of mixing types surfaces on the read path weeks later. A few weeks ago I published “Your Agent’s Memory Is Probably Wrong.” It opened with a filesystem agent using grep beating Mem0 on LoCoMo, and the whole piece leaned on that result. Then I wrote “Tail Rent,” about what happens to a long-running agent session after its third compaction, when a decision about a refresh token TTL had been sanded down into “auth token handling was reviewed and updated.” Both pieces were about memory going wrong. Both blamed different things. The first blamed retrieval vector search is bad at time, grep is familiar . The second blamed compression a summary of a summary keeps the shape and loses the specifics . This week two pieces by Jaroslaw Wasowski made me realise I had been circling the same problem from two sides without naming it: neither my one-table memory nor my compacted transcript knew what type of fact it was holding, so neither could apply the right rule to it. So I did the experiment I should have done before writing either article. One SQLite table, a type column, five memory types. Then I retrieved from it two ways: as one merged pool, and split by type with each type’s own read rule. Same rows, same embeddings, same questions. This is what happened, what I got wrong before, and what I still don’t know. I’ve been burned by my own citations, so this goes first. +------------------------------+--------------------------------+---------------------------------+| Number | Where it comes from | How much weight I give it |+------------------------------+--------------------------------+---------------------------------+| ENGRAM 77.55% - 46.56% on | Cited in Wasowski's memory | Secondhand. I have not read the || LoCoMo when typed stores | types piece | ENGRAM paper or rerun it || are merged | | |+------------------------------+--------------------------------+---------------------------------+| Letta filesystem 74.0% vs | Letta's own blog benchmark; | Vendor-run, self-reported on || Mem0 68.5% on LoCoMo | Mem0 figure is Mem0's own | both sides |+------------------------------+--------------------------------+---------------------------------+| Procedural graph: 17 of 19 | Paul Bruffett's write-up of | Author-run, single series, || survived vs 1; $0.84 vs | his own reference | run ended when API credits ran || $0.09 per month survived | implementation | out |+------------------------------+--------------------------------+---------------------------------+| Devin Dreaming runs about | Cognition's own product docs | Vendor description of a vendor || once a day | | feature |+------------------------------+--------------------------------+---------------------------------+| hit@1, stale@1, latency in | My lab, code below | Small synthetic set I wrote || the results section | | myself. A smoke test |+------------------------------+--------------------------------+---------------------------------+ None of the third-party numbers are verified by me. Where I say “Wasowski cites” or “Bruffett reports,” that is exactly what I mean. Wasowski’s piece on memory types starts with a line I have now repeated to two colleagues: “the user prefers TypeScript” sounds like one fact, but depending on what it will be used for later, it belongs in a different structure. His point is that you pay for mixing types on the read path, weeks later, but you decide it on the write path, usually silently, the day someone adds one more insert to the vector store because memory is “just embeddings.” The part that reframed things for me is that semantic, episodic, procedural, profile and working memory are not five storage products you need to buy. They are five contracts : different rules for how a fact gets written, when it stops being true, and how it gets read back. You can honour five contracts in one table. You can also violate all five with five separate databases. The number he uses to show the cost of ignoring this is from ENGRAM: 77.55 percent on LoCoMo with typed stores, falling to 46.56 percent when those stores are merged. I’m repeating that as he cites it. I haven’t read the ENGRAM paper, I don’t know the exact ablation setup, and LoCoMo itself has a known problem with its answer key and its judge, which I wrote about separately. So I’m not treating 31 points as the size of the effect. I’m treating it as a reason to measure the effect myself, which is what the rest of this article does. Here is how I’d write the five contracts down, the way I’d put them in an architecture decision record: +------------+---------------------------+----------------------------+-------------------+--------------------------------+| Type | Write rule | Read rule | Stops being true | What breaks if merged |+------------+---------------------------+----------------------------+-------------------+--------------------------------+| Profile | Keyed. New value | Exact key lookup. No | When superseded | Old preference ranks as high || | supersedes old | similarity search needed | | as the new one |+------------+---------------------------+----------------------------+-------------------+--------------------------------+| Semantic | Keyed. Supersedes, keeps | Rank live facts only | When superseded | An episode that mentions the || | history | | | topic outranks the fact itself |+------------+---------------------------+----------------------------+-------------------+--------------------------------+| Procedural | Keyed, versioned, should | Rank live rules only | When a stricter | Old rule and new rule both || | be gated | | rule replaces it | look like valid instructions |+------------+---------------------------+----------------------------+-------------------+--------------------------------+| Episodic | Append only. Never | Rank events, time matters | Never. It | "What happened" answered with || | superseded | | happened | "what is true" |+------------+---------------------------+----------------------------+-------------------+--------------------------------+| Working | Keyed, short TTL | Newest live row | On TTL or task | Yesterday's scratch note || | | | change | answers today's question |+------------+---------------------------+----------------------------+-------------------+--------------------------------+ Looking at that table, my original four-layer design had three of these contracts right and two wrong. I had no profile type at all: user preferences were mixed into semantic memory and searched by similarity, which is a strange way to answer “what is this user’s deploy target.” And I treated working memory as an in-process deque, which is fine until the agent runs for days, at which point it is not working memory any more, it is an unexpired transcript. Wasowski’s second piece is the one that connected this to “Tail Rent.” His argument is that saving the conversation and replaying it every turn is not memory. It is an event log. The same window can hold “I live in Krakow” and “I live in Warsaw,” and the model sees both and their order, but nothing in the log says which one is current. Serialising a transcript gives you an archive, which is great for audit and poor for answering “where does the user live now.” The phrase I wrote in my notebook was: an event log with no HEAD. In git terms, the commit log is the history and HEAD is the pointer that says which state is current. Chat history has the log and no pointer. Every turn, the model is asked to reconstruct HEAD by reading the whole log, which is both expensive that is exactly the tail rent I wrote about and unreliable it may pick the older line . What I did in “Tail Rent” with compaction, pinning and spilling was, in hindsight, a series of attempts to compute HEAD by summarising the log. A summary is a lossy, unversioned HEAD that nobody can diff. When the third fold dropped the 15 minute TTL, there was no record of which version of the decision was current, because there were no versions, just prose. A supersession pointer is a real HEAD. When a keyed fact is written, the previous row for that key gets a SupersededById, and the live row is the one where that pointer is null. The log stays you can still walk the chain to answer "what was it before?" but the current state is a lookup, not an inference. That is the single column I care most about in the code below. I wanted to know whether splitting by type helps because of separate storage , or because of the rules each type implies . So the lab keeps everything in one SQLite table and varies only what retrieval is allowed to see. Setup, all local, no API keys: dotnet new console -n MemLabcd MemLabdotnet add package Microsoft.EntityFrameworkCore.Sqlite Local embeddingsollama pull nomic-embed-textollama serve skip if the Ollama app is already running If you don’t want Ollama on the host, Docker works the same way: docker run -d --name ollama -p 11434:11434 -v ollama:/root/.ollama ollama/ollamadocker exec ollama ollama pull nomic-embed-text The project file: