Fifty runs of a closed graph

Zep

The client half of a hosted temporal knowledge graph, whose fifty committed LoCoMo runs separate retrieval sufficiency from answering skill and show accuracy-given-a-complete-context flat at 92 percent across a 5.8x swing in retrieved tokens.

Carries 2 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

This repository is not Zep. Its own README says so in the third paragraph: it "is not Zep's product or service", but example code, framework integrations and tooling for Zep Cloud, the hosted agent-memory platform. The engine that used to be here — Zep Community Edition — sits deprecated and unsupported in legacy/, and the open engine that powers the hosted product is a different repository, analyzed separately as Graphiti.

So the honest description of what a reader can inspect at this commit is: a client contract for a closed temporal knowledge graph, plus the measurement apparatus its vendor points at it. That sounds like a thin subject for a report, and for the memory mechanism it is — extraction, entity resolution, edge invalidation and ranking all happen on the other side of an HTTP call. Two things make the tree worth reading anyway.

The first is benchmarks/locomo/experiments/, which holds five LoCoMo experiments of ten runs each — fifty runs, 77,000 graded question instances, with per-run standard deviations, context-token distributions and retrieval latency percentiles all committed to git. Almost nothing else in this atlas publishes variance at all; the benchmarks page exists partly to complain about that. The sweep varies one thing, the retrieval budget, and it answers a question the field usually leaves as vibes: how much retrieval is enough, and what does the last increment buy?

The second is ingestion/, a bulk-loading library whose warning strings are the most candid public documentation of the hosted service's failure modes. It warns that an episode submitted without a timestamp is silently dated to ingestion time, "which corrupts fact validity timelines and invalidation ordering on backfills". It ships a helper whose entire reason to exist is that a fact is not searchable when the API says the write succeeded. It refuses to let you register "Will" as an entity alias, because rewriting it would corrupt "he will go". These are not marketing claims; they are a vendor's engineers writing down where the sharp edges are, in code, next to the guard rails.

The weakness is the obvious one and it is not fixable by reading harder: nothing in this tree stores, extracts, ranks or forgets anything. An API key is required before a single fact can be written, there is no local mode, and the correction semantics that make Zep interesting — bitemporal edge invalidation — are asserted at the API boundary and executed somewhere you cannot see.

2. Mental Model

From inside this repository a memory has two forms and a gap between them.

The form you write is an episode: a string, a data_type of text, json or message, an optional RFC3339 created_at, and at most ten metadata keys (ingestion/src/zep_ingest/types.py). The form you read is a fact edge: a typed relationship between two named nodes, carrying fact, fact_name in SCREAMING_SNAKE_CASE, and three timestamps — valid_at and invalid_at for when the claim held in the world, created_at for when the graph learned it (ingestion/src/zep_ingest/triples.py). Between the two sits the vendor's extraction, which the client never observes.

The states a client can distinguish are therefore states of submission, not of belief:

episode constructed   -> validated locally against documented API limits
submitted             -> batch id, episode or message UUID, or task id kept;
                         untracked_items incremented when none comes back
result.wait()         -> the tail of the queue reports processed
                      -> but the fact is still not searchable
search_when_ready()   -> polls every 5s up to 120s until something comes back
retrieved             -> a fact edge with valid_at / invalid_at / created_at

Belief states are the vendor's. There is no candidate, no verified, no rejected; the closest thing is min_fact_rating, a floating-point threshold on a hosted score that search_graph accepts as a filter. A score is not a state, so this report withholds the trust_state mark, and the near-miss is worth naming because Zep is often described as if it had one.

How a memory dies is the more interesting half. Nothing in the ingestion library deletes. The only correction primitive a client can assert is invalid_at on a FactTriple, which closes a validity interval rather than removing a row — the same move Graphiti makes, and for the same reason: a graph that overwrites cannot answer what did we believe last March. The consequence is that a wrong fact does not disappear; it becomes a fact that was true until a date. Whether the hosted extractor will re-derive it from the same episode on a later pass is not answerable from this tree, and there is no record of a rejected value anywhere, so the tombstone mark is withheld too.

The one place the client can cause a durable epistemic error is the timestamp. _MissingTimestampCounter in pipeline.py exists to count episodes with no created_at, and its warning is the sharpest sentence in the repository:

Zep silently defaults to the ingestion time, which corrupts fact validity timelines and invalidation ordering on backfills.

Bitemporality is only as good as the event clock it is fed. Backfill a year of Slack history without timestamps and every fact in it becomes true as of the afternoon you ran the import, in an order determined by your loader rather than by history — and because invalidation is ordered by that same clock, the corrections land backwards.

Diagram — what a client can see of a hosted graph: two lags, three timestamps, and no way to say never again
Diagram source
%% caption: what a client can see of a hosted graph: two lags, three timestamps, and no way to say never again
flowchart TB
    Ep["Episode<br/>data, data_type, created_at?"] --> Guard["LimitGuard splits at 9,500 chars<br/>Alias canonicalizer rewrites names"]
    Guard --> Sub["Submit: batch or sequential<br/>returns batch, episode or task handles"]
    Sub --> Opaque

    subgraph Opaque["Zep Cloud — not in this tree"]
      Extract["extraction, entity resolution,<br/>edge invalidation, ranking"]
    end

    Opaque --> Edge[("Fact edge<br/>valid_at … invalid_at<br/>created_at")]
    Sub -.->|"created_at absent"| Bad["dated to ingestion time:<br/>validity timeline and<br/>invalidation order corrupted"]
    Bad --> Opaque

    Wait["result.wait() reports success"] -.->|"fact still not searchable"| Lag["indexing lag<br/>search_when_ready polls 5s / 120s"]
    Lag --> Edge
    Edge --> Read["graph.search<br/>scope edges | nodes | episodes<br/>reranked, limit ≤ 50"]
    Edge -.->|"correction closes an interval,<br/>it never removes a row"| Keep["no delete path in the client"]
    Edge -.->|"min_fact_rating is a score,<br/>not a status"| NoState["no trust state"]

3. Architecture

Six code trees share the repository, developed and released independently.

  • ingestion/zep-ingest, about 5,200 lines of Python. A Loader → Transform* → LimitGuard → Submitter pipeline with loaders for Slack exports, .eml mail, WebVTT and speaker-labelled transcripts, text/Markdown files, and JSONL/CSV/JSON records, plus a ConcatLoader that chains loaders into one submit stream. This is the only substantial memory-adjacent implementation in the repository.
  • benchmarks/ — a LoCoMo harness (about 1,800 lines plus tests) with five committed experiments, and a LongMemEval harness with no committed results.
  • zep-eval-harness/ — about 4,600 lines of Python driving a smaller end-to-end ingest-then-ask loop over four conversations and four reference documents, with its runs committed under runs/ (section 10).
  • integrations/ — one package per framework per language, released independently: Google ADK, Microsoft Agent Framework, AutoGen, AG2, CrewAI, LangGraph, LiveKit, Pydantic AI and Strands in Python; ADK, Mastra and Vercel AI SDK in TypeScript; ADK in Go.
  • mcp/zep-mcp-server/ — about 1,550 lines of Go, a self-contained module (go.mod declaring github.com/getzep/zep/mcp/zep-mcp-server) with a Makefile, a Dockerfile and a docker-compose.yml.
  • legacy/ — Zep Community Edition, a Go server on Postgres via bun, with docker-compose.ce.yaml and a Dockerfile.ce. Deprecated and unsupported, per the README and the linked strategy post.

ontology/default_ontology.py is a single 139-line file of Pydantic models defining the entity types the hosted extractor is asked to classify into: User and Assistant (both declared singletons), Preference, Location, Event, Object, and the rest. The prompt engineering is in the docstrings — Preference instructs "Prioritize this classification over ALL other classifications except User and Assistant" and "Use LOW THRESHOLD for sensitivity", with trigger patterns listed. It is a useful artifact precisely because it shows what a production memory vendor decided to bias its extractor toward: user preferences, aggressively, at the expense of everything else.

The MCP server is a thin, complete Go binary over the zep-go/v3 SDK. cmd/server/main.go loads config through internal/config, and internal/server/server.go:67 registerTools() calls mcp.AddTool thirteen times against the tool values declared in internal/server/tools.go. Each handler in internal/handlers/ is thirty to ninety lines: apply defaults, build the SDK request struct, call one SDK method, marshal the response with transform.FormatJSON. Two transports are supported — StdioTransport for Claude Desktop and Cline, and mcp.NewStreamableHTTPHandler on a configurable port, chosen by TransportMode (server.go:93).

One detail in the HTTP path is a defect rather than a design choice: runHTTP's shutdown calls context.WithTimeout(context.Background(), 5) at server.go:160. The second argument is a time.Duration, so that is five nanoseconds, not five seconds — the graceful-shutdown window is effectively zero and in-flight requests are dropped on SIGINT.

Deployment and ergonomics

There is nothing to stand up and nothing to back up, which is either the whole appeal or a disqualification depending on the reader.

  • An API key is required to store anything at all. Pipeline.preview() runs the full loader-and-transform chain with no API calls, so you can validate a corpus offline; Pipeline.run() cannot do anything without a Zep client.
  • No local mode, no offline degradation, no self-host on the supported path. The Community Edition in legacy/ is the only self-hostable artifact and it is explicitly unsupported.
  • The store is not human-readable or hand-repairable. Inspection goes through graph.search and the getters in zep_graph_inspect.py.
  • Install is pip install zep-cloud for the SDK; zep-ingest and each integration package are separate installs. The MCP server is the one thing in the tree an operator runs: make build or docker compose up, a ZEP_API_KEY in the environment, and a stateless proxy holding nothing.

The screen of this checkout found one auto-run surface, 23 dependency surfaces inside the seven-day cooldown, 14 build-time execution points and 33 unpinned surfaces across 100 files. Nothing here was installed or run; the analysis is a read of the tree.

4. Essential Implementation Paths

  • Write pathPipeline.run() in ingestion/src/zep_ingest/pipeline.py:164. Builds a Destination, streams loader output through transforms and LimitGuard, materializes the whole stream through _validated_replay() (a SpooledTemporaryFile that spills past 8 MB, so a mid-run failure has resumable handles), then calls submit_episodes().
  • Submittersingestion/src/zep_ingest/submitters/batch.py and sequential.py, selected by method="auto". Documented limits in types.py: 350 items per add, a 50,000-item batch cap that the submitters roll over at 10,000 by default (DEFAULT_ITEMS_PER_BATCH), 30 messages per thread.add_messages call.
  • Chunking and limitstransforms/chunker.py and transforms/limits.py. LimitGuard targets SAFE_EPISODE_CHARS = 9_500 against a documented MAX_EPISODE_CHARS = 10_000, leaving headroom for context prefixes the contextualizer may prepend.
  • Alias canonicalizationtransforms/canonicalizer.py, the most interesting single file in the repository (see section 9).
  • Explicit fact assertioningest_fact_triples() in triples.py:162, which bypasses LLM extraction entirely and posts graph.add_fact_triple sequentially, because "the Batch API does not accept triples".
  • Retrievalsearch_when_ready() in verify.py:23, wrapping client.graph.search. The benchmark's own two-scope retrieval is LocomoEvaluator._graph_search_with_retry in benchmarks/locomo/evaluation.py:63, which fires a node search and an edge search concurrently.
  • Context assemblyCONTEXT_TEMPLATE in benchmarks/locomo/prompts.py, a <FACTS> block and an <ENTITIES> block with an instruction that timestamps mean event time and not mention time.
  • Delete path — none exists in zep-ingest.
  • Legacy purgepurgeDeleted() in legacy/src/store/purge_common.go hard-deletes soft-deleted rows in one transaction; the CE build's tableCleanup() in purge_ce.go is a thirteen-line no-op, so the two-tier soft-delete-then-purge design has an empty second tier in the open edition.

5. Memory Data Model

Episode (types.py) validates in __post_init__: non-empty string data, a data_type in the three-value enum, an RFC3339 created_at, and a metadata map of scalars capped at MAX_METADATA_KEYS = 10. A fifth field, document, is internal plumbing — the chunker stores the full source document on each chunk so the contextualizer can read it, and it is never sent to the API.

Provenance is structured rather than free text, and this is done well. Every loader stamps source_type (document, slack, transcript, email, json_record) plus source-specific keys: a document's file_name, a Slack message's channel and thread_ts. Ten keys is a tight budget for that, and it is a documented service limit rather than a library choice.

Destination is a frozen dataclass whose __post_init__ rejects any construction that is not exactly one of graph_id or user_id (bool(graph_id) == bool(user_id) raises). Every write and every read in zep-ingest carries one, which is what earns the scope_enforced mark: the scope is not a tag that happens to be stored, it is a required argument that this read path cannot omit. The mark covers that library and not the whole tree — the MCP server's five UUID-addressed getters take no scope key, as section 8 sets out.

FactTriple is the richest type and the clearest window into the hosted schema, because every documented API limit is re-validated client-side so a bad triple fails as a Python error naming the field rather than an HTTP 400 mid-run:

Field Constraint
fact ≤ 250 chars, required
fact_name ≤ 50 chars, must match ^[A-Z][A-Z0-9_]*$
source_node_name, target_node_name ≤ 50 chars, required
source_node_summary, target_node_summary ≤ 500 chars
source_node_labels, target_node_labels list of at most one label
source_node_uuid, target_node_uuid valid UUID, pins an endpoint by identity
valid_at, invalid_at, created_at RFC3339
attributes, metadata scalar maps, metadata ≤ 10 keys

Two details carry weight. The single-label cap is enforced with the comment that "extraction assigns one best-match type per node" — the hosted ontology is single-inheritance, so an entity cannot be both a Person and an Organization. And the UUID endpoints exist specifically so "a re-run cannot resolve a slightly different name to a new node", which is a plain admission that name-based entity resolution is the fragile part.

The three timestamps are what earn the bitemporal mark. valid_at/invalid_at are world time and created_at is record time, they are independently settable on the same edge, and the library's own warning text treats the distinction as load-bearing. The caveat a reader should hold: this is bitemporality observed at an API contract, not read out of a schema, because the schema is not here.

6. Retrieval Mechanics

The surface is graph.search, assembled parameter for parameter in mcp/zep-mcp-server/internal/handlers/search.go:15 and exercised in the benchmark: a query string, a scope of edges, nodes or episodes, a limit, and a reranker chosen from rrf, mmr, node_distance, episode_mentions and cross_encoder. Filters are min_fact_rating, node_labels, edge_types, and center_node_uuid for node-distance reranking; mmr_lambda tunes diversity. The handler defaults scope to edges and limit to 10 and enforces no ceiling — the maximum of 50 that docs/TOOLS.md records is the service's, checked on the other side.

Retrieval is application-driven. There is no automatic injection: the caller searches, formats and places the result. The benchmark's pattern is the one to copy — two searches in parallel, edges and nodes, with independent limits and independent rerankers, assembled into <FACTS> and <ENTITIES> blocks.

The observable failure mode is under-recall at small budgets, and the repository measures it rather than guessing. See section 10.

The unobservable ones stay unobservable. Whether two aliases merged into one node, whether an edge was invalidated correctly, whether the cross-encoder saw the right candidate set — none of it is inspectable from a client. The zep_graph_inspect.py script in the eval harness exists because reading your own graph back is otherwise awkward.

7. Write Mechanics

The write path is asynchronous end to end, and the repository is unusually explicit about the two separate lags this creates.

Pipeline.run() "submits the transformed stream and returns immediately". The docstring instructs the caller to bind the result before waiting, so the resume handles survive a timeout:

result = pipeline.run(client, graph_id="company_kb")
result.wait()

That is lag one: the extraction work is queued and wait() blocks on it. It polls one handle per submission path — batch.get on the last batch, the processed flag of the last-submitted graph.add episode, the last message UUID of each thread, and every task id for nodes and triples — and when a single queue's tail reports processed it marks everything submitted before it processed too (ingestion/src/zep_ingest/result.py:210-218), on the documented premise that plain graph.add episodes are processed in submission order. The default deadline is 60 seconds per submitted item with a 120-second floor (:82-94). Until 0.3.0 the sequential thread path looked for a task_id that thread.add_messages does not return, so every such backfill was counted as untracked and wait() refused to wait on it. Lag two is the one that matters and is almost never documented anywhere in this atlas — from verify.py:

Ingestion is asynchronous end to end: even after IngestResult.wait() reports success, just-written facts take a few more seconds to become searchable.

search_when_ready polls graph.search every 5 seconds for up to 120 seconds, returning the first response with any hits in context, edges, nodes, episodes, observations or thread_summaries, and returning the final empty response rather than raising, because "nothing matched" is a legitimate answer. A read-your-writes window measured in seconds, with a documented 120-second worst case, is a real constraint on any agent that writes a fact and then reasons about it in the same turn.

Nothing here blocks the agent on an LLM call — but nothing here is on an agent's turn at all. zep-ingest is a bulk backfill tool. The per-turn write path is thread.add_messages, capped at 30 messages per call.

Deduplication, consolidation and conflict handling are the vendor's. The client contributes exactly one pre-ingestion normalization, the alias canonicalizer, and one explicit path around extraction, ingest_fact_triples. No background pass in this tree re-reads or rewrites the store; whether one runs on the other side is not knowable here.

On the read side the benchmark measures the injection budget directly: at the default-ish 20/20 setting the assembled context has a median of 1,378 tokens and a p95 of 1,451 — a tight, predictable distribution, and small enough that the prompt-prefix cache question turns on where the caller places it rather than on its size.

8. Agent Integration

Thirteen integration packages, framework-first then language, each built and released independently: integrations/<framework>/<language>/. Python covers Google ADK, Microsoft Agent Framework, AutoGen, AG2, CrewAI, LangGraph, LiveKit, Pydantic AI and Strands; TypeScript covers ADK, Mastra and the Vercel AI SDK; Go covers ADK. That breadth is the product's real distribution strategy, and it is maintained as thirteen release trains rather than one adapter layer.

The MCP surface is thirteen tools and every one of them is a read, which is checkable against internal/server/server.go:67 rather than against a document: search_graph, get_user_context, get_user, list_threads, get_user_nodes, get_user_edges, get_episodes, get_thread_messages, get_node, get_edge, get_episode, get_node_edges, get_episode_mentions. Each handler bottoms out in exactly one SDK call and all thirteen are getters — Graph.Search, Graph.Node.GetByUserID, Graph.Episode.Get, Thread.GetUserContext and so on. The wrapped client (pkg/zep/client.go) embeds the whole zep-go/v3 client, so Graph.Add and the delete endpoints are one line away and no tool reaches them. There is no add_memory and no delete_memory, so under MCP the agent is a consumer of memory that something else wrote.

Scope is asymmetric across those thirteen. Six take a user_id (search_graph, get_user, list_threads, get_user_nodes, get_user_edges, get_episodes) and two take a thread_id (get_user_context, get_thread_messages). The remaining five are addressed by bare UUID — get_node, get_edge, get_episode, get_node_edges, get_episode_mentions (internal/handlers/types.go:61-84) — so an agent holding a UUID out of one user's search result can fetch that object without naming a user. Whether the hosted API refuses a cross-user fetch on the API key is not observable from here; the client does not attempt the check, which is the opposite of the invariant Destination enforces in zep-ingest.

Agency over memory is consequently low by construction. The model does not decide what to remember; the application ingests, the vendor extracts, and the agent searches. For a reader who wants an agent that curates its own memory, this is the wrong shape entirely.

9. Reliability, Safety, and Trust

The alias canonicalizer is the safety mechanism worth the trip. Zep resolves entities by the names it sees in text, so "PROTOTYPE-202" and "ROBOT-202" do not merge, and the recommended fix is to rewrite aliases before ingestion. That fix is a text-substitution pass over a corpus about to become a permanent knowledge graph, which is a data-corruption engine if it is naive. It is not naive:

  • Aliases shorter than three characters, or matching a built-in 146-word DEFAULT_RISKY_WORDS set of common English words and word-like given names (will, mark, bill, art, page, chase, hope, may, dot), are rejected at construction with an error explaining that "sentence-start capitalization defeats case-sensitive matching for word-like aliases".
  • URLs and backtick code spans are matched first in the same scan and passed through untouched.
  • Existing canonical mentions are protected, longest-literal-first, so an alias containing its canonical still wins.
  • Word boundaries use (?<!\w)/(?!\w) rather than \b, with the comment that \b would make aliases starting or ending in punctuation — .NET, C++ — silently never match.
  • A term declared as both an alias and a canonical name raises, rather than silently killing the alias.
  • Per-alias replacement counts surface in preview() warnings, so a runaway alias is visible before any API call.

That is six distinct failure modes anticipated in one 227-line file, in a transform most teams would write as a str.replace loop. It is the strongest example in this atlas of treating pre-ingestion normalization as a hazard rather than a convenience.

Against that: there is no defence against prompt-injected false memory anywhere in the tree. Anything a loader reads — a Slack message, an inbound .eml, a document — is submitted as an episode and becomes graph content. The Slack loader defaults to public channels only and reports unselected private channels and DMs in warnings, which is a privacy boundary rather than a trust one. min_fact_rating filters at read time on a score assigned by the vendor, so a caller can decline low-rated facts but cannot mark one rejected.

Provenance is a genuine strength: structured source_type plus source-specific keys on every episode, and IngestResult collects node_uuids, edge_uuids and task_ids so a run's outputs can be tied back to its inputs. Errors are collected per item as AddError(index, item_count, error) rather than aborting the run, and untracked_items counts submissions that came back without a completion id — a small, honest counter for "we sent this and cannot prove what happened to it".

The multi-tenancy boundary is the vendor's. Destination enforces the client side of it and nothing more.

10. Tests, Evals, and Benchmarks

ingestion/tests/ holds about thirty test modules covering loaders, transforms, submitters, validation, limits, threads, triples and the example ontology — a well-tested client library. benchmarks/locomo/tests/ adds tests for config, persistence and common utilities. The MCP server's twelve Go tests are thinner and cover no handler: config_test.go checks that a missing API key fails and that the server-name, version and log-level defaults load, types_test.go checks struct-tag and zero-value behaviour on the input types, and helpers_test.go covers transform's JSON formatting and optional-parameter readers. Nothing exercises a tool end to end, and there are no tests for the Community Edition in this tree.

The benchmark harness is the contribution. benchmarks/locomo/ runs LoCoMo-10 (1,540 questions per run, pulled from snap-research/locomo) and grades each answer on two independent axes with two separate LLM calls made concurrently (evaluation.py:154):

  1. Accuracy — a generous CORRECT/WRONG grader against the gold answer (prompts.py, GRADER_PROMPT).
  2. Context completeness — COMPLETE, PARTIAL or INSUFFICIENT, judging whether the retrieved context contained what was needed, with missing_elements and present_elements recorded per question (evaluation.py:251).

That second axis is the methodological move, and it is rare. It measures the memory layer directly instead of measuring a pipeline and attributing the score to memory. The derived metric accuracy_with_complete_context then answers the question the first axis cannot: given that retrieval worked, how often is the answer right?

Five experiments sweep the retrieval budget, ten runs each, all committed:

edge / node limit Accuracy σ Context COMPLETE Accuracy given COMPLETE Median context tokens Median retrieval
5 / 2 0.6962 0.0047 0.574 0.928 347 0.149 s
10 / 2 0.7372 0.0041 0.628 0.923 504 0.161 s
15 / 5 0.7706 0.0041 0.700 0.925 756 0.199 s
20 / 20 0.8006 0.0033 0.758 0.917 1,378 0.241 s
30 / 30 0.8032 0.0043 0.765 0.915 1,997 0.189 s

All five runs use gpt-4o-mini at temperature 0 for both answering and grading, with cross_encoder reranking on both scopes.

Three things fall out of that table, and they are worth more than the headline number.

Accuracy given a complete context is flat. It sits at 0.92 ± 0.01 across a 5.8x swing in retrieved tokens. Every point of end-to-end accuracy the sweep buys comes from completeness rising — 0.574 to 0.765 — and none of it comes from the answering model doing better with more material. The memory layer and the reader are cleanly separated by this design, and the separation holds.

The residual 8% is not a memory problem. When the grader confirms the context contained everything needed, the pipeline still gets one answer in twelve wrong. No amount of retrieval improvement touches that ceiling; it is the reader and the grader. A team optimizing memory against end-to-end LoCoMo accuracy is, past a point, optimizing against someone else's error bar.

The budget saturates before the top of the sweep. Going from 20/20 to 30/30 costs 45% more context tokens for 0.26 points of accuracy — a gap smaller than the sum of the two runs' standard deviations, and accompanied by accuracy-given-complete falling slightly, 0.917 to 0.915, the shape a mild distraction effect makes. The knee is at 20/20.

The second harness, zep-eval-harness/, commits one complete run chain and it is a much smaller artifact than the LoCoMo sweep. runs/chunk_sets/1_20260331T222430/ holds ten chunks from four reference documents at chunk_size: 500; runs/users/1_20260331T222436/manifest.json and runs/documents/1_20260331T222500/manifest.json record two synthetic users, four conversations, the custom entity and edge types used, and the resulting Zep episode UUIDs; runs/evaluations/1_20260331T222821/results.json grades four questions with gemini-2.5-flash-lite as both answerer and judge — 4 of 4 COMPLETE, 3 of 4 correct, accuracy_when_complete 75%, median prompt 3,144 tokens, median search 337 ms. Four questions across two users, all in one basic_facts category, is a smoke test rather than a measurement, and its timing and token statistics have a sample size of four behind them.

Two things about it are worth more than the numbers. Each run directory carries a *_config_snapshot/ — the actual Python config modules copied verbatim beside the results — so a committed result names the ontology, custom instructions and prompts it was produced under instead of pointing at a config that has since moved. And every test case in data/test_cases/ carries a needles array giving the source file, line and excerpt where the answer was stated, so a failure is attributable to a specific ingested sentence. The same two-axis grading as the LoCoMo harness is used — zep_evaluate.py runs an accuracy judge and a COMPLETE/PARTIAL/INSUFFICIENT completeness judge — so the design idea in section 11 is applied twice in this tree rather than once.

What is not here: benchmarks/longmemeval/ ships a runner, a dataset analysis script and a notebook, but no results. And no committed result compares Zep to anything else — the sweep is Zep against its own configuration, which is the right experiment for choosing a limit and the wrong one for choosing a vendor.

The tests a reader would want before trusting this and cannot find: anything asserting that invalid_at actually stops a superseded fact from being retrieved, anything measuring how long the post-wait() indexing lag really is, and any negative case asserting that deleted or scoped-out material must not appear in a search result.

11. For Your Own Build

Steal

Grade retrieval sufficiency separately from answer correctness, with a second judge on the same question. COMPLETE / PARTIAL / INSUFFICIENT over the retrieved context, plus missing_elements, turns an opaque end-to-end score into an attribution. It costs one extra LLM call per eval question and it is the cheapest way to stop shipping retrieval changes that were really prompt changes.

Report accuracy conditioned on complete context. It is one extra line in an aggregation and it exposes your own ceiling. If that number is flat across your retrieval sweep, more retrieval is not your problem.

Run the sweep ten times and publish σ. The gaps in the table above are two to nine standard deviations at the bottom and less than one at the top. Without the repeats, the 20/20-to-30/30 step reads as a real 0.26-point improvement.

Validate a corpus against the service's documented limits before the first API call. preview() runs the entire loader-and-transform chain with zero network calls and returns warnings; preview(limit=None) does it exhaustively. Every limit is re-checked client-side so failures name a field instead of returning an HTTP 400 halfway through a 50,000-item batch.

Treat pre-ingestion text rewriting as a hazard with a deny-list. If you canonicalize entity names before they reach an extractor, ship the risky-words guard, the URL and code-span protection, the punctuation-safe boundaries and the per-alias replacement counts. All four exist because all four failures happen.

Count the episodes with no event timestamp, and say what it will cost. A loader that silently dates history to import time produces a graph that is internally consistent and historically wrong.

Avoid

Do not assume a write is readable when the write API says it succeeded. Two lags, not one: task completion, then indexing. If your agent writes a fact and reasons about it in the same turn, you need the poll loop or you need a read-your-writes guarantee in writing.

Do not let a scope be a tag. Destination raising unless exactly one of graph_id and user_id is set is a one-line invariant that makes leaking across users a construction error rather than a filter someone forgot.

Do not read a rating as a status. A float on a fact tells you how confident something was; it does not tell you the fact was checked, and it cannot stop the extractor from re-deriving something you rejected.

Do not benchmark a memory system only against itself. Five well-run experiments on one vendor's own configuration establish where its knee is and nothing about whether the knee is in a good place.

Fit

Adopt this if memory is not the product you are building and you would rather buy the hard parts — entity resolution, temporal invalidation, reranking — than maintain them. The integration breadth is real, thirteen packages across three languages, and the client library is better engineered than most of the systems in this atlas that ship an actual engine.

Walk away if any of three things is true. If you need to inspect or repair the store, you cannot: the mechanism is on the other side of an API and this repository is the map, not the territory. If you need offline or air-gapped operation, there is no local mode and the self-hostable edition is deprecated. And if you want an agent that decides what to remember, the MCP server registers thirteen tools and all thirteen are reads — this design assumes the application ingests and the model only asks.

The reader who gets the most from this repository may be one who never adopts Zep at all, and takes benchmarks/locomo/ to point at their own system instead.

12. Open Questions

  • How long is the post-wait() indexing lag in practice? The client polls at a 5-second interval with a 120-second ceiling, which brackets it but does not measure it.
  • Does the hosted extractor re-derive a fact whose validity interval was closed? Nothing in the client can express "never assert this again", so a re-extraction loop is possible and undetectable from here.
  • What does min_fact_rating actually score, and on what scale? It appears only as a filter parameter.
  • Is tableCleanup() a no-op only in the Community Edition build, or was the second tier of the purge never implemented? The build tag split says the former; the tree cannot confirm it.
  • Why is the LoCoMo sweep's 30/30 retrieval median (0.189 s) lower than 20/20's (0.241 s)? Most likely load rather than budget, but the harness does not record enough to say.
  • Does the hosted API reject a get_node or get_edge for a UUID belonging to another user under the same API key? The MCP server's five UUID-addressed getters pass no scope key, so the answer lives entirely on the server side.

Appendix: File Index

Write path

  • ingestion/src/zep_ingest/pipeline.pyPipeline, preview(), run(), _validated_replay(), _MissingTimestampCounter.
  • ingestion/src/zep_ingest/submitters/batch.py, sequential.py — submission and retry.
  • ingestion/src/zep_ingest/triples.pyFactTriple, ingest_fact_triples().
  • ingestion/src/zep_ingest/transforms/limits.py, chunker.py, _splitting.py — size guards.
  • ingestion/src/zep_ingest/transforms/canonicalizer.pyAliasCanonicalizer, DEFAULT_RISKY_WORDS.
  • ingestion/src/zep_ingest/transforms/contextualizer.py — optional LLM chunk contextualization.

Data model

  • ingestion/src/zep_ingest/types.pyEpisode, Destination, documented API limits.
  • ingestion/src/zep_ingest/result.pyIngestResult, AddError, untracked_items.
  • ontology/default_ontology.py — the extractor's entity types.

Loaders

  • ingestion/src/zep_ingest/loaders/slack.py, email.py, transcript.py, text.py, json_records.py.

Retrieval

  • ingestion/src/zep_ingest/verify.pysearch_when_ready().
  • benchmarks/locomo/evaluation.py — two-scope concurrent search, both graders.
  • benchmarks/locomo/prompts.pyCONTEXT_TEMPLATE, RESPONSE_PROMPT, GRADER_PROMPT.

MCP

  • mcp/zep-mcp-server/internal/server/tools.go, server.go — the thirteen tool declarations and registerTools().
  • mcp/zep-mcp-server/internal/handlers/ — one file per tool; types.go carries the input schemas and shows which tools take a scope key.
  • mcp/zep-mcp-server/pkg/zep/client.go, internal/config/config.go, internal/transform/ — SDK wrapper, config, parameter and result marshalling.
  • mcp/zep-mcp-server/docs/TOOLS.md — the tool documentation, including the service-side limit maximum of 50.

Evals

  • benchmarks/locomo/experiments/ — five experiments, ten runs each, with configs and summaries.
  • benchmarks/locomo/benchmark.py, persistence.py, config.py, ontology.py.
  • benchmarks/longmemeval/ — runner and notebook, no committed results.
  • zep-eval-harness/zep_evaluate.py — the second two-axis grader; data/test_cases/ with per-answer needles.
  • zep-eval-harness/runs/ — one committed chain: chunk set, user and document manifests, and a four-question evaluations/1_20260331T222821/results.json, each with its config snapshot.

Legacy Community Edition

  • legacy/src/store/purge_common.go, purge_ce.go — soft delete and the no-op cleanup.
  • legacy/src/store/schema_ce.go, memory_ce.go, sessionstore_ce.go — the Postgres store.
  • legacy/docker-compose.ce.yaml, Dockerfile.ce — the deprecated self-host path.

History

2026-09-15495bf728… — five commits on, to 2026-09-11. Screened at the new pin before reading: one auto-run surface, 23 dependency surfaces inside the cooldown, 14 build-time execution points and 33 unpinned surfaces across 100 files; nothing was installed or run. zep-ingest went to 0.3.0. Batches now roll over at 10,000 items by default instead of filling to the 50,000 cap. Every file or loader bound for one graph is submitted before anything waits, and wait() polls the tail of each submission path, inferring that the queue in front of a processed tail has drained, with a deadline of 60 seconds per item and a 120-second floor. Sequential thread backfills now poll the last message UUID per thread: at the previous pin that path looked for a task_id which thread.add_messages never returns, so those backfills were all counted untracked and wait() refused them. A ConcatLoader, multi-path sources and IngestResult.combine() were added, along with a production smoke script that needs a live key. The other two commits repair the Python, TypeScript and Go examples for SDK v3. Nothing in benchmarks/, mcp/, integrations/ or legacy/ changed. Both marks were re-checked and stand, now with evidence records. bitemporal rests on the triple contract and on readers that render the closed interval; no as-of filter is issued in this tree. scope_enforced rests on Destination, which search_when_ready builds before every read.

2026-08-31be263ee2… — same pin, two absence claims corrected, both wrong in the direction of asserting something missing that is committed. The MCP server was described as documented here and implemented elsewhere; mcp/zep-mcp-server/ is a complete Go module — cmd/server/main.go, internal/server/{server,tools}.go, thirteen handler files, pkg/zep/client.go, go.mod, Makefile, Dockerfile — and registerTools() registers exactly the thirteen tool names the report listed. The read-only finding survives on code rather than on docs/TOOLS.md; the scope asymmetry across the five UUID-addressed getters, the twelve Go tests and the nanosecond shutdown timeout at server.go:160 were not previously reported, and the open question asking where the server is implemented is answered from the tree. zep-eval-harness/runs/ was described as holding only a .gitkeep; it holds one committed chain — chunk set, user and document manifests, and a four-question evaluation under gemini-2.5-flash-lite — thin but present, and section 10 describes it instead. DEFAULT_RISKY_WORDS holds 146 words, not 150. No capability mark moved; scope_enforced was re-checked in both directions and stands on Destination in zep-ingest, which section 5 states explicitly.

2026-08-13be263ee2… — first reading. Screened before opening: one auto-run surface, 47 dependency surfaces inside the seven-day cooldown, 14 build-time execution points, 29 unpinned surfaces across 93 files. Nothing was installed and nothing was run; the committed benchmark artifacts were read from git, not reproduced.