emem-langmem 1.2.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.
- emem_langmem-1.2.1/.gitignore +91 -0
- emem_langmem-1.2.1/PKG-INFO +142 -0
- emem_langmem-1.2.1/README.md +109 -0
- emem_langmem-1.2.1/pyproject.toml +62 -0
- emem_langmem-1.2.1/src/emem_langmem/__init__.py +44 -0
- emem_langmem-1.2.1/src/emem_langmem/signing.py +231 -0
- emem_langmem-1.2.1/src/emem_langmem/store.py +400 -0
- emem_langmem-1.2.1/tests/test_signing.py +271 -0
- emem_langmem-1.2.1/tests/test_store.py +301 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Rust build artefacts
|
|
2
|
+
/target/
|
|
3
|
+
**/target/
|
|
4
|
+
|
|
5
|
+
# Local persisted data + secrets
|
|
6
|
+
/var/
|
|
7
|
+
**/var/emem/
|
|
8
|
+
**/var/demos/
|
|
9
|
+
*.secret.b32
|
|
10
|
+
identity.secret.b32
|
|
11
|
+
.cloudflared/
|
|
12
|
+
acme.cache/
|
|
13
|
+
tls/
|
|
14
|
+
# Runtime cruft — when EMEM_DATA=:memory: is misinterpreted as a path
|
|
15
|
+
# string, a literal :memory: directory is created. Untracked but
|
|
16
|
+
# silenced from `git status` so it doesn't read as repo state.
|
|
17
|
+
/:memory:/
|
|
18
|
+
**/:memory:/
|
|
19
|
+
|
|
20
|
+
# Editor / OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# Node / Vite — the old TS/Vite SPA at repo root was retired in 0.0.6;
|
|
28
|
+
# the live https://emem.dev is served by the Rust `emem-server` binary.
|
|
29
|
+
# These patterns prevent the scaffolding from sneaking back in at root.
|
|
30
|
+
# sdks/emem-ts/ has its own legitimate package.json / tsconfig.json /
|
|
31
|
+
# src/ — those are NOT matched here because patterns are anchored to /.
|
|
32
|
+
#
|
|
33
|
+
# Anchoring only holds for git. hatchling re-anchors a root-anchored
|
|
34
|
+
# pattern to whatever directory it is building, so `/src/` here also hit
|
|
35
|
+
# `sdks/emem-py/src` and `sdks/emem-langmem/src`, and both wheels built
|
|
36
|
+
# clean with zero modules in them. ememdev 1.0.0 shipped to PyPI that
|
|
37
|
+
# way: metadata only, `import emem` raised ModuleNotFoundError. Keep
|
|
38
|
+
# root-anchored patterns off any directory name an SDK also uses.
|
|
39
|
+
node_modules/
|
|
40
|
+
dist/
|
|
41
|
+
dist-server/
|
|
42
|
+
.parcel-cache/
|
|
43
|
+
/index.html
|
|
44
|
+
/package-lock.json
|
|
45
|
+
/vite.config.ts
|
|
46
|
+
/tsconfig.server.json
|
|
47
|
+
/server/
|
|
48
|
+
/tests/*.test.ts
|
|
49
|
+
|
|
50
|
+
# Artifacts (screenshots, generated images) — keep folder, ignore contents
|
|
51
|
+
|
|
52
|
+
# Python ad-hoc tools
|
|
53
|
+
__pycache__/
|
|
54
|
+
*.pyc
|
|
55
|
+
.venv/
|
|
56
|
+
|
|
57
|
+
# Local Claude / IDE state
|
|
58
|
+
.claude/
|
|
59
|
+
CLAUDE.md
|
|
60
|
+
registry_claude.md
|
|
61
|
+
|
|
62
|
+
# Env files
|
|
63
|
+
.env
|
|
64
|
+
.env.*
|
|
65
|
+
!.env.example
|
|
66
|
+
|
|
67
|
+
# Logs + coverage
|
|
68
|
+
*.log
|
|
69
|
+
npm-debug.log*
|
|
70
|
+
yarn-debug.log*
|
|
71
|
+
yarn-error.log*
|
|
72
|
+
coverage/
|
|
73
|
+
.nyc_output/
|
|
74
|
+
notes/
|
|
75
|
+
|
|
76
|
+
# fastembed-rs ONNX model cache (HF hub mirror, ~400 MB)
|
|
77
|
+
.fastembed_cache/
|
|
78
|
+
scripts/eval/results/q*.json
|
|
79
|
+
|
|
80
|
+
# mdbook output for the /docs/ site. The book is built at deploy/CI time
|
|
81
|
+
# (see scripts/redeploy.sh and .github/workflows/ci.yml) and baked into
|
|
82
|
+
# the binary via `include_dir!("docs/book")`. Committing it would churn the
|
|
83
|
+
# repo every docs edit (hashed asset filenames) and add ~5 MB; build it
|
|
84
|
+
# fresh on each CI run instead.
|
|
85
|
+
docs/book/
|
|
86
|
+
|
|
87
|
+
# 3d-worlds exporter output (samples live under examples/3d-worlds/scenes/)
|
|
88
|
+
examples/3d-worlds/out/
|
|
89
|
+
|
|
90
|
+
# Agent scratch notes belong in .well-known/agent-notes/, never the repo root.
|
|
91
|
+
/AGENT_*.md
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emem-langmem
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: LangChain BaseStore backed by emem.dev — drop-in agent memory for LangGraph
|
|
5
|
+
Project-URL: Homepage, https://emem.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/Vortx-AI/emem
|
|
7
|
+
Project-URL: Documentation, https://emem.dev/docs/sdks/langmem.html
|
|
8
|
+
Project-URL: Issues, https://github.com/Vortx-AI/emem/issues
|
|
9
|
+
Author-email: Vortx AI Private Limited <avijeet@vortx.ai>
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
Keywords: agent-memory,content-addressed,ed25519,langchain,langgraph,langmem,mcp,verifiable
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: blake3>=0.3
|
|
25
|
+
Requires-Dist: cryptography>=41
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: langchain-core<1.0,>=0.3
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
31
|
+
Requires-Dist: respx>=0.21; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# emem-langmem
|
|
35
|
+
|
|
36
|
+
LangChain `BaseStore` backed by [emem.dev](https://emem.dev) — drop-in
|
|
37
|
+
agent memory for LangGraph with ed25519 receipts and content-addressed
|
|
38
|
+
recall.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install emem-langmem
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from emem_langmem import EmemStore
|
|
50
|
+
from langgraph.graph import StateGraph
|
|
51
|
+
|
|
52
|
+
store = EmemStore(base_url="https://emem.dev", signing_key=SEED)
|
|
53
|
+
|
|
54
|
+
graph = StateGraph(...).compile(store=store)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`SEED` is a raw 32-byte ed25519 seed that you own and persist, for
|
|
58
|
+
example `os.urandom(32)` once, then kept wherever the agent's other
|
|
59
|
+
secrets live. It signs every write, and the responder requires that:
|
|
60
|
+
an unattested write is refused unless the operator opened the responder
|
|
61
|
+
with `EMEM_MEMORY_OPEN=1`. The key also decides where the store writes.
|
|
62
|
+
|
|
63
|
+
`EmemStore` implements the four `BaseStore` verbs by issuing MCP
|
|
64
|
+
`tools/call` requests to the emem responder against the six Anthropic
|
|
65
|
+
memory-tool verbs:
|
|
66
|
+
|
|
67
|
+
| BaseStore verb | emem MCP tool | Signed |
|
|
68
|
+
|----------------|-----------------|--------|
|
|
69
|
+
| `mget(keys)` | `memory_view` | read, no signature |
|
|
70
|
+
| `mset(pairs)` | `memory_create` | yes |
|
|
71
|
+
| `mdelete(ks)` | `memory_delete` | yes |
|
|
72
|
+
| `yield_keys` | `memory_view` (directory walk) | read, no signature |
|
|
73
|
+
|
|
74
|
+
## Namespaces
|
|
75
|
+
|
|
76
|
+
With a signing key, the store roots itself at
|
|
77
|
+
`/memories/by_attester/<pubkey8>/`, where `<pubkey8>` is the first 8
|
|
78
|
+
characters of your base32 pubkey. Only that key can write there, so
|
|
79
|
+
agents sharing one responder cannot overwrite each other. LangChain
|
|
80
|
+
calls `mget(("ns","key"))` and the store reads
|
|
81
|
+
`/memories/by_attester/<pubkey8>/ns/key`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
store.root # '/memories/by_attester/aoqqpp7t'
|
|
85
|
+
store.signer.pubkey_b32 # the full base32 pubkey
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A key that starts with `/` is taken as a literal absolute path and is
|
|
89
|
+
left alone, which is how you reach outside your own root. Writing into
|
|
90
|
+
another key's namespace is refused with a 403. Without a signing key the
|
|
91
|
+
root stays `/memories` and writes go out unattested. See
|
|
92
|
+
[docs/memory.md](https://emem.dev/docs/memory.md) for the wire format.
|
|
93
|
+
|
|
94
|
+
`EmemSigner` is public if you drive the memory tools yourself and need
|
|
95
|
+
the same `attester` block:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from emem_langmem import EmemSigner
|
|
99
|
+
|
|
100
|
+
signer = EmemSigner(SEED)
|
|
101
|
+
signer.attester_block("create", f"{signer.namespace_root}/n.md", b"hi")
|
|
102
|
+
# {'pubkey_b32': '...', 'sig_b32': '...'}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Async variants `amget` / `amset` / `amdelete` / `ayield_keys` are
|
|
106
|
+
implemented over `httpx.AsyncClient`.
|
|
107
|
+
|
|
108
|
+
## Self-host
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
EmemStore(base_url="http://127.0.0.1:5051")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
or set `EMEM_BASE_URL` in the environment.
|
|
115
|
+
|
|
116
|
+
## Receipts
|
|
117
|
+
|
|
118
|
+
Every read and write returns an ed25519 receipt from the emem responder.
|
|
119
|
+
The receipt is currently dropped at the `BaseStore` interface boundary
|
|
120
|
+
(LangChain's contract has no receipt slot). To inspect receipts, call
|
|
121
|
+
the underlying MCP tool directly:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import json, httpx, uuid
|
|
125
|
+
r = httpx.post(
|
|
126
|
+
"https://emem.dev/mcp",
|
|
127
|
+
json={
|
|
128
|
+
"jsonrpc": "2.0",
|
|
129
|
+
"id": str(uuid.uuid4()),
|
|
130
|
+
"method": "tools/call",
|
|
131
|
+
"params": {
|
|
132
|
+
"name": "memory_view",
|
|
133
|
+
"arguments": {"path": "/memories/my/note.txt"},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
).json()
|
|
137
|
+
print(r["result"]["structuredContent"]["receipt"])
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
Apache-2.0.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# emem-langmem
|
|
2
|
+
|
|
3
|
+
LangChain `BaseStore` backed by [emem.dev](https://emem.dev) — drop-in
|
|
4
|
+
agent memory for LangGraph with ed25519 receipts and content-addressed
|
|
5
|
+
recall.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install emem-langmem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from emem_langmem import EmemStore
|
|
17
|
+
from langgraph.graph import StateGraph
|
|
18
|
+
|
|
19
|
+
store = EmemStore(base_url="https://emem.dev", signing_key=SEED)
|
|
20
|
+
|
|
21
|
+
graph = StateGraph(...).compile(store=store)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`SEED` is a raw 32-byte ed25519 seed that you own and persist, for
|
|
25
|
+
example `os.urandom(32)` once, then kept wherever the agent's other
|
|
26
|
+
secrets live. It signs every write, and the responder requires that:
|
|
27
|
+
an unattested write is refused unless the operator opened the responder
|
|
28
|
+
with `EMEM_MEMORY_OPEN=1`. The key also decides where the store writes.
|
|
29
|
+
|
|
30
|
+
`EmemStore` implements the four `BaseStore` verbs by issuing MCP
|
|
31
|
+
`tools/call` requests to the emem responder against the six Anthropic
|
|
32
|
+
memory-tool verbs:
|
|
33
|
+
|
|
34
|
+
| BaseStore verb | emem MCP tool | Signed |
|
|
35
|
+
|----------------|-----------------|--------|
|
|
36
|
+
| `mget(keys)` | `memory_view` | read, no signature |
|
|
37
|
+
| `mset(pairs)` | `memory_create` | yes |
|
|
38
|
+
| `mdelete(ks)` | `memory_delete` | yes |
|
|
39
|
+
| `yield_keys` | `memory_view` (directory walk) | read, no signature |
|
|
40
|
+
|
|
41
|
+
## Namespaces
|
|
42
|
+
|
|
43
|
+
With a signing key, the store roots itself at
|
|
44
|
+
`/memories/by_attester/<pubkey8>/`, where `<pubkey8>` is the first 8
|
|
45
|
+
characters of your base32 pubkey. Only that key can write there, so
|
|
46
|
+
agents sharing one responder cannot overwrite each other. LangChain
|
|
47
|
+
calls `mget(("ns","key"))` and the store reads
|
|
48
|
+
`/memories/by_attester/<pubkey8>/ns/key`.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
store.root # '/memories/by_attester/aoqqpp7t'
|
|
52
|
+
store.signer.pubkey_b32 # the full base32 pubkey
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A key that starts with `/` is taken as a literal absolute path and is
|
|
56
|
+
left alone, which is how you reach outside your own root. Writing into
|
|
57
|
+
another key's namespace is refused with a 403. Without a signing key the
|
|
58
|
+
root stays `/memories` and writes go out unattested. See
|
|
59
|
+
[docs/memory.md](https://emem.dev/docs/memory.md) for the wire format.
|
|
60
|
+
|
|
61
|
+
`EmemSigner` is public if you drive the memory tools yourself and need
|
|
62
|
+
the same `attester` block:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from emem_langmem import EmemSigner
|
|
66
|
+
|
|
67
|
+
signer = EmemSigner(SEED)
|
|
68
|
+
signer.attester_block("create", f"{signer.namespace_root}/n.md", b"hi")
|
|
69
|
+
# {'pubkey_b32': '...', 'sig_b32': '...'}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Async variants `amget` / `amset` / `amdelete` / `ayield_keys` are
|
|
73
|
+
implemented over `httpx.AsyncClient`.
|
|
74
|
+
|
|
75
|
+
## Self-host
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
EmemStore(base_url="http://127.0.0.1:5051")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
or set `EMEM_BASE_URL` in the environment.
|
|
82
|
+
|
|
83
|
+
## Receipts
|
|
84
|
+
|
|
85
|
+
Every read and write returns an ed25519 receipt from the emem responder.
|
|
86
|
+
The receipt is currently dropped at the `BaseStore` interface boundary
|
|
87
|
+
(LangChain's contract has no receipt slot). To inspect receipts, call
|
|
88
|
+
the underlying MCP tool directly:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import json, httpx, uuid
|
|
92
|
+
r = httpx.post(
|
|
93
|
+
"https://emem.dev/mcp",
|
|
94
|
+
json={
|
|
95
|
+
"jsonrpc": "2.0",
|
|
96
|
+
"id": str(uuid.uuid4()),
|
|
97
|
+
"method": "tools/call",
|
|
98
|
+
"params": {
|
|
99
|
+
"name": "memory_view",
|
|
100
|
+
"arguments": {"path": "/memories/my/note.txt"},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
).json()
|
|
104
|
+
print(r["result"]["structuredContent"]["receipt"])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Apache-2.0.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "emem-langmem"
|
|
7
|
+
version = "1.2.1"
|
|
8
|
+
description = "LangChain BaseStore backed by emem.dev — drop-in agent memory for LangGraph"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vortx AI Private Limited", email = "avijeet@vortx.ai" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"langchain",
|
|
17
|
+
"langgraph",
|
|
18
|
+
"langmem",
|
|
19
|
+
"mcp",
|
|
20
|
+
"agent-memory",
|
|
21
|
+
"verifiable",
|
|
22
|
+
"ed25519",
|
|
23
|
+
"content-addressed",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: Apache Software License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
32
|
+
"Programming Language :: Python :: 3.9",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Topic :: Software Development :: Libraries",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"langchain-core>=0.3,<1.0",
|
|
40
|
+
"httpx>=0.27",
|
|
41
|
+
# Attester signing on every memory write: blake3 builds the preimage,
|
|
42
|
+
# cryptography holds the ed25519 key. Both mirror what the responder
|
|
43
|
+
# verifies (crates/emem-primitives/src/memory_acl.rs).
|
|
44
|
+
"blake3>=0.3",
|
|
45
|
+
"cryptography>=41",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://emem.dev"
|
|
50
|
+
Repository = "https://github.com/Vortx-AI/emem"
|
|
51
|
+
Documentation = "https://emem.dev/docs/sdks/langmem.html"
|
|
52
|
+
Issues = "https://github.com/Vortx-AI/emem/issues"
|
|
53
|
+
|
|
54
|
+
[project.optional-dependencies]
|
|
55
|
+
test = [
|
|
56
|
+
"pytest>=8.0",
|
|
57
|
+
"pytest-asyncio>=0.23",
|
|
58
|
+
"respx>=0.21",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel]
|
|
62
|
+
packages = ["src/emem_langmem"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""LangChain BaseStore backed by emem.dev.
|
|
2
|
+
|
|
3
|
+
A LangGraph agent gets verifiable, ed25519-signed agent memory in one
|
|
4
|
+
line, plus the key it signs with:
|
|
5
|
+
|
|
6
|
+
from emem_langmem import EmemStore
|
|
7
|
+
store = EmemStore(base_url="https://emem.dev", signing_key=SEED)
|
|
8
|
+
graph = StateGraph(...).compile(store=store)
|
|
9
|
+
|
|
10
|
+
`SEED` is a raw 32-byte ed25519 seed the caller owns and persists. It
|
|
11
|
+
does two things. It signs every write, which the responder requires:
|
|
12
|
+
an unattested write is refused unless the operator opened the responder
|
|
13
|
+
with `EMEM_MEMORY_OPEN=1`. And it roots the store at
|
|
14
|
+
`/memories/by_attester/<pubkey8>/`, the namespace only that key can
|
|
15
|
+
write to, so agents sharing one responder cannot overwrite each other.
|
|
16
|
+
Lose the seed and you lose write access to that namespace, so keep it
|
|
17
|
+
where the agent's other secrets live.
|
|
18
|
+
|
|
19
|
+
`EmemStore` implements the four `BaseStore` verbs (`mget`, `mset`,
|
|
20
|
+
`mdelete`, `yield_keys`) by issuing MCP `tools/call` requests to the
|
|
21
|
+
emem responder against the six Anthropic memory-tool verbs (`memory_view`,
|
|
22
|
+
`memory_create`, `memory_str_replace`, `memory_insert`, `memory_delete`,
|
|
23
|
+
`memory_rename`). Every write is content-addressed and countersigned by
|
|
24
|
+
the responder; every read carries a receipt verifiable offline at the
|
|
25
|
+
responder's `/verify` page.
|
|
26
|
+
|
|
27
|
+
`EmemSigner` is public for callers who drive the memory tools directly
|
|
28
|
+
and need the same `attester` block.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
32
|
+
|
|
33
|
+
from emem_langmem.signing import EmemSigner
|
|
34
|
+
from emem_langmem.store import EmemAttestationError, EmemStore, EmemStoreError
|
|
35
|
+
|
|
36
|
+
__all__ = ["EmemStore", "EmemSigner", "EmemStoreError", "EmemAttestationError"]
|
|
37
|
+
|
|
38
|
+
# Single source of truth: the installed distribution's version, which comes
|
|
39
|
+
# from pyproject.toml. Keeps __version__ and the store's User-Agent from
|
|
40
|
+
# drifting out of sync with what actually ships to PyPI.
|
|
41
|
+
try:
|
|
42
|
+
__version__ = _pkg_version("emem-langmem")
|
|
43
|
+
except PackageNotFoundError: # running from a source checkout, not installed
|
|
44
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""ed25519 attester signing for emem memory writes.
|
|
2
|
+
|
|
3
|
+
The emem responder gates every memory write verb (`create`,
|
|
4
|
+
`str_replace`, `insert`, `delete`, `rename`) behind an `attester`
|
|
5
|
+
block. The block binds the write to an ed25519 key:
|
|
6
|
+
|
|
7
|
+
sig = ed25519(blake3(b"emem.memory_write|" + verb + b"|"
|
|
8
|
+
+ path + b"|" + body_hash))
|
|
9
|
+
|
|
10
|
+
and travels on the wire as::
|
|
11
|
+
|
|
12
|
+
"attester": {"pubkey_b32": "<52 chars>", "sig_b32": "<103 chars>"}
|
|
13
|
+
|
|
14
|
+
Both fields are base32-nopad, lowercase. The preimage shape is fixed so
|
|
15
|
+
a client signs offline with no server round-trip. This module mirrors
|
|
16
|
+
`crates/emem-primitives/src/memory_acl.rs`; the function names match the
|
|
17
|
+
Rust (`attester_preimage`, `body_hash`, `rename_body_hash`,
|
|
18
|
+
`pubkey_short_from_b32`) so the two sides can be read side by side.
|
|
19
|
+
|
|
20
|
+
`body_hash` is blake3 over the bytes the responder itself hashes, which
|
|
21
|
+
is not always the bytes a caller thinks it is sending:
|
|
22
|
+
|
|
23
|
+
| verb | body the responder hashes |
|
|
24
|
+
|---------------|--------------------------------------------------|
|
|
25
|
+
| `create` | UTF-8 bytes of the `file_text` string sent |
|
|
26
|
+
| `str_replace` | UTF-8 bytes of the whole file *after* the edit |
|
|
27
|
+
| `insert` | UTF-8 bytes of the whole file *after* the insert |
|
|
28
|
+
| `delete` | empty (blake3 of b"") |
|
|
29
|
+
| `rename` | UTF-8 bytes of the `old_path` string |
|
|
30
|
+
|
|
31
|
+
So `str_replace` and `insert` cannot be signed without knowing the
|
|
32
|
+
file's current content: read it, apply the edit locally, and sign the
|
|
33
|
+
result.
|
|
34
|
+
|
|
35
|
+
`rename` is the one verb whose preimage `path` is not the path the
|
|
36
|
+
caller names first. The destination rides `path` and the source rides
|
|
37
|
+
the body, so one signature binds both ends of the move. Use
|
|
38
|
+
`EmemSigner.rename_attester_block`, which puts them in the right slots.
|
|
39
|
+
The responder cannot check the signature against the source (that would
|
|
40
|
+
be one signature against two messages), so it answers the source as a
|
|
41
|
+
namespace question instead: the key must own both ends.
|
|
42
|
+
|
|
43
|
+
Writes under `/memories/by_attester/<pubkey8>/...` are restricted to the
|
|
44
|
+
holder of the matching private key, where `<pubkey8>` is the first 8
|
|
45
|
+
characters of the lowercased base32 pubkey. A signed write aimed at
|
|
46
|
+
another key's namespace is refused with a 403.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import base64
|
|
52
|
+
from typing import Union
|
|
53
|
+
|
|
54
|
+
from blake3 import blake3
|
|
55
|
+
from cryptography.hazmat.primitives import serialization
|
|
56
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
57
|
+
|
|
58
|
+
# Mirrors emem_primitives::BY_ATTESTER_PREFIX / PUBKEY_SHORT_LEN.
|
|
59
|
+
BY_ATTESTER_PREFIX = "/memories/by_attester/"
|
|
60
|
+
PUBKEY_SHORT_LEN = 8
|
|
61
|
+
|
|
62
|
+
#: The five memory-tool write verbs the responder will verify a
|
|
63
|
+
#: signature for. `memory_view` is a read and carries no attester.
|
|
64
|
+
WRITE_VERBS = ("create", "str_replace", "insert", "delete", "rename")
|
|
65
|
+
|
|
66
|
+
_PREIMAGE_TAG = b"emem.memory_write|"
|
|
67
|
+
_SEP = b"|"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def b32_nopad_lc(raw: bytes) -> str:
|
|
71
|
+
"""Encode bytes the way the responder expects: base32, no padding,
|
|
72
|
+
lowercase."""
|
|
73
|
+
return base64.b32encode(raw).decode("ascii").rstrip("=").lower()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def body_hash(body: bytes = b"") -> bytes:
|
|
77
|
+
"""blake3 of the write body, as 32 raw bytes. `delete` carries no
|
|
78
|
+
body and hashes the empty string, which is the default. `rename`
|
|
79
|
+
carries its source path: see `rename_body_hash`."""
|
|
80
|
+
return blake3(body).digest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rename_body_hash(old_path: str) -> bytes:
|
|
84
|
+
"""The body-hash for a `rename`: blake3 over the *source* path's
|
|
85
|
+
UTF-8 bytes.
|
|
86
|
+
|
|
87
|
+
The destination already rides the preimage's `path` component, so
|
|
88
|
+
hashing the source here makes one signature bind both ends of the
|
|
89
|
+
move.
|
|
90
|
+
"""
|
|
91
|
+
return body_hash(old_path.encode("utf-8"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def attester_preimage(verb: str, path: str, body_digest: bytes) -> bytes:
|
|
95
|
+
"""Build the 32-byte digest that gets signed.
|
|
96
|
+
|
|
97
|
+
`body_digest` is the 32 raw bytes from `body_hash`, not the body.
|
|
98
|
+
"""
|
|
99
|
+
if verb not in WRITE_VERBS:
|
|
100
|
+
raise ValueError(f"unknown write verb {verb!r}; expected one of {', '.join(WRITE_VERBS)}")
|
|
101
|
+
if len(body_digest) != 32:
|
|
102
|
+
raise ValueError(f"body_digest must be 32 bytes, got {len(body_digest)}")
|
|
103
|
+
h = blake3()
|
|
104
|
+
h.update(_PREIMAGE_TAG)
|
|
105
|
+
h.update(verb.encode("utf-8"))
|
|
106
|
+
h.update(_SEP)
|
|
107
|
+
h.update(path.encode("utf-8"))
|
|
108
|
+
h.update(_SEP)
|
|
109
|
+
h.update(body_digest)
|
|
110
|
+
return h.digest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pubkey_short_from_b32(pubkey_b32: str) -> str:
|
|
114
|
+
"""First 8 chars of the lowercased base32 pubkey: the segment the
|
|
115
|
+
responder matches against `/memories/by_attester/<pubkey8>/...`."""
|
|
116
|
+
return pubkey_b32.lower()[:PUBKEY_SHORT_LEN]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EmemSigner:
|
|
120
|
+
"""Signs emem memory writes with an ed25519 key.
|
|
121
|
+
|
|
122
|
+
The key comes from the caller. Nothing here generates one, and there
|
|
123
|
+
is no fallback key: an `EmemSigner` without a real private key would
|
|
124
|
+
write into a namespace the caller cannot reclaim.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
private_key:
|
|
129
|
+
The raw 32-byte ed25519 seed (what `cryptography` calls the
|
|
130
|
+
private bytes, and what the Rust side calls
|
|
131
|
+
`SigningKey::from_bytes`). Use `from_hex` for a 64-char hex seed.
|
|
132
|
+
|
|
133
|
+
Example
|
|
134
|
+
-------
|
|
135
|
+
>>> import os
|
|
136
|
+
>>> signer = EmemSigner(os.urandom(32)) # persist this seed
|
|
137
|
+
>>> block = signer.attester_block("create", f"{signer.namespace_root}/n.md", b"hi")
|
|
138
|
+
>>> sorted(block)
|
|
139
|
+
['pubkey_b32', 'sig_b32']
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, private_key: bytes) -> None:
|
|
143
|
+
if isinstance(private_key, str):
|
|
144
|
+
raise TypeError(
|
|
145
|
+
"private_key must be the raw 32-byte seed, not a string; "
|
|
146
|
+
"use EmemSigner.from_hex() for a hex seed"
|
|
147
|
+
)
|
|
148
|
+
if not isinstance(private_key, (bytes, bytearray)):
|
|
149
|
+
raise TypeError(f"private_key must be bytes, got {type(private_key).__name__}")
|
|
150
|
+
if len(private_key) != 32:
|
|
151
|
+
raise ValueError(f"ed25519 private key must be 32 bytes, got {len(private_key)}")
|
|
152
|
+
self._key = Ed25519PrivateKey.from_private_bytes(bytes(private_key))
|
|
153
|
+
self.pubkey: bytes = self._key.public_key().public_bytes(
|
|
154
|
+
encoding=serialization.Encoding.Raw,
|
|
155
|
+
format=serialization.PublicFormat.Raw,
|
|
156
|
+
)
|
|
157
|
+
self.pubkey_b32: str = b32_nopad_lc(self.pubkey)
|
|
158
|
+
self.pubkey_short: str = pubkey_short_from_b32(self.pubkey_b32)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_hex(cls, seed_hex: str) -> "EmemSigner":
|
|
162
|
+
"""Build a signer from a 64-character hex ed25519 seed."""
|
|
163
|
+
try:
|
|
164
|
+
raw = bytes.fromhex(seed_hex.strip())
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
raise ValueError(f"seed_hex is not valid hex: {e}") from e
|
|
167
|
+
return cls(raw)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def namespace_root(self) -> str:
|
|
171
|
+
"""This key's own memory namespace: `/memories/by_attester/<pubkey8>`."""
|
|
172
|
+
return f"{BY_ATTESTER_PREFIX}{self.pubkey_short}"
|
|
173
|
+
|
|
174
|
+
def sign_preimage(self, preimage: bytes) -> bytes:
|
|
175
|
+
"""Raw ed25519 signature (64 bytes) over an already-built preimage."""
|
|
176
|
+
return self._key.sign(preimage)
|
|
177
|
+
|
|
178
|
+
def attester_block(self, verb: str, path: str, body: bytes = b"") -> dict:
|
|
179
|
+
"""Build the wire `attester` block for one write.
|
|
180
|
+
|
|
181
|
+
`body` is the byte string the responder will hash for this verb
|
|
182
|
+
(see the table in this module's docstring). It defaults to empty,
|
|
183
|
+
which is right for `delete`. For `rename`, prefer
|
|
184
|
+
`rename_attester_block`.
|
|
185
|
+
"""
|
|
186
|
+
digest = attester_preimage(verb, path, body_hash(body))
|
|
187
|
+
return {
|
|
188
|
+
"pubkey_b32": self.pubkey_b32,
|
|
189
|
+
"sig_b32": b32_nopad_lc(self.sign_preimage(digest)),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def rename_attester_block(self, old_path: str, new_path: str) -> dict:
|
|
193
|
+
"""Build the wire `attester` block for a `rename`.
|
|
194
|
+
|
|
195
|
+
The preimage's `path` is the destination and its body is the
|
|
196
|
+
source, which is easy to invert by hand. Pass the paths in the
|
|
197
|
+
same order `memory_rename` takes them and this puts them in the
|
|
198
|
+
right slots.
|
|
199
|
+
|
|
200
|
+
The responder additionally requires this key to own both ends of
|
|
201
|
+
the move; it refuses a source in another key's namespace with a
|
|
202
|
+
403. Signing here does not check that.
|
|
203
|
+
"""
|
|
204
|
+
return {
|
|
205
|
+
"pubkey_b32": self.pubkey_b32,
|
|
206
|
+
"sig_b32": b32_nopad_lc(
|
|
207
|
+
self.sign_preimage(
|
|
208
|
+
attester_preimage("rename", new_path, rename_body_hash(old_path))
|
|
209
|
+
)
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def owns(self, path: str) -> bool:
|
|
214
|
+
"""Whether `path` sits in this key's attester namespace. Paths
|
|
215
|
+
outside `/memories/by_attester/` are not owned by anyone, so they
|
|
216
|
+
return False."""
|
|
217
|
+
rest = path[len(BY_ATTESTER_PREFIX) :] if path.startswith(BY_ATTESTER_PREFIX) else None
|
|
218
|
+
if rest is None:
|
|
219
|
+
return False
|
|
220
|
+
return rest.split("/", 1)[0] == self.pubkey_short
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
# Never render private bytes.
|
|
224
|
+
return f"EmemSigner(pubkey_short={self.pubkey_short!r})"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def coerce_signer(signing_key: Union["EmemSigner", bytes, bytearray, None]) -> "EmemSigner":
|
|
228
|
+
"""Accept either a ready `EmemSigner` or a raw 32-byte seed."""
|
|
229
|
+
if isinstance(signing_key, EmemSigner):
|
|
230
|
+
return signing_key
|
|
231
|
+
return EmemSigner(signing_key) # type: ignore[arg-type]
|