memtrails for Python: native async memory for agents

memtrails now runs natively in Python, fully async. How to use it, how it stays identical to the TypeScript library, and where it stops.

Abstract illustration: a single folder of records in the centre, joined by lines to three different shapes, an orange circle, a dark square and a triangle

memtrails for Python is a native, fully async port of the open-source memtrails agent memory library: pip install memtrails, Python 3.10 to 3.13, asyncio throughout. It is not a binding or a wrapper around Node. It runs the same algorithms with the same prompts and writes the same files, so a memory folder written in Python opens and searches the same in TypeScript and Rust.

The Rust version, built on tokio, has its own post: memtrails for Rust.

The Python version first shipped on 5 November 2026 as memtrails 0.4.1, MIT-licensed, on PyPI alongside npm and crates.io. Several releases followed the same day, up to 0.5.4, which added an MCP server and command line, LangChain and LlamaIndex retrievers, and Windows x64. This post describes the package as its README stands now.

We covered what memtrails is, how its search works and how it compares with Mem0 in the post on the original TypeScript library. This post does not repeat that; for the wider question of how agents keep memory between sessions, see how AI agents remember across sessions. It answers a narrower question: what does it take to write a second implementation of a memory library that has to agree with the first one exactly, and what does the Python version look like to use?

What is memtrails for Python?

It is the whole library, written again in Python. The Python README calls it a native port of the TypeScript library with “the same algorithms, constants, prompts, JSON Schemas and files, written in Python and natively async.”

Everything the original does is there:

  • add, add_conversation, remove, search, search_text, room, open and explore.
  • With a language model: consolidate, sleep, gather (with notes) and profiles.
  • Six importers: mbox, eml, Slack export, git, files and JSON Lines.
  • Three agent tools, memory_search, memory_open and memory_explore, with JSON schemas.

The handlers of the agent tools are async like everything else. The package shipped with 567 tests and type-checks under mypy --strict.

The local stack is the same too. The first open_memory downloads EmbeddingGemma-300m (4-bit, 218 MB, from Hugging Face) and a Qdrant binary (about 30 MB) into ~/.cache/memtrails, each checked against a pinned SHA-256. It is the same files in the same folder as the TypeScript library, so if you already use memtrails from Node, the two share one download. There is no server to run, and a language model is optional: you need one only for consolidate, sleep and gather.

TypeScript (the reference)Python
Installnpm install memtrailspip install memtrails
RuntimeNode.js 22 or newerPython 3.10 to 3.13
Concurrency modelPromisesasyncio: every I/O method is async def
Importersasync iterablesasync generators
Agent tool schemaszod and JSON SchemaJSON Schema, the reference’s key for key
Framework retrieversLangChainLangChain and LlamaIndex
Memory folder formatthe referenceidentical, readable by either

How do you install it and open a memory?

pip install memtrails

The README lists macOS and Linux (x64 or arm64) and Windows (x64). MEMTRAILS_CACHE moves the download folder. On Windows, keep a memory’s path under 90 characters, because Qdrant’s files sit up to about 170 characters deeper and Windows stops a path at 260. memtrails tells you when a path is too long.

A memory is an async context manager. This is the README’s quick start:

import asyncio
from memtrails import open_memory

async def main() -> None:
    async with await open_memory(home="./memory") as memory:
        await memory.add([
            {"id": "msg-1", "kind": "email", "author": "Ana", "title": "Proxy certificate expired", "at": "2026-04-02",
             "text": "The TLS certificate expired on the 1st. Renewal stopped because the cron job was removed in March."},
            {"id": "c9f1e2", "kind": "commit", "author": "Ion", "title": "Restore certificate renewal cron", "at": "2026-04-03",
             "text": "Files: deploy/cron/renew.sh"},
        ])
        hits = await memory.search({"query": "why did the certificate stop renewing?"})

asyncio.run(main())

Note the double keyword: async with await open_memory(...). Opening is itself I/O, because it may download the model, start Qdrant and replay the logs, so it is awaited. The memory it returns is then entered as a context manager, and leaving the block closes it.

A record is any dict with text. Only id and text are required. source, kind, collection, thread, author, title, at, url and metadata are optional, and at takes milliseconds, an ISO or RFC 2822 string, or a datetime. Adding the same id again updates the record. Conversations go in as turns:

await memory.add_conversation({"id": "chat-42", "turns": [
    {"role": "user", "text": "Where do we have lunch on Friday?"},
    {"role": "assistant", "text": "At the Italian place near the office, at one."},
]})

How do the importers handle a large mailbox or Slack export?

Each importer is an async generator. Records stream one at a time, so the whole source is never held in memory:

from memtrails.importers import from_mbox, from_eml, from_slack_export, from_git, from_files, from_jsonl, batches

await memory.import_(from_mbox("./archive.mbox"))                  # e-mail (mbox / .eml)
await memory.import_(from_slack_export("./slack-export"))          # a Slack workspace export
await memory.import_(from_git("./my-repo", since="2025-01-01"))    # commit messages and changed files
await memory.import_(from_files("./notes"))                        # Markdown, text and HTML files
await memory.import_(from_jsonl("./records.jsonl"))                # one record per line

async for record in from_mbox("./archive.mbox"):
    ...

The method is import_ with a trailing underscore, because import is a Python keyword. It takes any async iterable of records, and a plain iterable too, so a database cursor or a paginated API client plugs in the same way. Records that have not changed since the last import cost nothing, and await memory.remove([...ids]) forgets.

The async for form matters when you want to filter or enrich records on the way in. You get each record as the importer parses it and can decide what to do with it before passing a generator of your own to import_.

What does searching look like from Python?

The search shapes are the same as in TypeScript, written in snake case:

await memory.search({"query": "who renewed the certificate?", "limit": 8})
await memory.search({"query": "standup notes", "collections": ["ops"], "kinds": ["message"], "from": "2026-04", "to": "2026-04"})
await memory.search_text({"query": "what happened with the proxy?", "order": "time"})   # as a reader is shown it
await memory.room({"query": "what made the renewal stop?", "chars": 6000})             # the best sentences
await memory.open(hits[0]["id"])                                                         # one item whole
await memory.explore({"topic": "the proxy"})                                             # what the concept graph reaches

One design choice is worth noticing. The method names follow Python conventions, but the results do not get Pythonic keys. They are plain dicts keyed as the reference keys them: eventAt, startAt, endAt, match, record. The README gives the reason: they “serialise as they are and read the same in either language.”

That is the right trade for a library whose point is interchangeability. If a Python service and a Node service both put search results in a queue or log them, the JSON is the same. You do not need a mapping layer, and a test fixture recorded in one language checks the other.

Why is it async all the way down?

The Python version is built for async applications, such as FastAPI services and agents, rather than as a sync library with async wrappers on top. The README’s section “Async all the way down” sets out how that works:

  • Every method that does I/O is async def, and a memory is an async context manager.
  • One httpx.AsyncClient reaches your own Qdrant, embedding and rerank servers, the language model and the downloads. Downloads are streamed and given up after 60 seconds without a byte. You can pass your own client through the http option, and it stays open when the memory closes.
  • Subprocesses are asyncio subprocesses. Qdrant, tar and git run that way. Qdrant is supervised and restarted by an asyncio task.
  • CPU work runs on threads. The model’s inference (one at a time, under an asyncio.Lock), the word-by-word scoring and the reading or rewriting of whole files run off the event loop, so a search never stalls your other requests while the embedding model runs.
  • Heavy jobs take turns. Consolidate, sleep, rebuild, index_words and remove share an asyncio.Lock, and an add waits while a rebuild runs.
  • Closing is orderly. close() stops the running job at its next step and waits for it and for the adds and searches in flight.

The rule that follows is the one to remember when you build on it: a memory belongs to the event loop that opened it. Use it and close it from that loop. In a web app, open the memory in the app’s startup on the server’s loop and keep it for the life of the process, rather than opening one per request in a thread pool.

The same rule shows up in the framework retrievers. Their sync calls (invoke, retrieve) tell you to use the async ones instead, because the memory cannot be driven from another loop.

How do you give it to an agent?

There are three ways in, depending on what the agent is.

Your own agent loop or function calling. memory.tools() returns the three tools, each with a name, a description, a JSON schema and an async handler:

for tool in memory.tools():          # memory_search, memory_open, memory_explore
    tool.name, tool.description, tool.json_schema
    result = await tool.handler({"query": "proxy certificate"})   # {"content": [{"type": "text", "text": …}]}

The schemas match the reference key for key. The handler checks its own arguments (texts up to 2,000 characters, up to 50 collections) and answers a bad call with the same words the TypeScript version uses, which come from zod. An agent that has learned the tools against one implementation sees no difference in the other.

An MCP client. Since 0.5.0 the Python package carries the same Model Context Protocol server as the TypeScript one. It serves memory_search, memory_open, memory_explore, and memory_save for what the agent should remember:

{ "mcpServers": { "memory": { "command": "uvx", "args": ["memtrails", "mcp"] } } }

You fill it from the command line:

uvx memtrails add ~/notes ~/mail/archive.mbox ./slack-export      # notes and documents, mail, a Slack export
uvx memtrails add --git ~/code/my-repo                             # commit messages and changed files
uvx memtrails search "why did the certificate stop renewing?"

The memory lives in ~/.memtrails, in the same files as the TypeScript server’s, so either server can serve a memory the other filled. The server opens the memory at the first tool call and closes it after five minutes without one, so add can run in between. --read-only leaves out memory_save.

LangChain or LlamaIndex. A memory is a retriever in either framework, async like the memory:

pip install "memtrails[langchain]"      # or memtrails[llama-index]
from memtrails.integrations.langchain import MemtrailsRetriever

docs = await MemtrailsRetriever(memory=memory, search={"limit": 8}).ainvoke("why did renewal stop?")

Each document is a found item: its summary, plus the passage that matched, as the text, with its id and flat metadata. The TypeScript LangChain adapter produces the same documents, and a conformance fixture checks that.

What does “the same library” actually mean?

The claim is stronger than “the same API”. The Python README lists what is shared byte for byte or value for value:

  • The files on disk: the records.jsonl and knowledge.jsonl logs, crash recovery included, plus words.bin, usage.json, recall.jsonl, index.json, memory.lock, rebuild.json and sleep.json.
  • The Qdrant collection: named vectors dense and bm25, payload fields, point ids and index layout 7.
  • Identity: the same record ids and fingerprints.
  • Keyword tokens: ICU word segmentation, through icu4py, which ships ICU 78, the same version the reference uses.
  • Vectors: on the same ONNX Runtime (1.23.2, pinned) and the same machine, the very same vectors.
  • Prompts: every language-model request (system text, prompt and JSON Schema) is the reference’s, byte for byte.

The lock file is shared as well. A memory open in Node cannot be opened in Python at the same time, and the other way round, so two languages never write one folder at once.

This is what allows a mixed setup: a Node service that ingests, a Python worker that consolidates overnight, and a Rust tool that searches, all on one folder. It also means you can move between languages without migrating data.

How is “the same” kept true?

Two mechanisms run in CI, one per kind of failure.

How memtrails keeps three languages the same: conformance fixtures written by the TypeScript library must be reproduced by the Python and Rust tests, and interop has each language write a memory folder that the other two read and search, with 14 of 14 comparisons agreeing

Conformance catches a function that drifts. The conformance folder holds what the TypeScript library answers for hundreds of fixed inputs: word splitting in many scripts, keyword vectors, ids and hashes, dates, records, the secret filter, search text, importers, every prompt, fused search and vectors. A script runs the reference on those inputs and writes the answers to JSON fixtures. Each port’s test suite reads the same fixtures and must give the same answers, with floating-point values to 1e-6. CI regenerates the fixtures and fails when they differ from what is committed, so a change in the reference is a change every port must follow. The MCP server’s answers to a fixed session and the retrievers’ documents are recorded the same way. Every input is invented, and no benchmark data is used.

Interop catches a whole system that drifts. Unit-level agreement does not prove that a folder written by one implementation reads correctly in another. Ordering, file layout and index details can still differ. The interop harness tests that end to end. On every change to any of the three libraries, GitHub runs the same scenario:

  1. Each language writes a memory folder from one shared corpus.
  2. Each language reads every folder, on a fresh copy, and runs the same 16 queries: plain search, a Romanian, a German and a Japanese question, kind, collection and date filters, search_text, room, explore and open.
  3. Each language adds one record to every folder, and the language that wrote the folder reads it back.

Then it compares. Each folder read by another language is checked against the writer’s own read. Each language’s own write-and-read is checked against TypeScript’s. Each add by a foreign language is checked against the writer adding the record itself. That makes 14 comparisons, and all 14 agree.

“Agree” has a precise meaning here. Scores must be within 0.01, near-ties may swap places, and everything else must match exactly. The tolerance exists because ONNX Runtime’s 4-bit kernels round differently between processor types: the interop script notes a score moving by up to 0.007 on GitHub’s Linux x64 runners, while on Apple silicon the languages agree to 1e-6. For the same reason the job runs all three languages on one machine.

Which details were hardest to match?

Most of a port is translation. The rest is matching behaviour that one language gives you for free and the other does not. The conformance rules spell out where Python had to imitate JavaScript.

String lengths in UTF-16 code units. JavaScript measures strings in UTF-16 code units. Python measures them in code points. Any place the library cuts text, caps a token at 60 units, drops words under 2 units, or records sentence offsets has to count the JavaScript way, or an emoji or a rare CJK character moves every boundary after it. cut must also never end on the first half of a surrogate pair.

A keyword hash over UTF-16. Keyword ids are 32-bit FNV-1a computed over UTF-16 code units, not bytes and not code points. Hashing the UTF-8 bytes instead, the natural choice in Python, would give different keyword ids from the ones the other languages write into the same index.

Word splitting for scripts without spaces. Chinese, Japanese, Thai, Lao, Khmer and Myanmar are split with ICU’s dictionary-based word segmenter, keeping only word-like segments. The Python port uses icu4py with the same ICU version as Node.

Dates the way V8 reads them. Dates are read as V8’s Date.parse reads them, local time and mail dates included. A date alone is UTC midnight, a time without an offset is local time, and anything that is not a number, ISO 8601 or RFC 2822 is no time at all. The port reproduces those rules rather than using a Python parser’s own.

JSON the way JSON.stringify writes it. Logs and payloads are written exactly as JavaScript writes them, so both languages produce the same files.

Hashes and ids. Fingerprints are SHA-1 over parts joined with NUL, record uids are SHA-256 of source NUL id, and point ids are SHA-1 laid out as a UUID version 5.

None of this is visible from the API. All of it would surface as “Python search misses things Node finds” if it were wrong, which is why it is pinned down in fixtures rather than left to review.

How do the benchmark numbers apply to Python?

The benchmarks were measured with the TypeScript library. The Python port was not benchmarked separately. It gives the same results by construction: the conformance fixtures pin every scoring step, and interop shows that the three languages return the same results on the same folder. Read the numbers below as memtrails’ numbers, and the Python port as a way to get them from Python.

From docs/BENCHMARKS.md, against Mem0, with the same reader and judges for every system and 6,000 characters of context:

SettingmemtrailsMem0Reading
LoCoMo, 1,307 held-out questions, gather with notes, strict judge80.976.5Ahead (+113/−55 per question, p < 0.001)
LoCoMo, 1,307 held-out questions, search alone, strict judge77.476.5A tie (+97/−85, p 0.42)
LongMemEval-S, 60-question sample, gather with notes, strict judge90.088.3A tie (+4/−3)

The lead comes from gather, which asks a language model to read what search found when searching. Search alone, with no model at search time, matches Mem0 but does not beat it. On LongMemEval-S, 60 questions can only show a gap of about 10 points, so 90.0 against 88.3 is a tie. The protocol, the per-category numbers and the limitations are in the benchmark write-up, and the earlier post walks through the method.

Is it safe to run inside a service?

Each port had an independent security and bug review before release. The parts that matter for a Python service:

  • The local Qdrant is private. It listens only on 127.0.0.1 with a random key, has to prove it is the process memtrails started before it gets the key, and is never reached through a proxy configured in the environment. It also gets a minimal environment.
  • The memory folder is private and single-owner. It is created as 0700 and locked to one process.
  • Downloads are verified on disk against their pinned SHA-256.
  • Secrets are scrubbed (keys, tokens, credentials in URLs, private keys) from everything sent to a language model and from what consolidation keeps, exactly as the reference scrubs them.

What are the limits of the Python version?

The README lists them, and they are worth knowing before you commit.

  • Python 3.14 is not supported yet. The package pins ONNX Runtime 1.23.2, the same build as the TypeScript library, because identical vectors depend on an identical runtime. Python 3.14 waits for a runtime version that both libraries can pin together. Supporting it by moving Python alone to a newer runtime would give up the guarantee that vectors match.
  • Unicode versions differ. Keyword folding uses Python’s unicodedata, which is Unicode 13 on Python 3.10 and 15.1 on 3.13. Node 24 has Unicode 17, so characters added since then fold differently or not at all.
  • Collation is approximated. JavaScript’s localeCompare breaks two ties: a profile’s name among equally common spellings, and items with the same time. Python has no ICU collator here, so the root collation is approximated. It agrees on every name the tests compare.
  • Word-like segments are inferred. icu4py gives segment boundaries but not ICU’s word-or-not status, so the port reads it off the segment. Boundaries agree everywhere. On adversarial mixtures of scripts it differs from Node only for a combining mark left after a Hangul syllable or a ‿ joiner.
  • One loop, one process. A memory belongs to the event loop that opened it, and a memory folder is open in one process at a time, in any language.
  • Vectors match exactly only on the same machine type. Across processor architectures, scores move in the last digits, by up to 0.007 in the interop runs.

The general limitations of memtrails apply too: the evidence is mostly from English benchmarks, and consolidation’s prompts were tuned on conversations. The main README lists them.

Questions

Is memtrails for Python a wrapper around the Node package? No. It is native Python, with no Node process and no bindings. It reaches the same results because it is held to the TypeScript library’s answers in shared fixtures and checked against it end to end.

Can a Python service and a Node service share one memory? Yes, the folder format is the same, but not at the same moment. The lock is shared, so one process opens a memory folder at a time. You can take turns: one ingests, the other searches later.

Does it need a GPU, a server or a language model? No. The embedding model runs on the CPU and Qdrant runs as a local process the library starts. A language model is needed only for consolidate, sleep and gather, and it can be any OpenAI-compatible endpoint or your own async function.

Can I call it from synchronous code? Not directly. Every I/O method is async and the memory belongs to the event loop that opened it. From synchronous code, run your work in one event loop with asyncio.run and keep the memory inside it.

Why not Python 3.14? Identical vectors need the same ONNX Runtime build in every language, and the pinned 1.23.2 does not cover 3.14. Support waits for a runtime both libraries can pin.

Were the Python results benchmarked? No. The numbers come from the TypeScript library. The Python port reproduces its outputs by construction, as checked by conformance and interop, so the same results apply.

Get memtrails for Python

Install it with pip install memtrails, then run the four examples in the repository’s python/examples folder: a quick start, importing sources, the agent tools and consolidation. The code, the conformance fixtures, the interop harness and the full benchmark write-up are on GitHub, and the package is on PyPI.

ბლოგის სხვა პოსტები