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.
- memtrails-0.4.1/.gitignore +8 -0
- memtrails-0.4.1/LICENSE +21 -0
- memtrails-0.4.1/PKG-INFO +237 -0
- memtrails-0.4.1/README.md +200 -0
- memtrails-0.4.1/pyproject.toml +78 -0
- memtrails-0.4.1/src/memtrails/__init__.py +74 -0
- memtrails-0.4.1/src/memtrails/_aio.py +23 -0
- memtrails-0.4.1/src/memtrails/_fs.py +44 -0
- memtrails-0.4.1/src/memtrails/_http.py +25 -0
- memtrails-0.4.1/src/memtrails/_js.py +830 -0
- memtrails-0.4.1/src/memtrails/core/__init__.py +1 -0
- memtrails-0.4.1/src/memtrails/core/consolidate.py +977 -0
- memtrails-0.4.1/src/memtrails/core/embedder.py +214 -0
- memtrails-0.4.1/src/memtrails/core/episodes.py +78 -0
- memtrails-0.4.1/src/memtrails/core/extract.py +665 -0
- memtrails-0.4.1/src/memtrails/core/gather.py +209 -0
- memtrails-0.4.1/src/memtrails/core/graph.py +239 -0
- memtrails-0.4.1/src/memtrails/core/local_embedder.py +178 -0
- memtrails-0.4.1/src/memtrails/core/model.py +105 -0
- memtrails-0.4.1/src/memtrails/core/paths.py +15 -0
- memtrails-0.4.1/src/memtrails/core/profiles.py +218 -0
- memtrails-0.4.1/src/memtrails/core/qdrant_binary.py +359 -0
- memtrails-0.4.1/src/memtrails/core/qdrant_client.py +421 -0
- memtrails-0.4.1/src/memtrails/core/qdrant_process.py +457 -0
- memtrails-0.4.1/src/memtrails/core/rebuild.py +201 -0
- memtrails-0.4.1/src/memtrails/core/recall.py +352 -0
- memtrails-0.4.1/src/memtrails/core/recall_log.py +142 -0
- memtrails-0.4.1/src/memtrails/core/redact.py +176 -0
- memtrails-0.4.1/src/memtrails/core/reranker.py +116 -0
- memtrails-0.4.1/src/memtrails/core/room.py +93 -0
- memtrails-0.4.1/src/memtrails/core/segment.py +57 -0
- memtrails-0.4.1/src/memtrails/core/sleep.py +267 -0
- memtrails-0.4.1/src/memtrails/core/store.py +424 -0
- memtrails-0.4.1/src/memtrails/core/text.py +162 -0
- memtrails-0.4.1/src/memtrails/core/words.py +187 -0
- memtrails-0.4.1/src/memtrails/core/writer.py +291 -0
- memtrails-0.4.1/src/memtrails/importers/__init__.py +60 -0
- memtrails-0.4.1/src/memtrails/importers/files.py +333 -0
- memtrails-0.4.1/src/memtrails/importers/git.py +193 -0
- memtrails-0.4.1/src/memtrails/importers/jsonl.py +94 -0
- memtrails-0.4.1/src/memtrails/importers/mbox.py +494 -0
- memtrails-0.4.1/src/memtrails/importers/slack.py +236 -0
- memtrails-0.4.1/src/memtrails/importers/text.py +651 -0
- memtrails-0.4.1/src/memtrails/llm.py +192 -0
- memtrails-0.4.1/src/memtrails/memory.py +794 -0
- memtrails-0.4.1/src/memtrails/py.typed +0 -0
- memtrails-0.4.1/src/memtrails/records.py +269 -0
- memtrails-0.4.1/src/memtrails/tools/__init__.py +1 -0
- memtrails-0.4.1/src/memtrails/tools/agent_tools.py +409 -0
memtrails-0.4.1/LICENSE
ADDED
|
@@ -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.
|
memtrails-0.4.1/PKG-INFO
ADDED
|
@@ -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
|