memtrails 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. memtrails-0.4.1/.gitignore +8 -0
  2. memtrails-0.4.1/LICENSE +21 -0
  3. memtrails-0.4.1/PKG-INFO +237 -0
  4. memtrails-0.4.1/README.md +200 -0
  5. memtrails-0.4.1/pyproject.toml +78 -0
  6. memtrails-0.4.1/src/memtrails/__init__.py +74 -0
  7. memtrails-0.4.1/src/memtrails/_aio.py +23 -0
  8. memtrails-0.4.1/src/memtrails/_fs.py +44 -0
  9. memtrails-0.4.1/src/memtrails/_http.py +25 -0
  10. memtrails-0.4.1/src/memtrails/_js.py +830 -0
  11. memtrails-0.4.1/src/memtrails/core/__init__.py +1 -0
  12. memtrails-0.4.1/src/memtrails/core/consolidate.py +977 -0
  13. memtrails-0.4.1/src/memtrails/core/embedder.py +214 -0
  14. memtrails-0.4.1/src/memtrails/core/episodes.py +78 -0
  15. memtrails-0.4.1/src/memtrails/core/extract.py +665 -0
  16. memtrails-0.4.1/src/memtrails/core/gather.py +209 -0
  17. memtrails-0.4.1/src/memtrails/core/graph.py +239 -0
  18. memtrails-0.4.1/src/memtrails/core/local_embedder.py +178 -0
  19. memtrails-0.4.1/src/memtrails/core/model.py +105 -0
  20. memtrails-0.4.1/src/memtrails/core/paths.py +15 -0
  21. memtrails-0.4.1/src/memtrails/core/profiles.py +218 -0
  22. memtrails-0.4.1/src/memtrails/core/qdrant_binary.py +359 -0
  23. memtrails-0.4.1/src/memtrails/core/qdrant_client.py +421 -0
  24. memtrails-0.4.1/src/memtrails/core/qdrant_process.py +457 -0
  25. memtrails-0.4.1/src/memtrails/core/rebuild.py +201 -0
  26. memtrails-0.4.1/src/memtrails/core/recall.py +352 -0
  27. memtrails-0.4.1/src/memtrails/core/recall_log.py +142 -0
  28. memtrails-0.4.1/src/memtrails/core/redact.py +176 -0
  29. memtrails-0.4.1/src/memtrails/core/reranker.py +116 -0
  30. memtrails-0.4.1/src/memtrails/core/room.py +93 -0
  31. memtrails-0.4.1/src/memtrails/core/segment.py +57 -0
  32. memtrails-0.4.1/src/memtrails/core/sleep.py +267 -0
  33. memtrails-0.4.1/src/memtrails/core/store.py +424 -0
  34. memtrails-0.4.1/src/memtrails/core/text.py +162 -0
  35. memtrails-0.4.1/src/memtrails/core/words.py +187 -0
  36. memtrails-0.4.1/src/memtrails/core/writer.py +291 -0
  37. memtrails-0.4.1/src/memtrails/importers/__init__.py +60 -0
  38. memtrails-0.4.1/src/memtrails/importers/files.py +333 -0
  39. memtrails-0.4.1/src/memtrails/importers/git.py +193 -0
  40. memtrails-0.4.1/src/memtrails/importers/jsonl.py +94 -0
  41. memtrails-0.4.1/src/memtrails/importers/mbox.py +494 -0
  42. memtrails-0.4.1/src/memtrails/importers/slack.py +236 -0
  43. memtrails-0.4.1/src/memtrails/importers/text.py +651 -0
  44. memtrails-0.4.1/src/memtrails/llm.py +192 -0
  45. memtrails-0.4.1/src/memtrails/memory.py +794 -0
  46. memtrails-0.4.1/src/memtrails/py.typed +0 -0
  47. memtrails-0.4.1/src/memtrails/records.py +269 -0
  48. memtrails-0.4.1/src/memtrails/tools/__init__.py +1 -0
  49. memtrails-0.4.1/src/memtrails/tools/agent_tools.py +409 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Corneliu Maftuleac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.5
2
+ Name: memtrails
3
+ Version: 0.4.1
4
+ Summary: Retrieval and exploration for any text — e-mail, chat, notes, documents, news, commits, conversations. Searchable at once, on your own machine, in any language; optional consolidation into typed knowledge with a language model.
5
+ Project-URL: Homepage, https://github.com/cmaftuleac/memtrails
6
+ Project-URL: Repository, https://github.com/cmaftuleac/memtrails
7
+ Project-URL: Issues, https://github.com/cmaftuleac/memtrails/issues
8
+ Author: Corneliu Maftuleac
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent-memory,colbert,embeddings,knowledge-graph,late-interaction,llm,long-term-memory,maxsim,mcp,memory,multilingual,qdrant,rag,retrieval,search
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Text Processing :: Indexing
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: icu4py>=1.1
27
+ Requires-Dist: numpy>=1.24
28
+ Requires-Dist: onnxruntime==1.23.2
29
+ Requires-Dist: regex>=2024.4.16
30
+ Requires-Dist: tokenizers>=0.20
31
+ Provides-Extra: dev
32
+ Requires-Dist: mypy==2.4.1; extra == 'dev'
33
+ Requires-Dist: pytest-asyncio==1.4.1.post1; extra == 'dev'
34
+ Requires-Dist: pytest==9.2.0; extra == 'dev'
35
+ Requires-Dist: ruff==0.17.3; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # memtrails for Python
39
+
40
+ **Retrieval and exploration for any text — e-mail, chat, notes, memos, documents, news, commit messages, conversations.**
41
+
42
+ A native Python port of [memtrails](https://github.com/cmaftuleac/memtrails) (npm `memtrails` 0.4.1): the same algorithms,
43
+ constants, prompts, JSON Schemas and files, written in Python and natively async. Everything you add is searchable the
44
+ moment it is added, on your own machine, with no server and no language model: a local embedding model, a keyword
45
+ channel, and a word-by-word reordering that needs no reranker. Give it a language model too, and it consolidates what it
46
+ holds into typed knowledge, dated events and profiles — and an agent can explore it.
47
+
48
+ ```python
49
+ import asyncio
50
+ from memtrails import open_memory
51
+
52
+ async def main() -> None:
53
+ async with await open_memory(home="./memory") as memory:
54
+ await memory.add([
55
+ {"id": "msg-1", "kind": "email", "author": "Ana", "title": "Proxy certificate expired", "at": "2026-04-02",
56
+ "text": "The TLS certificate expired on the 1st. Renewal stopped because the cron job was removed in March."},
57
+ {"id": "c9f1e2", "kind": "commit", "author": "Ion", "title": "Restore certificate renewal cron", "at": "2026-04-03",
58
+ "text": "Files: deploy/cron/renew.sh"},
59
+ ])
60
+ hits = await memory.search({"query": "why did the certificate stop renewing?"})
61
+ # [{"id": "stm:…", "kind": "email", "title": …, "summary": …, "ts": …, "record": {"id": "msg-1", "author": "Ana"}, "score": …}, …]
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install memtrails
70
+ ```
71
+
72
+ Python 3.10 to 3.13, on macOS or Linux (x64 or arm64). The first `open_memory` downloads the embedding model (218 MB, from
73
+ Hugging Face) and the Qdrant binary (~30 MB, from Qdrant's GitHub release) into `~/.cache/memtrails`, each checked against
74
+ a pinned SHA-256 — the same files, in the same folder, as the TypeScript library, so the two share one download. Set
75
+ `MEMTRAILS_CACHE` to put them elsewhere.
76
+
77
+ ## One memory, two languages
78
+
79
+ A memory folder written by the TypeScript library opens and searches in Python with the same results, and the other way
80
+ round: the same `records.jsonl` and `knowledge.jsonl` logs (crash recovery included), `words.bin`, `usage.json`,
81
+ `recall.jsonl`, `index.json`, `memory.lock`, `rebuild.json` and `sleep.json`; the same Qdrant collection (named vectors
82
+ `dense` and `bm25`, payload fields, point ids, index layout 7); the same record ids and fingerprints; the same keyword
83
+ tokens (ICU word segmentation, through `icu4py`, which ships ICU 78 — the reference's); and, on the same ONNX Runtime
84
+ (1.23.2, pinned) and the same machine, the very same vectors (its 4-bit kernels differ slightly between CPU
85
+ architectures, in both libraries). The one-process-a-home lock is shared: a memory open in Node cannot be opened in
86
+ Python at the same time, and the other way round.
87
+
88
+ Strings are measured as JavaScript measures them (UTF-16 code units), JSON is written as `JSON.stringify` writes it, and
89
+ dates are read as V8's `Date.parse` reads them, local time and mail dates included. Every language-model request — system
90
+ text, prompt and JSON Schema — is the reference's, byte for byte.
91
+
92
+ The port is held to the conformance fixtures in [`../conformance`](../conformance): what the TypeScript library gives for
93
+ fixed inputs (text, words, ids, dates, records, the secret filter, search text, importers, prompts, fused search, vectors).
94
+
95
+ ## Adding data
96
+
97
+ A record is anything with text. Only `id` and `text` are required.
98
+
99
+ ```python
100
+ await memory.add({
101
+ "id": "<CAF8w@mail.example>", # unique within its source; adding the same id again updates it
102
+ "source": "work-mailbox", # part of the identity
103
+ "kind": "email", # any word: email, message, note, memo, article, commit, ticket, …
104
+ "collection": "ops", # search can be scoped to collections
105
+ "thread": "proxy-cert", # records that belong together
106
+ "author": "Ana Popescu",
107
+ "title": "Proxy certificate expired",
108
+ "text": "…",
109
+ "at": "2026-04-02T09:14:00Z", # ms, an ISO or RFC 2822 string, or a datetime
110
+ "url": "https://mail.example/…",
111
+ "metadata": {"to": "ops@example.com"},
112
+ })
113
+
114
+ await memory.add_conversation({"id": "chat-42", "turns": [
115
+ {"role": "user", "text": "Where do we have lunch on Friday?"},
116
+ {"role": "assistant", "text": "At the Italian place near the office, at one."},
117
+ ]})
118
+ ```
119
+
120
+ ### Importers
121
+
122
+ Each importer is an async generator: records stream one at a time, never the whole source in memory.
123
+
124
+ ```python
125
+ from memtrails.importers import from_mbox, from_eml, from_slack_export, from_git, from_files, from_jsonl, batches
126
+
127
+ await memory.import_(from_mbox("./archive.mbox")) # e-mail (mbox / .eml)
128
+ await memory.import_(from_slack_export("./slack-export")) # a Slack workspace export
129
+ await memory.import_(from_git("./my-repo", since="2025-01-01")) # commit messages and changed files
130
+ await memory.import_(from_files("./notes")) # Markdown, text and HTML files
131
+ await memory.import_(from_jsonl("./records.jsonl")) # one record per line
132
+
133
+ async for record in from_mbox("./archive.mbox"):
134
+ ...
135
+ ```
136
+
137
+ `import_` takes any async iterable of records (a plain iterable too), so a database cursor or an API client works the
138
+ same way. Unchanged records cost nothing; `await memory.remove([...ids])` forgets.
139
+
140
+ ## Searching
141
+
142
+ ```python
143
+ await memory.search({"query": "who renewed the certificate?", "limit": 8})
144
+ await memory.search({"query": "standup notes", "collections": ["ops"], "kinds": ["message"], "from": "2026-04", "to": "2026-04"})
145
+ await memory.search_text({"query": "what happened with the proxy?", "order": "time"}) # as a reader is shown it
146
+ await memory.room({"query": "what made the renewal stop?", "chars": 6000}) # the best sentences
147
+ await memory.open(hits[0]["id"]) # one item whole
148
+ await memory.explore({"topic": "the proxy"}) # what the concept graph reaches
149
+ ```
150
+
151
+ Results are plain dicts keyed as the reference keys them (`eventAt`, `startAt`, `endAt`, `match`, `record`), so they
152
+ serialise as they are and read the same in either language.
153
+
154
+ ## Consolidation and sleep (optional, with a language model)
155
+
156
+ ```python
157
+ memory = await open_memory(
158
+ home="./memory",
159
+ llm={"model": "gpt-6-luna", "reasoning_effort": "low", "api_key": os.environ["OPENAI_API_KEY"]}, # or base_url for any OpenAI-compatible server
160
+ )
161
+ await memory.consolidate() # after adding
162
+ await memory.sleep() # once a day
163
+ await memory.gather({"query": "every plumber we called this year"}, notes=True)
164
+ ```
165
+
166
+ Any model works: pass `llm={"base_url": …, "model": …, "api_key": …}` for an OpenAI-compatible server, or your own async
167
+ function:
168
+
169
+ ```python
170
+ from memtrails import LlmRequest
171
+
172
+ async def ask(req: LlmRequest) -> dict:
173
+ ... # req.system, req.prompt, req.schema (strict JSON Schema), req.name, req.max_tokens, req.timeout_ms
174
+ return {"answer": {...}} # or {"unavailable": "why"}
175
+ ```
176
+
177
+ Secrets (keys, tokens, credentials in URLs, private keys) are scrubbed from everything sent to a language model and from
178
+ what consolidation keeps, exactly as the reference scrubs them. `OPENAI_API_KEY` is sent only to OpenAI's own address.
179
+
180
+ ## For agents
181
+
182
+ ```python
183
+ for tool in memory.tools(): # memory_search, memory_open, memory_explore
184
+ tool.name, tool.description, tool.json_schema
185
+ result = await tool.handler({"query": "proxy certificate"}) # {"content": [{"type": "text", "text": …}]}
186
+ ```
187
+
188
+ The schemas are the reference's, key for key; the handler checks its arguments itself (texts up to 2,000 characters, up
189
+ to 50 collections) and answers a bad call with zod's words.
190
+
191
+ ## Configuration
192
+
193
+ | Option of `open_memory` | Default | |
194
+ |---|---|---|
195
+ | `home` | — | Where this memory lives: its logs, index and word vectors (made private, 0700). |
196
+ | `embedding` | the model fetched into the cache | `{"model_dir": …}`, `{"server": {"url", "api_key"?, "model"?, "gzip"?}}`, `{"threads": 2}`. |
197
+ | `rerank` | `"words"` | `"words"`, a rerank server `{"url", …}`, your own async function, or `False`. |
198
+ | `llm` | from the environment | Your own async `Ask`, or `{"model", "api_key"?, "base_url"?, "reasoning_effort"?, "on_usage"?, "retries"?}`. |
199
+ | `qdrant` | a process of its own | `{"url", "api_key"?, "collection"?}` for your Qdrant server, or `{"binary": …}`. |
200
+ | `concurrency` | 4 | Windows the language model reads at once. |
201
+ | `http` | one of its own | The `httpx.AsyncClient` every call goes through (yours stays open when the memory closes). |
202
+ | `log` | silent | A function of one line. |
203
+
204
+ The environment variables are the reference's: `MEMTRAILS_CACHE`, `MEMTRAILS_MODEL`, `MEMTRAILS_LLM_API_KEY` or
205
+ `OPENAI_API_KEY`, `MEMTRAILS_LLM_BASE_URL`, `MEMTRAILS_EMBED_URL` / `_KEY` / `_MODEL`, `MEMTRAILS_RERANK_URL` / `_KEY` / `_MODEL`.
206
+
207
+ ## Async all the way down
208
+
209
+ Every method that does I/O is `async def`, and a memory is an async context manager. A memory belongs to the event loop
210
+ that opened it: use it, and close it, from that loop. One `httpx.AsyncClient` reaches a Qdrant of yours, the embedding
211
+ and rerank servers, the language model and the downloads (streamed, given up after 60 s without a byte); memory's own
212
+ Qdrant is reached through a client of its own that takes no proxy from the environment. Qdrant, `tar` and `git` run as
213
+ asyncio subprocesses; Qdrant is supervised and restarted by an asyncio task, bound to 127.0.0.1 with a random key,
214
+ proven to be ours before it gets the key, and given a minimal environment. The model's inference (one at a time, under
215
+ an `asyncio.Lock`), word-by-word scoring, and the reading or rewriting of whole files run on threads, so the event loop
216
+ never waits on them. Consolidate, sleep, rebuild, index_words and remove take turns under an `asyncio.Lock`, and an add
217
+ waits while a rebuild runs; `close()` stops the running one at its next step and waits for it and for the adds and
218
+ searches in flight.
219
+
220
+ ## Known differences
221
+
222
+ - **Word-like segments**: ICU tells JavaScript whether a segment is a word (its rule status); `icu4py` gives the segments
223
+ but not the status, so it is read off the segment. On adversarial mixtures of every script it agrees with Node except
224
+ for a combining mark left alone after a Hangul syllable or a `‿` joiner — text no one writes. Boundaries agree everywhere.
225
+ - **Unicode versions**: `fold` uses Python's `unicodedata` (Unicode 13 on Python 3.10, 15.1 on 3.13) where Node 24 has
226
+ Unicode 17: characters added since fold differently or not at all.
227
+ - **Collation**: `localeCompare` breaks two ties (a profile's name among equally common spellings, items of the same time);
228
+ Python has no ICU collator here, so the root collation is approximated (punctuation, digits, letters by base, then
229
+ accents, then case). It agrees on every name memory compares in the tests.
230
+ - **ONNX Runtime**: pinned to 1.23.2 for identical vectors; Python 3.14 waits for a runtime that both libraries pin.
231
+ - **Windows** has the pieces it needs but is not tested yet (as with the TypeScript library).
232
+
233
+ ## Licence
234
+
235
+ MIT © Corneliu Maftuleac. Third parties: EmbeddingGemma is Google's, under the
236
+ [Gemma Terms of Use](https://ai.google.dev/gemma/terms), downloaded from Hugging Face, not shipped; Qdrant is Apache-2.0,
237
+ downloaded from its release; ICU (in `icu4py`) is under the Unicode licence.
@@ -0,0 +1,200 @@
1
+ # memtrails for Python
2
+
3
+ **Retrieval and exploration for any text — e-mail, chat, notes, memos, documents, news, commit messages, conversations.**
4
+
5
+ A native Python port of [memtrails](https://github.com/cmaftuleac/memtrails) (npm `memtrails` 0.4.1): the same algorithms,
6
+ constants, prompts, JSON Schemas and files, written in Python and natively async. Everything you add is searchable the
7
+ moment it is added, on your own machine, with no server and no language model: a local embedding model, a keyword
8
+ channel, and a word-by-word reordering that needs no reranker. Give it a language model too, and it consolidates what it
9
+ holds into typed knowledge, dated events and profiles — and an agent can explore it.
10
+
11
+ ```python
12
+ import asyncio
13
+ from memtrails import open_memory
14
+
15
+ async def main() -> None:
16
+ async with await open_memory(home="./memory") as memory:
17
+ await memory.add([
18
+ {"id": "msg-1", "kind": "email", "author": "Ana", "title": "Proxy certificate expired", "at": "2026-04-02",
19
+ "text": "The TLS certificate expired on the 1st. Renewal stopped because the cron job was removed in March."},
20
+ {"id": "c9f1e2", "kind": "commit", "author": "Ion", "title": "Restore certificate renewal cron", "at": "2026-04-03",
21
+ "text": "Files: deploy/cron/renew.sh"},
22
+ ])
23
+ hits = await memory.search({"query": "why did the certificate stop renewing?"})
24
+ # [{"id": "stm:…", "kind": "email", "title": …, "summary": …, "ts": …, "record": {"id": "msg-1", "author": "Ana"}, "score": …}, …]
25
+
26
+ asyncio.run(main())
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install memtrails
33
+ ```
34
+
35
+ Python 3.10 to 3.13, on macOS or Linux (x64 or arm64). The first `open_memory` downloads the embedding model (218 MB, from
36
+ Hugging Face) and the Qdrant binary (~30 MB, from Qdrant's GitHub release) into `~/.cache/memtrails`, each checked against
37
+ a pinned SHA-256 — the same files, in the same folder, as the TypeScript library, so the two share one download. Set
38
+ `MEMTRAILS_CACHE` to put them elsewhere.
39
+
40
+ ## One memory, two languages
41
+
42
+ A memory folder written by the TypeScript library opens and searches in Python with the same results, and the other way
43
+ round: the same `records.jsonl` and `knowledge.jsonl` logs (crash recovery included), `words.bin`, `usage.json`,
44
+ `recall.jsonl`, `index.json`, `memory.lock`, `rebuild.json` and `sleep.json`; the same Qdrant collection (named vectors
45
+ `dense` and `bm25`, payload fields, point ids, index layout 7); the same record ids and fingerprints; the same keyword
46
+ tokens (ICU word segmentation, through `icu4py`, which ships ICU 78 — the reference's); and, on the same ONNX Runtime
47
+ (1.23.2, pinned) and the same machine, the very same vectors (its 4-bit kernels differ slightly between CPU
48
+ architectures, in both libraries). The one-process-a-home lock is shared: a memory open in Node cannot be opened in
49
+ Python at the same time, and the other way round.
50
+
51
+ Strings are measured as JavaScript measures them (UTF-16 code units), JSON is written as `JSON.stringify` writes it, and
52
+ dates are read as V8's `Date.parse` reads them, local time and mail dates included. Every language-model request — system
53
+ text, prompt and JSON Schema — is the reference's, byte for byte.
54
+
55
+ The port is held to the conformance fixtures in [`../conformance`](../conformance): what the TypeScript library gives for
56
+ fixed inputs (text, words, ids, dates, records, the secret filter, search text, importers, prompts, fused search, vectors).
57
+
58
+ ## Adding data
59
+
60
+ A record is anything with text. Only `id` and `text` are required.
61
+
62
+ ```python
63
+ await memory.add({
64
+ "id": "<CAF8w@mail.example>", # unique within its source; adding the same id again updates it
65
+ "source": "work-mailbox", # part of the identity
66
+ "kind": "email", # any word: email, message, note, memo, article, commit, ticket, …
67
+ "collection": "ops", # search can be scoped to collections
68
+ "thread": "proxy-cert", # records that belong together
69
+ "author": "Ana Popescu",
70
+ "title": "Proxy certificate expired",
71
+ "text": "…",
72
+ "at": "2026-04-02T09:14:00Z", # ms, an ISO or RFC 2822 string, or a datetime
73
+ "url": "https://mail.example/…",
74
+ "metadata": {"to": "ops@example.com"},
75
+ })
76
+
77
+ await memory.add_conversation({"id": "chat-42", "turns": [
78
+ {"role": "user", "text": "Where do we have lunch on Friday?"},
79
+ {"role": "assistant", "text": "At the Italian place near the office, at one."},
80
+ ]})
81
+ ```
82
+
83
+ ### Importers
84
+
85
+ Each importer is an async generator: records stream one at a time, never the whole source in memory.
86
+
87
+ ```python
88
+ from memtrails.importers import from_mbox, from_eml, from_slack_export, from_git, from_files, from_jsonl, batches
89
+
90
+ await memory.import_(from_mbox("./archive.mbox")) # e-mail (mbox / .eml)
91
+ await memory.import_(from_slack_export("./slack-export")) # a Slack workspace export
92
+ await memory.import_(from_git("./my-repo", since="2025-01-01")) # commit messages and changed files
93
+ await memory.import_(from_files("./notes")) # Markdown, text and HTML files
94
+ await memory.import_(from_jsonl("./records.jsonl")) # one record per line
95
+
96
+ async for record in from_mbox("./archive.mbox"):
97
+ ...
98
+ ```
99
+
100
+ `import_` takes any async iterable of records (a plain iterable too), so a database cursor or an API client works the
101
+ same way. Unchanged records cost nothing; `await memory.remove([...ids])` forgets.
102
+
103
+ ## Searching
104
+
105
+ ```python
106
+ await memory.search({"query": "who renewed the certificate?", "limit": 8})
107
+ await memory.search({"query": "standup notes", "collections": ["ops"], "kinds": ["message"], "from": "2026-04", "to": "2026-04"})
108
+ await memory.search_text({"query": "what happened with the proxy?", "order": "time"}) # as a reader is shown it
109
+ await memory.room({"query": "what made the renewal stop?", "chars": 6000}) # the best sentences
110
+ await memory.open(hits[0]["id"]) # one item whole
111
+ await memory.explore({"topic": "the proxy"}) # what the concept graph reaches
112
+ ```
113
+
114
+ Results are plain dicts keyed as the reference keys them (`eventAt`, `startAt`, `endAt`, `match`, `record`), so they
115
+ serialise as they are and read the same in either language.
116
+
117
+ ## Consolidation and sleep (optional, with a language model)
118
+
119
+ ```python
120
+ memory = await open_memory(
121
+ home="./memory",
122
+ llm={"model": "gpt-6-luna", "reasoning_effort": "low", "api_key": os.environ["OPENAI_API_KEY"]}, # or base_url for any OpenAI-compatible server
123
+ )
124
+ await memory.consolidate() # after adding
125
+ await memory.sleep() # once a day
126
+ await memory.gather({"query": "every plumber we called this year"}, notes=True)
127
+ ```
128
+
129
+ Any model works: pass `llm={"base_url": …, "model": …, "api_key": …}` for an OpenAI-compatible server, or your own async
130
+ function:
131
+
132
+ ```python
133
+ from memtrails import LlmRequest
134
+
135
+ async def ask(req: LlmRequest) -> dict:
136
+ ... # req.system, req.prompt, req.schema (strict JSON Schema), req.name, req.max_tokens, req.timeout_ms
137
+ return {"answer": {...}} # or {"unavailable": "why"}
138
+ ```
139
+
140
+ Secrets (keys, tokens, credentials in URLs, private keys) are scrubbed from everything sent to a language model and from
141
+ what consolidation keeps, exactly as the reference scrubs them. `OPENAI_API_KEY` is sent only to OpenAI's own address.
142
+
143
+ ## For agents
144
+
145
+ ```python
146
+ for tool in memory.tools(): # memory_search, memory_open, memory_explore
147
+ tool.name, tool.description, tool.json_schema
148
+ result = await tool.handler({"query": "proxy certificate"}) # {"content": [{"type": "text", "text": …}]}
149
+ ```
150
+
151
+ The schemas are the reference's, key for key; the handler checks its arguments itself (texts up to 2,000 characters, up
152
+ to 50 collections) and answers a bad call with zod's words.
153
+
154
+ ## Configuration
155
+
156
+ | Option of `open_memory` | Default | |
157
+ |---|---|---|
158
+ | `home` | — | Where this memory lives: its logs, index and word vectors (made private, 0700). |
159
+ | `embedding` | the model fetched into the cache | `{"model_dir": …}`, `{"server": {"url", "api_key"?, "model"?, "gzip"?}}`, `{"threads": 2}`. |
160
+ | `rerank` | `"words"` | `"words"`, a rerank server `{"url", …}`, your own async function, or `False`. |
161
+ | `llm` | from the environment | Your own async `Ask`, or `{"model", "api_key"?, "base_url"?, "reasoning_effort"?, "on_usage"?, "retries"?}`. |
162
+ | `qdrant` | a process of its own | `{"url", "api_key"?, "collection"?}` for your Qdrant server, or `{"binary": …}`. |
163
+ | `concurrency` | 4 | Windows the language model reads at once. |
164
+ | `http` | one of its own | The `httpx.AsyncClient` every call goes through (yours stays open when the memory closes). |
165
+ | `log` | silent | A function of one line. |
166
+
167
+ The environment variables are the reference's: `MEMTRAILS_CACHE`, `MEMTRAILS_MODEL`, `MEMTRAILS_LLM_API_KEY` or
168
+ `OPENAI_API_KEY`, `MEMTRAILS_LLM_BASE_URL`, `MEMTRAILS_EMBED_URL` / `_KEY` / `_MODEL`, `MEMTRAILS_RERANK_URL` / `_KEY` / `_MODEL`.
169
+
170
+ ## Async all the way down
171
+
172
+ Every method that does I/O is `async def`, and a memory is an async context manager. A memory belongs to the event loop
173
+ that opened it: use it, and close it, from that loop. One `httpx.AsyncClient` reaches a Qdrant of yours, the embedding
174
+ and rerank servers, the language model and the downloads (streamed, given up after 60 s without a byte); memory's own
175
+ Qdrant is reached through a client of its own that takes no proxy from the environment. Qdrant, `tar` and `git` run as
176
+ asyncio subprocesses; Qdrant is supervised and restarted by an asyncio task, bound to 127.0.0.1 with a random key,
177
+ proven to be ours before it gets the key, and given a minimal environment. The model's inference (one at a time, under
178
+ an `asyncio.Lock`), word-by-word scoring, and the reading or rewriting of whole files run on threads, so the event loop
179
+ never waits on them. Consolidate, sleep, rebuild, index_words and remove take turns under an `asyncio.Lock`, and an add
180
+ waits while a rebuild runs; `close()` stops the running one at its next step and waits for it and for the adds and
181
+ searches in flight.
182
+
183
+ ## Known differences
184
+
185
+ - **Word-like segments**: ICU tells JavaScript whether a segment is a word (its rule status); `icu4py` gives the segments
186
+ but not the status, so it is read off the segment. On adversarial mixtures of every script it agrees with Node except
187
+ for a combining mark left alone after a Hangul syllable or a `‿` joiner — text no one writes. Boundaries agree everywhere.
188
+ - **Unicode versions**: `fold` uses Python's `unicodedata` (Unicode 13 on Python 3.10, 15.1 on 3.13) where Node 24 has
189
+ Unicode 17: characters added since fold differently or not at all.
190
+ - **Collation**: `localeCompare` breaks two ties (a profile's name among equally common spellings, items of the same time);
191
+ Python has no ICU collator here, so the root collation is approximated (punctuation, digits, letters by base, then
192
+ accents, then case). It agrees on every name memory compares in the tests.
193
+ - **ONNX Runtime**: pinned to 1.23.2 for identical vectors; Python 3.14 waits for a runtime that both libraries pin.
194
+ - **Windows** has the pieces it needs but is not tested yet (as with the TypeScript library).
195
+
196
+ ## Licence
197
+
198
+ MIT © Corneliu Maftuleac. Third parties: EmbeddingGemma is Google's, under the
199
+ [Gemma Terms of Use](https://ai.google.dev/gemma/terms), downloaded from Hugging Face, not shipped; Qdrant is Apache-2.0,
200
+ downloaded from its release; ICU (in `icu4py`) is under the Unicode licence.
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "memtrails"
7
+ version = "0.4.1"
8
+ description = "Retrieval and exploration for any text — e-mail, chat, notes, documents, news, commits, conversations. Searchable at once, on your own machine, in any language; optional consolidation into typed knowledge with a language model."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Corneliu Maftuleac" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["retrieval", "search", "memory", "rag", "agent-memory", "long-term-memory", "embeddings", "late-interaction",
15
+ "colbert", "maxsim", "qdrant", "knowledge-graph", "llm", "mcp", "multilingual"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ "Topic :: Text Processing :: Indexing",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = [
31
+ "httpx>=0.27",
32
+ "icu4py>=1.1",
33
+ "numpy>=1.24",
34
+ "onnxruntime==1.23.2",
35
+ "regex>=2024.4.16",
36
+ "tokenizers>=0.20",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["mypy==2.4.1", "pytest==9.2.0", "pytest-asyncio==1.4.1.post1", "ruff==0.17.3"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/cmaftuleac/memtrails"
44
+ Repository = "https://github.com/cmaftuleac/memtrails"
45
+ Issues = "https://github.com/cmaftuleac/memtrails/issues"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/memtrails"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src/memtrails", "README.md", "LICENSE", "pyproject.toml"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ asyncio_mode = "auto"
56
+ asyncio_default_fixture_loop_scope = "function"
57
+ markers = ["integration: needs the real embedding model and a real Qdrant (MEMTRAILS_INTEGRATION=1)"]
58
+ filterwarnings = ["error::pytest.PytestUnraisableExceptionWarning"]
59
+
60
+ [tool.ruff]
61
+ line-length = 140
62
+ target-version = "py310"
63
+ src = ["src", "tests"]
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "BLE"]
67
+ ignore = ["RUF001", "RUF002", "RUF003", "SIM108", "B008", "SIM102", "SIM105", "SIM401", "SIM112", "E501"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ strict = true
72
+ warn_return_any = false
73
+ disallow_any_generics = false
74
+ files = ["src/memtrails"]
75
+
76
+ [[tool.mypy.overrides]]
77
+ module = ["icu4py.*", "regex", "regex.*", "tokenizers", "onnxruntime"]
78
+ ignore_missing_imports = true
@@ -0,0 +1,74 @@
1
+ """
2
+ memtrails — retrieval and exploration for any text: e-mail, chat, notes, documents, news, commits, conversations.
3
+
4
+ add records, conversations, or a stream from an importer — searchable at once, no model asked
5
+ search dense + keyword, fused; reordered word by word on this machine; dates first when asked
6
+ room the best sentences of what was found, for a small context
7
+ open one item whole, with the records it was learned from
8
+ explore the concept graph from a topic or items to what search does not find
9
+ consolidate (with a language model) typed knowledge, dated observations, kept episodes
10
+ sleep (with a language model) merge, supersede, link, profiles
11
+ gather (with a language model) every instance a counting or listing question needs
12
+ tools memory_search / memory_open / memory_explore for an agent
13
+
14
+ A native port of the TypeScript library (npm ``memtrails``): the same algorithms, prompts and files.
15
+ """
16
+
17
+ from .core.embedder import EmbedRoute
18
+ from .core.gather import Gathered
19
+ from .core.model import MODEL_REPO, MODEL_REVISION, default_model_dir, ensure_model
20
+ from .core.paths import cache_dir
21
+ from .core.qdrant_binary import QDRANT_VERSION, ensure_qdrant
22
+ from .core.recall import Explored, RecalledItem, RecordRef
23
+ from .core.reranker import RerankRoute
24
+ from .core.room import sentence_room, sentence_spans
25
+ from .core.words import WORD_LIMIT, PackedWords, WordStore, WordVectors, max_sim, pack_words
26
+ from .llm import Ask, LlmRequest, LlmUsage, OpenAiAsk, openai_ask
27
+ from .memory import EmbeddingOptions, Memory, QdrantOptions, SearchOptions, open_memory
28
+ from .records import DEFAULT_COLLECTION, Conversation, MemoryRecord, record_uid, time_of
29
+ from .tools.agent_tools import AgentTool, build_agent_tools, search_text
30
+
31
+ __version__ = "0.4.1"
32
+
33
+ __all__ = [
34
+ "DEFAULT_COLLECTION",
35
+ "MODEL_REPO",
36
+ "MODEL_REVISION",
37
+ "QDRANT_VERSION",
38
+ "WORD_LIMIT",
39
+ "AgentTool",
40
+ "Ask",
41
+ "Conversation",
42
+ "EmbedRoute",
43
+ "EmbeddingOptions",
44
+ "Explored",
45
+ "Gathered",
46
+ "LlmRequest",
47
+ "LlmUsage",
48
+ "Memory",
49
+ "MemoryRecord",
50
+ "OpenAiAsk",
51
+ "PackedWords",
52
+ "QdrantOptions",
53
+ "RecalledItem",
54
+ "RecordRef",
55
+ "RerankRoute",
56
+ "SearchOptions",
57
+ "WordStore",
58
+ "WordVectors",
59
+ "__version__",
60
+ "build_agent_tools",
61
+ "cache_dir",
62
+ "default_model_dir",
63
+ "ensure_model",
64
+ "ensure_qdrant",
65
+ "max_sim",
66
+ "open_memory",
67
+ "openai_ask",
68
+ "pack_words",
69
+ "record_uid",
70
+ "search_text",
71
+ "sentence_room",
72
+ "sentence_spans",
73
+ "time_of",
74
+ ]
@@ -0,0 +1,23 @@
1
+ """asyncio pieces the port needs on every Python it supports (3.10 has neither TaskGroup nor asyncio.timeout)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ async def gather_or_cancel(*aws: Awaitable[T]) -> list[T]:
13
+ """``asyncio.gather``, but the first that raises cancels the others before it is raised, and they have ended when it
14
+ is: asks the model is paid for are not left running for an answer no one reads (a cancelled caller cancels them
15
+ too, as gather does)."""
16
+ tasks: list[asyncio.Future[T]] = [asyncio.ensure_future(a) for a in aws]
17
+ try:
18
+ return list(await asyncio.gather(*tasks))
19
+ except BaseException:
20
+ for t in tasks:
21
+ t.cancel()
22
+ await asyncio.gather(*tasks, return_exceptions=True)
23
+ raise