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.
@@ -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]