Skip to content
Cortadel
Source not public yet

Memory that changes its mind.

Most memory layers append. Tell one that something changed and you get both answers back. Cortadel adjudicates the collision — the old fact gets an end date, the new one opens, and recall returns a single answer.

Self-hosted, graph-native, bi-temporal. Exposed over MCP and REST from one process on one port.

Write pipeline · live trace

stored 2026-03-02

“We ship from the Rotterdam warehouse.”

    Validity

    MarMayJulnow

    ships from Rotterdam

    A fact is already in the graph.

    93.0% R@5
    LongMemEval-s, n=500, with the cross-encoder. 75.2% without it.
    1,671 tests
    xUnit, passing on 2026-08-05. Zero skipped.
    Console only
    The sole telemetry exporter in the project. No OTLP package, no endpoint.

    The problem

    Append-only memory keeps both answers.

    A vector store has no opinion about contradiction. Six months after “we ship from Rotterdam”, “we moved to Memphis” is simply a second row — and whichever one ranks higher is the one your agent believes.

    Receipt Core / Dedup / DedupService.cs

    Append-only

    mem_7f3a “We ship from the Rotterdam warehouse.” retrieved
    pref_012 “Invoices go out on Fridays.”
    mem_9c1e “Fulfilment moved to Memphis.” retrieved
    pref_318 “Support replies within one business day.”

    both rows persist · ranking decides · the model guesses

    Adjudicated

    input “Fulfilment moved to Memphis.”
    verdict Supersedes
    mem_7f3a “We ship from the Rotterdam warehouse.” validTo = set
    mem_9c1e “Fulfilment moved to Memphis.” returned

    one answer returned · history kept

    The mechanics behind the right panel: writes are classified STORE, INVALIDATE, DELETE_ENTITY, TOUCH or RESOLVE before anything lands, collisions above 0.85 cosine are adjudicated by an LLM, and a SUPERSEDES verdict closes the old fact with a timestamp. Nothing here is destroyed — a superseded memory keeps its interval and stays queryable. The one genuinely destructive path is deleting an entity outright, which is a hard delete and is meant to be.

    Write path

    Five decisions before anything is stored.

    A write is not an insert. It is classified, checked for collisions, adjudicated, and only then persisted — which is why “forget the Rotterdam thing” mutates the graph instead of adding a note to it.

    Receipt Core / Memory / MemoryWriteService.cs

    1. 01

      Classify

      An LLM decides what the write means first — STORE, INVALIDATE, DELETE_ENTITY, TOUCH or RESOLVE. “That’s resolved” mutates; it doesn’t append.

      Core/Mcp/IntentClassifier.cs

    2. 02

      Collide

      A vector lookup pulls near-duplicates at 0.85 cosine or better. Below the gate, the write is simply stored.

      Core/Dedup/DedupService.cs

    3. 03

      Adjudicate

      The verdict: DUPLICATE, SUPERSEDES or DIFFERENT. A negation guard keeps “I like X” and “I don’t like X” apart.

      Core/Dedup/DedupService.cs

    4. 04

      Write

      One bulk UNWIND insert. SUPERSEDES sets validTo on the old memory instead of updating in place — history stays reconstructible.

      Core/Memory/MemoryWriteService.cs

    5. 05

      Extract

      Entities, relations, scope and cognitive type — extracted off the request path on a 500-slot bounded channel.

      Core/Entities/ExtractionQueue.cs

    Three call sites reach this pipeline: the two MCP write tools and the single-memory REST endpoint. The REST bulk endpoint takes a different, deliberately non-atomic path — worth knowing before you point a migration at it.

    The substrate

    One graph holds all of it.

    Memories, the entities extracted from them, the communities they cluster into — and the three things a colliding or retiring fact can become: deduplicated away, superseded into a successor, or invalidated with none. Step through it.

    Receipt Core / Clusters / LouvainCommunityDetector.cs

    1/6
    Memory graph, stage Memories: Every write lands as a memory node, anchored to its user.HAS_ENTITYcosine 0.94DUPLICATE — not storedSUPERSEDESvalidTo setINVALIDATEinvalidAt set · no successormem_01mem_02mem_03mem_04mem_05mem_06mem_07mem_08mem_09mem_10mem_11mem_12ENTITYENTITY

    Every write lands as a memory node, anchored to its user.

    Entity identity is (userId, normalised name) — type is metadata, not identity — and every read is anchored to the user node, which is what makes one server hold many people’s memory without them bleeding together.

    Recall

    Two arms, fused, then re-read by a cross-encoder.

    Text and vectors disagree about relevance often enough that picking one is a choice you will regret. Both run, their rankings are fused, and a local cross-encoder re-reads the top of the list.

    Receipt Core / Search / HybridSearchService.cs

    Two arms

    A BM25 text arm and a vector arm run against the same query, which is embedded exactly once and threaded to both.

    Fuse

    Reciprocal Rank Fusion at k = 60. On the FalkorDB path BM25 is sigmoid-normalised first, so an unbounded text score cannot swamp the vector arm.

    Overfetch

    The vector arm takes 5,000 candidates and filters by user afterwards, because neither graph engine has a filtered ANN index. This is symmetric across both providers.

    Re-read

    A local bge-reranker-v2-m3 cross-encoder re-scores the top candidates in-process, on CPU. No network call, no third-party API.

    What live recall returns

    Reads filter on invalidAt IS NULL, so a superseded fact is never in the answer. It is still in the graph, still timestamped and still queryable through history — which is what separates supersession from deletion. Results older than 90 days come back labelled stale; labelled, not filtered — nothing is dropped for age.

    A gate worth knowing about

    The graph and session arms only contribute when the cross-encoder is on. Their additions rank below the fusion floor and surface at rerank. Setting the REST session flag without also setting rerank does nothing at all.

    Evidence

    The numbers, and what they cost.

    Every figure below is recomputed from row-level result data on each build, and the artifacts are hash-pinned. If the rows and the page disagree, the build fails rather than the page winning.

    Receipt src / data / evidence / *.jsonl

    Retrieval recall at R@5, R@10, R@20. Raw RRF: 75.2, 80.8, 89.6 percent. With cross-encoder: 93.0, 95.0, 98.0 percent. 0 25 50 75 100 75.2 93.0 R@5 80.8 95.0 R@10 89.6 98.0 R@20
    With cross-encoder Raw RRF LongMemEval-s · n=500 · per-session · recall %
    0 5s 10s 1,026 ms · raw RRF 10× 10,229 ms · with cross-encoder · CPU, concurrency 3
    the lift and its price — mean query latency, same 500 questions
    The reranker's lift and its latency cost, from the same 500 questions
    R@5 R@20 MRR mean latency
    Raw RRF 75.2% 89.6% 0.652 1,026 ms
    With cross-encoder 93.0% 98.0% 0.872 10,229 ms
    Difference +17.8 points +8.4 +0.220 10×

    That last row is the whole trade: the cross-encoder buys +17.8 points of R@5 and costs 10× the query latency on CPU. Both halves ship together, or neither would be honest.

    R@5
    92.3%
    R@10
    95.3%
    R@20
    97.6%
    MRR
    0.839

    1,986 QA pairs · per-session · FalkorDB · 2026-07-25

    Measured over 272 memories across ten users — roughly twenty-seven per person, not a lifetime of them. The run used the cross-encoder with query expansion, which is not any surface's default configuration.

    The same run produced answer scores of F1 51.2 and LLM-judge 51.5. Those are not comparable to published competitor figures: ours were generated and judged by a local model, theirs by GPT-4o-class models. Here for completeness, not for comparison.

    What this page will not claim

    A leaderboard place against mem0, Zep, cognee or Supermemory.
    Our headline figures are retrieval recall — did the right evidence come back. Several published competitor figures are answer accuracy. Putting them in one table would be a category error, not a comparison.
    A latency, throughput or memory-footprint number.
    No production measurement exists at any size. The only figures in the repository are harness runs that move tenfold with configuration alone, and one of them is on this page precisely so you can see that.
    That one graph engine is faster than the other.
    An earlier “35× faster” figure was retracted inside this repository after an audit found no artifact behind it. The corrected measurements land near parity once two of our own bugs are fixed.

    Surface

    One process, one port, three front doors.

    The API, the OpenAPI UI, the MCP endpoint and the dashboard are the same ASP.NET Core process. There is no sidecar, no second container for the UI, and no separate MCP server to keep in sync.

    Receipt Api / Program.cs

    Any Streamable-HTTP MCP client — Claude Code, Cursor, VS Code, Cline, Windsurf

    {
      "cortadel": {
        "type": "http",
        "url": "http://localhost:3001/mcp/claude-code/<userId>",
        "headers": {
          "Authorization": "Bearer <apiKey>",
          "Project": "${workspaceFolderBasename}"
        }
      }
    }
    
    // There is no /sse sub-path. {clientName} becomes the memory's app
    // name; {userId} must match the key's user or the server returns 403.

    Eight tools over MCP

    answers needs its flag

    Write

    • add_memories

      Store memories. Intent-classified, deduped, extraction queued.

    • add_conversation

      Distil a multi-turn conversation into atomic facts, then store those.

    • add_media needs Multimodal:Enabled

      Capture an image as searchable memories.

    Read

    • search_memory

      Hybrid recall. Omit the query to browse chronologically; scan headlines, then expand ids without re-searching.

    • get_skill needs MEMFORGE_SKILL_ARM

      Expand a distilled skill’s procedure.

    Reconcile

    • reconcile_memories

      Queue duplicate-entity merge suggestions; auto-merge above 0.95.

    • reconcile_status

      Progress of a reconcile run.

    • list_merge_suggestions

      Queued suggestions with the judge’s reasoning, for approval.

    Tools only — no MCP resources and no prompts. A Project header scopes a connection so memories from different repositories stay separate under one user, which pairs with an editor variable like ${workspaceFolderBasename}.

    Against the field

    Where it wins, and where it loses.

    Capabilities, not benchmark scores — those are measured on different axes and the numbers are contested. Graphiti, which is also Zep's engine, matches this on most rows. The last row is one most of the field wins and we don't.

    Receipt docs / competitive-analysis-2026-07-27.md

    Capability comparison of Cortadel against Cognee, Graphiti, mem0 and Supermemory, from the repository's 2026-07 survey. Zep is represented by Graphiti, its memory engine.
    Capability Cortadel Cognee Graphiti mem0 Supermemory Detail
    Graph-native storage cognee runs a triple store: SQL + vector + graph
    Bi-temporal — valid and invalid time cognee keeps provenance only; Supermemory has an isLatest flag
    Contradiction detection mem0 is add-only
    Supersede rather than delete cognee’s forget deletes; here the old version stays queryable
    Entity resolution mem0 is hash dedup; cognee is memify consolidation
    Reversible merge — undo, and never re-auto-merge the only row nothing else in the survey offers
    Hybrid retrieval with RRF
    Cross-encoder rerank local ONNX here — in-process, no rerank API call
    Community detection Louvain here; hierarchical summaries in cognee
    Managed cloud not offered — cognee, mem0 and Supermemory all host. This row stays.

    yes partial no

    as of the 2026-07 survey — capabilities move; re-verify before citing

    Run it

    What it actually takes.

    Prerequisites first, because two of them will stop you and a quickstart that hides them is just a slower way to find out.

    Receipt docker-compose.yml

    Before anything runs

    • An embedding endpoint and an LLM endpoint

      Cortadel ships no embedding model. Both are mandatory and both can be local — Ollama and LM Studio are the tested paths, Azure is the third provider. Embeddings are 1024-dimensional by default, and startup fails loudly on a width mismatch rather than corrupting the index.

    • The reranker model, fetched separately

      A 543 MiB int8 ONNX build of bge-reranker-v2-m3 is git-ignored and shipped as a release asset. The Docker build fails on a missing COPY until you fetch it.

    • Docker with Compose

      The compose file brings up FalkorDB and the API together. Nothing else is required.

    Then three commands

    1. 01 Fetch the reranker model (543 MiB, once).

    2. 02 Point at your own endpoints. The shipped default is a LAN address that will not exist on your network.

    3. 03 Bring up FalkorDB and the API on port 3001.

    That brings the dashboard, the REST API, Swagger and the MCP endpoint up together on port 3001, with FalkorDB alongside it. None of this works for you today — the repository and the model asset are not public yet.

    Where it all runs

    Everything below the dashed line is yours. The model endpoints are the one place traffic can leave — and whether it does is your configuration, not ours.

    What it costs to run

    Licence
    Nothing. Apache 2.0.
    Per-memory or per-token fee
    Nothing. There is no meter and nobody to bill you.
    Graph store
    One FalkorDB or Memgraph container, on your hardware.
    Embeddings and LLM calls
    Whatever your endpoint charges. Zero if it is a local model on your own GPU.
    Reranking
    543 MiB of disk and CPU per query. It is the reason recall is 93.0% and the reason queries are not fast.

    Limits

    What’s not true yet.

    This is the section other memory products give to customer logos. We have none, and this is the better trade anyway: everything below costs us something, which is what makes the rest of the page cheaper to believe.

    Receipt src / data / claims.json

    1. The source is not public yet

      The repository returns 404 to anyone who is not a collaborator, and the LICENSE file is present on disk but not committed. Until both change, every claim on this page is checkable only by someone who already has access — which is the weakest form of the thing this page is arguing for.

    2. Auth is off by default

      Auth:Secret ships empty, which disables the key gate entirely: every REST and MCP route answers unauthenticated, and CORS allows any origin. Set a secret and mint a key before this is reachable from anywhere but localhost.

    3. A fresh clone cannot build

      The Dockerfile copies a 543 MiB model that is git-ignored and distributed separately. Until that download succeeds, `docker compose up --build` fails on a missing file rather than on anything informative.

    4. Two of the eight MCP tools are inert

      get_skill returns null and add_media returns disabled unless MEMFORGE_SKILL_ARM and Multimodal:Enabled are set. Several ranking behaviours — decay, reinforcement, type weighting, the community arm — are also default-off, so a stock deploy does less than the architecture allows.

    5. Only one graph path is verified

      Memgraph is the code default and FalkorDB is what the compose file runs and what every benchmark used. The Memgraph path has no automated coverage: the unit tests mock the graph client and the integration tests are FalkorDB-only.

    6. Some documented behaviour is not implemented

      The README advertises relationship weights: the extraction worker parses one out of the LLM response and then never persists it. It also advertises automatic retry for failed entity extraction; nothing re-enqueues a failed job, and recovery is an explicit API call. Both sit on this page’s kill list rather than on this page.

    Fourteen retracted statements are held in the page's claim ledger and the build fails if any reappears in the rendered HTML. Six qualifying phrases are required the same way — the flattering number cannot ship without the sentence that limits it.

    Questions

    The ones that decide an evaluation.

    Receipt spec / 04-claim-ledger.md

    A timestamp records when a row was written. It does not decide anything. The difference is the verdict step: a colliding write is adjudicated, and a SUPERSEDES verdict closes the earlier fact so recall stops returning it. You can get most of the storage shape from a vector store and a validAt column; what you cannot get is something that decides which of two contradicting statements is currently true.

    The graph does not — it runs in your Docker network and there is no Cortadel service anywhere. What does leave is whatever you send to the embedding and LLM endpoints you configure, and the shipped compose defaults point at an address on a LAN that is not yours, so set them. Both can be local models. The only telemetry exporter compiled into the API is OpenTelemetry.Exporter.Console, which writes to stdout.

    On its own, not much, which is why the corpus size is printed next to it. The number that carries weight is the LongMemEval one, because it is a controlled before-and-after on the same 500 questions with one stage switched off: 75.2% to 93.0%. That measures our reranking, not the dataset.

    Because the honest one is unflattering and configuration-dependent. The reranked LongMemEval run averages 10,229 ms per query on CPU against 1,026 ms with the reranker off — a tenfold difference from one setting. Quoting a p50 from a tuned run without that context would be the same trick this project already retracted once.

    It is one config key and Memgraph is what the code defaults to. But the FalkorDB path is the one with automated verification and the one every benchmark on this page used, so treat Memgraph as supported-but-unproven rather than an equal option.

    No, and none is being built. There is no account, no console and no billing. That is the trade: you run a graph container and two model endpoints yourself, and in exchange there is no third party in the path and no meter.

    It stays in the graph with a validTo timestamp. Live reads filter on invalidAt IS NULL so it stops appearing in results, but it is still queryable, still attributable and still part of the history. Entity deletion via the DELETE_ENTITY intent is the one genuinely destructive operation, and it is a DETACH DELETE.

    Not yet, honestly. The repository is private, the licence file is uncommitted, and the reranker model is behind a release asset nobody outside can fetch. Everything on this page describes code that exists and was measured; none of it is code you can currently run.

    Nothing to sign up for.

    There is no waitlist, no newsletter and no demo to book. The source goes public or it does not; until then the most useful thing here is the part that says what does not work.