totalreclaw 0.1.0__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.
- totalreclaw-0.1.0/PKG-INFO +135 -0
- totalreclaw-0.1.0/README.md +111 -0
- totalreclaw-0.1.0/pyproject.toml +32 -0
- totalreclaw-0.1.0/setup.cfg +4 -0
- totalreclaw-0.1.0/src/totalreclaw/__init__.py +5 -0
- totalreclaw-0.1.0/src/totalreclaw/client.py +242 -0
- totalreclaw-0.1.0/src/totalreclaw/crypto.py +122 -0
- totalreclaw-0.1.0/src/totalreclaw/embedding.py +119 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/__init__.py +75 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/debrief.py +172 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/extractor.py +212 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/hooks.py +307 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/llm_client.py +139 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/schemas.py +97 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/state.py +204 -0
- totalreclaw-0.1.0/src/totalreclaw/hermes/tools.py +139 -0
- totalreclaw-0.1.0/src/totalreclaw/lsh.py +65 -0
- totalreclaw-0.1.0/src/totalreclaw/operations.py +405 -0
- totalreclaw-0.1.0/src/totalreclaw/protobuf.py +150 -0
- totalreclaw-0.1.0/src/totalreclaw/relay.py +167 -0
- totalreclaw-0.1.0/src/totalreclaw/reranker.py +482 -0
- totalreclaw-0.1.0/src/totalreclaw/userop.py +491 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/PKG-INFO +135 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/SOURCES.txt +40 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/dependency_links.txt +1 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/entry_points.txt +2 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/requires.txt +17 -0
- totalreclaw-0.1.0/src/totalreclaw.egg-info/top_level.txt +1 -0
- totalreclaw-0.1.0/tests/test_client.py +94 -0
- totalreclaw-0.1.0/tests/test_cross_client.py +80 -0
- totalreclaw-0.1.0/tests/test_crypto.py +127 -0
- totalreclaw-0.1.0/tests/test_debrief.py +129 -0
- totalreclaw-0.1.0/tests/test_embedding.py +55 -0
- totalreclaw-0.1.0/tests/test_extractor.py +515 -0
- totalreclaw-0.1.0/tests/test_hermes_plugin.py +497 -0
- totalreclaw-0.1.0/tests/test_lsh.py +70 -0
- totalreclaw-0.1.0/tests/test_operations.py +225 -0
- totalreclaw-0.1.0/tests/test_protobuf.py +477 -0
- totalreclaw-0.1.0/tests/test_relay.py +62 -0
- totalreclaw-0.1.0/tests/test_reranker.py +558 -0
- totalreclaw-0.1.0/tests/test_staging_integration.py +87 -0
- totalreclaw-0.1.0/tests/test_userop.py +202 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: totalreclaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: End-to-end encrypted memory for AI agents — Python client
|
|
5
|
+
Author: TotalReclaw Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: totalreclaw-core>=0.1.0
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: onnxruntime>=1.17
|
|
12
|
+
Requires-Dist: numpy>=1.26
|
|
13
|
+
Requires-Dist: PyStemmer>=2.2
|
|
14
|
+
Requires-Dist: transformers>=4.40
|
|
15
|
+
Requires-Dist: huggingface-hub>=0.23
|
|
16
|
+
Requires-Dist: eth-account>=0.13
|
|
17
|
+
Provides-Extra: hermes
|
|
18
|
+
Requires-Dist: hermes-agent>=0.5.0; extra == "hermes"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-xdist>=3.5; extra == "dev"
|
|
23
|
+
Requires-Dist: mnemonic>=0.21; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# totalreclaw
|
|
26
|
+
|
|
27
|
+
End-to-end encrypted memory for AI agents -- the "password manager for AI memory."
|
|
28
|
+
|
|
29
|
+
Store, search, and recall memories across any AI agent with zero-knowledge encryption. Your data is encrypted on-device before it leaves -- the server never sees plaintext.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **End-to-end encrypted** -- AES-256-GCM encryption, HKDF key derivation from BIP-39 mnemonic
|
|
34
|
+
- **Portable** -- Same recovery phrase works across Hermes, OpenClaw, Claude Desktop, IronClaw
|
|
35
|
+
- **Local embeddings** -- Qwen3-Embedding-0.6B runs on-device (no API calls)
|
|
36
|
+
- **Hybrid search** -- BM25 + cosine similarity + RRF reranking
|
|
37
|
+
- **LSH bucketing** -- Locality-sensitive hashing for encrypted search
|
|
38
|
+
- **On-chain storage** -- Managed service stores on Gnosis/Base Sepolia via ERC-4337
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install totalreclaw
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from totalreclaw import TotalReclaw
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
client = TotalReclaw(mnemonic="your twelve word recovery phrase here")
|
|
52
|
+
|
|
53
|
+
# Store a memory
|
|
54
|
+
fact_id = await client.remember("Pedro prefers dark mode for all editors")
|
|
55
|
+
|
|
56
|
+
# Search memories
|
|
57
|
+
results = await client.recall("What does Pedro prefer?")
|
|
58
|
+
for r in results:
|
|
59
|
+
print(f" [{r.rrf_score:.3f}] {r.text}")
|
|
60
|
+
|
|
61
|
+
# Delete a memory
|
|
62
|
+
await client.forget(fact_id)
|
|
63
|
+
|
|
64
|
+
# Export all memories
|
|
65
|
+
facts = await client.export_all()
|
|
66
|
+
|
|
67
|
+
# Check billing
|
|
68
|
+
status = await client.status()
|
|
69
|
+
print(f"Tier: {status.tier}, Used: {status.free_writes_used}/{status.free_writes_limit}")
|
|
70
|
+
|
|
71
|
+
await client.close()
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## With Embeddings (Recommended)
|
|
77
|
+
|
|
78
|
+
For semantic search, install with embedding support:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install totalreclaw
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from totalreclaw import TotalReclaw
|
|
86
|
+
from totalreclaw.embedding import get_embedding
|
|
87
|
+
|
|
88
|
+
client = TotalReclaw(mnemonic="...")
|
|
89
|
+
|
|
90
|
+
# Store with embedding for semantic search
|
|
91
|
+
text = "Pedro prefers dark mode"
|
|
92
|
+
embedding = get_embedding(text)
|
|
93
|
+
await client.remember(text, embedding=embedding)
|
|
94
|
+
|
|
95
|
+
# Search with embedding
|
|
96
|
+
query = "What are Pedro's UI preferences?"
|
|
97
|
+
query_emb = get_embedding(query)
|
|
98
|
+
results = await client.recall(query, query_embedding=query_emb)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The embedding model (~600 MB) downloads automatically on first use.
|
|
102
|
+
|
|
103
|
+
## Hermes Agent Plugin
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install totalreclaw[hermes]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The plugin registers automatically with Hermes Agent v0.5.0+. See the [Hermes setup guide](https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/hermes-setup.md).
|
|
110
|
+
|
|
111
|
+
## Architecture
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Plaintext → AES-256-GCM encrypt → Blind indices (SHA-256) → LSH buckets → On-chain via relay
|
|
115
|
+
↓
|
|
116
|
+
Query → Blind trapdoors → GraphQL search → Decrypt candidates → BM25+Cosine+RRF rerank → Top 8
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
All encryption happens client-side. The relay server and on-chain storage never see plaintext.
|
|
120
|
+
|
|
121
|
+
## Cross-Language Parity
|
|
122
|
+
|
|
123
|
+
This Python client produces byte-for-byte identical outputs to the TypeScript implementation (`@totalreclaw/mcp-server`):
|
|
124
|
+
|
|
125
|
+
- Key derivation (HKDF-SHA256)
|
|
126
|
+
- AES-256-GCM wire format (iv || tag || ciphertext)
|
|
127
|
+
- Blind indices (SHA-256 + Porter stemming)
|
|
128
|
+
- Content fingerprints (HMAC-SHA256)
|
|
129
|
+
- LSH bucket hashes (32-bit x 20 tables)
|
|
130
|
+
|
|
131
|
+
Memories stored by the Python client can be recalled by the MCP server, and vice versa.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# totalreclaw
|
|
2
|
+
|
|
3
|
+
End-to-end encrypted memory for AI agents -- the "password manager for AI memory."
|
|
4
|
+
|
|
5
|
+
Store, search, and recall memories across any AI agent with zero-knowledge encryption. Your data is encrypted on-device before it leaves -- the server never sees plaintext.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **End-to-end encrypted** -- AES-256-GCM encryption, HKDF key derivation from BIP-39 mnemonic
|
|
10
|
+
- **Portable** -- Same recovery phrase works across Hermes, OpenClaw, Claude Desktop, IronClaw
|
|
11
|
+
- **Local embeddings** -- Qwen3-Embedding-0.6B runs on-device (no API calls)
|
|
12
|
+
- **Hybrid search** -- BM25 + cosine similarity + RRF reranking
|
|
13
|
+
- **LSH bucketing** -- Locality-sensitive hashing for encrypted search
|
|
14
|
+
- **On-chain storage** -- Managed service stores on Gnosis/Base Sepolia via ERC-4337
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install totalreclaw
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
from totalreclaw import TotalReclaw
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
client = TotalReclaw(mnemonic="your twelve word recovery phrase here")
|
|
28
|
+
|
|
29
|
+
# Store a memory
|
|
30
|
+
fact_id = await client.remember("Pedro prefers dark mode for all editors")
|
|
31
|
+
|
|
32
|
+
# Search memories
|
|
33
|
+
results = await client.recall("What does Pedro prefer?")
|
|
34
|
+
for r in results:
|
|
35
|
+
print(f" [{r.rrf_score:.3f}] {r.text}")
|
|
36
|
+
|
|
37
|
+
# Delete a memory
|
|
38
|
+
await client.forget(fact_id)
|
|
39
|
+
|
|
40
|
+
# Export all memories
|
|
41
|
+
facts = await client.export_all()
|
|
42
|
+
|
|
43
|
+
# Check billing
|
|
44
|
+
status = await client.status()
|
|
45
|
+
print(f"Tier: {status.tier}, Used: {status.free_writes_used}/{status.free_writes_limit}")
|
|
46
|
+
|
|
47
|
+
await client.close()
|
|
48
|
+
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## With Embeddings (Recommended)
|
|
53
|
+
|
|
54
|
+
For semantic search, install with embedding support:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install totalreclaw
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from totalreclaw import TotalReclaw
|
|
62
|
+
from totalreclaw.embedding import get_embedding
|
|
63
|
+
|
|
64
|
+
client = TotalReclaw(mnemonic="...")
|
|
65
|
+
|
|
66
|
+
# Store with embedding for semantic search
|
|
67
|
+
text = "Pedro prefers dark mode"
|
|
68
|
+
embedding = get_embedding(text)
|
|
69
|
+
await client.remember(text, embedding=embedding)
|
|
70
|
+
|
|
71
|
+
# Search with embedding
|
|
72
|
+
query = "What are Pedro's UI preferences?"
|
|
73
|
+
query_emb = get_embedding(query)
|
|
74
|
+
results = await client.recall(query, query_embedding=query_emb)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The embedding model (~600 MB) downloads automatically on first use.
|
|
78
|
+
|
|
79
|
+
## Hermes Agent Plugin
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install totalreclaw[hermes]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The plugin registers automatically with Hermes Agent v0.5.0+. See the [Hermes setup guide](https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/hermes-setup.md).
|
|
86
|
+
|
|
87
|
+
## Architecture
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
Plaintext → AES-256-GCM encrypt → Blind indices (SHA-256) → LSH buckets → On-chain via relay
|
|
91
|
+
↓
|
|
92
|
+
Query → Blind trapdoors → GraphQL search → Decrypt candidates → BM25+Cosine+RRF rerank → Top 8
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
All encryption happens client-side. The relay server and on-chain storage never see plaintext.
|
|
96
|
+
|
|
97
|
+
## Cross-Language Parity
|
|
98
|
+
|
|
99
|
+
This Python client produces byte-for-byte identical outputs to the TypeScript implementation (`@totalreclaw/mcp-server`):
|
|
100
|
+
|
|
101
|
+
- Key derivation (HKDF-SHA256)
|
|
102
|
+
- AES-256-GCM wire format (iv || tag || ciphertext)
|
|
103
|
+
- Blind indices (SHA-256 + Porter stemming)
|
|
104
|
+
- Content fingerprints (HMAC-SHA256)
|
|
105
|
+
- LSH bucket hashes (32-bit x 20 tables)
|
|
106
|
+
|
|
107
|
+
Memories stored by the Python client can be recalled by the MCP server, and vice versa.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "totalreclaw"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "End-to-end encrypted memory for AI agents — Python client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "TotalReclaw Team" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"totalreclaw-core>=0.1.0",
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
"onnxruntime>=1.17",
|
|
17
|
+
"numpy>=1.26",
|
|
18
|
+
"PyStemmer>=2.2",
|
|
19
|
+
"transformers>=4.40",
|
|
20
|
+
"huggingface-hub>=0.23",
|
|
21
|
+
"eth-account>=0.13",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
hermes = ["hermes-agent>=0.5.0"]
|
|
26
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-xdist>=3.5", "mnemonic>=0.21"]
|
|
27
|
+
|
|
28
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
29
|
+
totalreclaw = "totalreclaw.hermes"
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TotalReclaw Client -- High-level API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from totalreclaw import TotalReclaw
|
|
6
|
+
|
|
7
|
+
client = TotalReclaw(mnemonic="abandon abandon ...")
|
|
8
|
+
await client.remember("Pedro prefers dark mode")
|
|
9
|
+
results = await client.recall("What does Pedro prefer?")
|
|
10
|
+
await client.forget(results[0].id)
|
|
11
|
+
facts = await client.export_all()
|
|
12
|
+
status = await client.status()
|
|
13
|
+
await client.close()
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .crypto import (
|
|
19
|
+
derive_keys_from_mnemonic,
|
|
20
|
+
derive_lsh_seed,
|
|
21
|
+
compute_auth_key_hash,
|
|
22
|
+
DerivedKeys,
|
|
23
|
+
)
|
|
24
|
+
from .lsh import LSHHasher
|
|
25
|
+
from .relay import RelayClient, BillingStatus, DEFAULT_RELAY_URL
|
|
26
|
+
from .reranker import RerankerResult
|
|
27
|
+
from .operations import store_fact, search_facts, forget_fact, export_facts
|
|
28
|
+
|
|
29
|
+
# Smart Account address derivation constants
|
|
30
|
+
# These match the CREATE2 deterministic address generation used by
|
|
31
|
+
# SimpleSmartAccount in the permissionless library
|
|
32
|
+
SIMPLE_ACCOUNT_FACTORY = "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985"
|
|
33
|
+
ENTRYPOINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_eoa_account(mnemonic: str):
|
|
37
|
+
"""Derive the EOA (externally-owned account) from a BIP-39 mnemonic.
|
|
38
|
+
|
|
39
|
+
Returns the full ``LocalAccount`` so callers can access both address and
|
|
40
|
+
private key.
|
|
41
|
+
"""
|
|
42
|
+
from eth_account import Account
|
|
43
|
+
Account.enable_unaudited_hdwallet_features()
|
|
44
|
+
return Account.from_mnemonic(mnemonic.strip(), account_path="m/44'/60'/0'/0/0")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_eoa_address(mnemonic: str) -> str:
|
|
48
|
+
"""Derive the EOA address from a BIP-39 mnemonic."""
|
|
49
|
+
return _get_eoa_account(mnemonic).address
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _derive_smart_account_address(mnemonic: str, rpc_url: str = "https://sepolia.base.org") -> str:
|
|
53
|
+
"""Derive the CREATE2 Smart Account address by querying the factory contract.
|
|
54
|
+
|
|
55
|
+
Calls SimpleAccountFactory.getAddress(owner, 0) via eth_call on a public RPC.
|
|
56
|
+
This matches the `toSimpleSmartAccount` logic in permissionless/viem.
|
|
57
|
+
|
|
58
|
+
Factory: 0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985 (v0.7)
|
|
59
|
+
Method: getAddress(address owner, uint256 salt) view returns (address)
|
|
60
|
+
"""
|
|
61
|
+
import httpx
|
|
62
|
+
|
|
63
|
+
eoa_address = _get_eoa_address(mnemonic)
|
|
64
|
+
|
|
65
|
+
# ABI-encode: getAddress(address,uint256)
|
|
66
|
+
# keccak256("getAddress(address,uint256)")[:4] = 0x8cb84e18
|
|
67
|
+
selector = "8cb84e18"
|
|
68
|
+
|
|
69
|
+
# Pad owner address to 32 bytes and salt (0) to 32 bytes
|
|
70
|
+
owner_padded = eoa_address.lower().replace("0x", "").zfill(64)
|
|
71
|
+
salt_padded = "0" * 64
|
|
72
|
+
|
|
73
|
+
calldata = f"0x{selector}{owner_padded}{salt_padded}"
|
|
74
|
+
|
|
75
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
76
|
+
resp = await client.post(
|
|
77
|
+
rpc_url,
|
|
78
|
+
json={
|
|
79
|
+
"jsonrpc": "2.0",
|
|
80
|
+
"method": "eth_call",
|
|
81
|
+
"params": [
|
|
82
|
+
{"to": SIMPLE_ACCOUNT_FACTORY, "data": calldata},
|
|
83
|
+
"latest",
|
|
84
|
+
],
|
|
85
|
+
"id": 1,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
result = resp.json().get("result", "")
|
|
89
|
+
if not result or result == "0x" or len(result) < 66:
|
|
90
|
+
# Fallback to EOA if RPC fails
|
|
91
|
+
return eoa_address.lower()
|
|
92
|
+
# Result is ABI-encoded address (32 bytes, last 20 bytes are the address)
|
|
93
|
+
address = "0x" + result[-40:]
|
|
94
|
+
return address.lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TotalReclaw:
|
|
98
|
+
"""High-level TotalReclaw client with remember/recall/forget/export/status.
|
|
99
|
+
|
|
100
|
+
The wallet_address is the CREATE2 Smart Account address. If not provided,
|
|
101
|
+
call `await client.resolve_address()` before using remember/recall/forget/export
|
|
102
|
+
to derive it via an RPC call to the SimpleAccountFactory contract.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
mnemonic: str,
|
|
108
|
+
relay_url: str = DEFAULT_RELAY_URL,
|
|
109
|
+
wallet_address: Optional[str] = None,
|
|
110
|
+
is_test: bool = False,
|
|
111
|
+
):
|
|
112
|
+
self._mnemonic = mnemonic.strip()
|
|
113
|
+
self._keys = derive_keys_from_mnemonic(self._mnemonic)
|
|
114
|
+
self._lsh_seed = derive_lsh_seed(self._mnemonic, self._keys.salt)
|
|
115
|
+
self._lsh_hasher: Optional[LSHHasher] = None
|
|
116
|
+
self._auth_key_hex = self._keys.auth_key.hex()
|
|
117
|
+
self._relay_url = relay_url
|
|
118
|
+
|
|
119
|
+
# Derive EOA account (address + private key) for UserOp signing
|
|
120
|
+
eoa_acct = _get_eoa_account(self._mnemonic)
|
|
121
|
+
self._eoa_address: str = eoa_acct.address
|
|
122
|
+
self._eoa_private_key: bytes = bytes(eoa_acct.key)
|
|
123
|
+
|
|
124
|
+
# Use provided address, or fall back to EOA (resolve_address fixes it later)
|
|
125
|
+
self._wallet_address = (wallet_address or self._eoa_address).lower()
|
|
126
|
+
self._address_resolved = wallet_address is not None
|
|
127
|
+
self._relay = RelayClient(
|
|
128
|
+
relay_url=relay_url,
|
|
129
|
+
auth_key_hex=self._auth_key_hex,
|
|
130
|
+
wallet_address=self._wallet_address,
|
|
131
|
+
is_test=is_test,
|
|
132
|
+
)
|
|
133
|
+
self._registered = False
|
|
134
|
+
|
|
135
|
+
async def resolve_address(self) -> str:
|
|
136
|
+
"""Resolve the CREATE2 Smart Account address via RPC.
|
|
137
|
+
|
|
138
|
+
Must be called before remember/recall/forget/export if wallet_address
|
|
139
|
+
was not provided at construction time.
|
|
140
|
+
"""
|
|
141
|
+
if self._address_resolved:
|
|
142
|
+
return self._wallet_address
|
|
143
|
+
|
|
144
|
+
self._wallet_address = await _derive_smart_account_address(self._mnemonic)
|
|
145
|
+
self._address_resolved = True
|
|
146
|
+
# Update relay client with resolved address
|
|
147
|
+
self._relay._wallet_address = self._wallet_address
|
|
148
|
+
return self._wallet_address
|
|
149
|
+
|
|
150
|
+
async def _ensure_address(self) -> None:
|
|
151
|
+
"""Lazily resolve the Smart Account address if not yet done."""
|
|
152
|
+
if not self._address_resolved:
|
|
153
|
+
await self.resolve_address()
|
|
154
|
+
|
|
155
|
+
def _get_lsh_hasher(self, dims: int = 1024) -> LSHHasher:
|
|
156
|
+
if self._lsh_hasher is None:
|
|
157
|
+
self._lsh_hasher = LSHHasher(self._lsh_seed, dims)
|
|
158
|
+
return self._lsh_hasher
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def wallet_address(self) -> str:
|
|
162
|
+
return self._wallet_address
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def keys(self) -> DerivedKeys:
|
|
166
|
+
return self._keys
|
|
167
|
+
|
|
168
|
+
async def register(self) -> str:
|
|
169
|
+
"""Register with the relay. Returns user_id."""
|
|
170
|
+
auth_hash = compute_auth_key_hash(self._keys.auth_key)
|
|
171
|
+
user_id = await self._relay.register(auth_hash, self._keys.salt.hex())
|
|
172
|
+
self._registered = True
|
|
173
|
+
return user_id
|
|
174
|
+
|
|
175
|
+
async def remember(
|
|
176
|
+
self,
|
|
177
|
+
text: str,
|
|
178
|
+
embedding: Optional[list[float]] = None,
|
|
179
|
+
importance: float = 0.5,
|
|
180
|
+
source: str = "python-client",
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Store a fact. Returns the fact ID."""
|
|
183
|
+
await self._ensure_address()
|
|
184
|
+
lsh = self._get_lsh_hasher() if embedding else None
|
|
185
|
+
return await store_fact(
|
|
186
|
+
text=text,
|
|
187
|
+
keys=self._keys,
|
|
188
|
+
owner=self._wallet_address,
|
|
189
|
+
relay=self._relay,
|
|
190
|
+
lsh_hasher=lsh,
|
|
191
|
+
embedding=embedding,
|
|
192
|
+
importance=importance,
|
|
193
|
+
source=source,
|
|
194
|
+
eoa_private_key=self._eoa_private_key,
|
|
195
|
+
eoa_address=self._eoa_address,
|
|
196
|
+
sender=self._wallet_address,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def recall(
|
|
200
|
+
self,
|
|
201
|
+
query: str,
|
|
202
|
+
query_embedding: Optional[list[float]] = None,
|
|
203
|
+
max_candidates: int = 250,
|
|
204
|
+
top_k: int = 8,
|
|
205
|
+
) -> list[RerankerResult]:
|
|
206
|
+
"""Search for facts matching a query. Returns ranked results."""
|
|
207
|
+
await self._ensure_address()
|
|
208
|
+
return await search_facts(
|
|
209
|
+
query=query,
|
|
210
|
+
keys=self._keys,
|
|
211
|
+
owner=self._wallet_address,
|
|
212
|
+
relay=self._relay,
|
|
213
|
+
query_embedding=query_embedding,
|
|
214
|
+
max_candidates=max_candidates,
|
|
215
|
+
top_k=top_k,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def forget(self, fact_id: str) -> bool:
|
|
219
|
+
"""Soft-delete a fact by writing a tombstone."""
|
|
220
|
+
await self._ensure_address()
|
|
221
|
+
return await forget_fact(
|
|
222
|
+
fact_id,
|
|
223
|
+
self._wallet_address,
|
|
224
|
+
self._relay,
|
|
225
|
+
eoa_private_key=self._eoa_private_key,
|
|
226
|
+
eoa_address=self._eoa_address,
|
|
227
|
+
sender=self._wallet_address,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def export_all(self) -> list[dict]:
|
|
231
|
+
"""Export all active facts, decrypted."""
|
|
232
|
+
await self._ensure_address()
|
|
233
|
+
return await export_facts(self._keys, self._wallet_address, self._relay)
|
|
234
|
+
|
|
235
|
+
async def status(self) -> BillingStatus:
|
|
236
|
+
"""Get billing status."""
|
|
237
|
+
await self._ensure_address()
|
|
238
|
+
return await self._relay.get_billing_status()
|
|
239
|
+
|
|
240
|
+
async def close(self):
|
|
241
|
+
"""Close the HTTP client."""
|
|
242
|
+
await self._relay.close()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TotalReclaw E2EE crypto primitives.
|
|
3
|
+
|
|
4
|
+
Delegates to the totalreclaw_core Rust/PyO3 module for all crypto operations.
|
|
5
|
+
Maintains the same Python API for backward compatibility.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import struct
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import totalreclaw_core
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Key Derivation
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DerivedKeys:
|
|
22
|
+
salt: bytes
|
|
23
|
+
auth_key: bytes
|
|
24
|
+
encryption_key: bytes
|
|
25
|
+
dedup_key: bytes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def derive_keys_from_mnemonic(mnemonic: str) -> DerivedKeys:
|
|
29
|
+
"""Derive all crypto keys from a BIP-39 mnemonic.
|
|
30
|
+
|
|
31
|
+
Delegates to totalreclaw_core (Rust/PyO3).
|
|
32
|
+
"""
|
|
33
|
+
result = totalreclaw_core.derive_keys_from_mnemonic(mnemonic)
|
|
34
|
+
return DerivedKeys(
|
|
35
|
+
salt=result["salt"],
|
|
36
|
+
auth_key=result["auth_key"],
|
|
37
|
+
encryption_key=result["encryption_key"],
|
|
38
|
+
dedup_key=result["dedup_key"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_auth_key_hash(auth_key: bytes) -> str:
|
|
43
|
+
"""SHA-256(authKey) as hex string."""
|
|
44
|
+
return totalreclaw_core.compute_auth_key_hash(auth_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def derive_lsh_seed(mnemonic: str, salt: bytes) -> bytes:
|
|
48
|
+
"""Derive the 32-byte LSH seed."""
|
|
49
|
+
return totalreclaw_core.derive_lsh_seed(mnemonic, salt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# AES-256-GCM Encryption / Decryption
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def encrypt(plaintext: str, encryption_key: bytes) -> str:
|
|
58
|
+
"""Encrypt with AES-256-GCM.
|
|
59
|
+
|
|
60
|
+
Wire format: iv(12) || tag(16) || ciphertext -> base64.
|
|
61
|
+
"""
|
|
62
|
+
return totalreclaw_core.encrypt(plaintext, encryption_key)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def decrypt(encrypted_base64: str, encryption_key: bytes) -> str:
|
|
66
|
+
"""Decrypt AES-256-GCM.
|
|
67
|
+
|
|
68
|
+
Expects wire format: iv(12) || tag(16) || ciphertext.
|
|
69
|
+
"""
|
|
70
|
+
return totalreclaw_core.decrypt(encrypted_base64, encryption_key)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Blind Indices + Stemming
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_blind_indices(text: str) -> list[str]:
|
|
79
|
+
"""Generate blind indices (SHA-256 hashes of tokens).
|
|
80
|
+
|
|
81
|
+
Tokenization rules (matches canonical TypeScript/Rust implementation):
|
|
82
|
+
1. Lowercase
|
|
83
|
+
2. Remove punctuation (keep Unicode letters, numbers, whitespace)
|
|
84
|
+
3. Split on whitespace
|
|
85
|
+
4. Filter tokens shorter than 2 characters
|
|
86
|
+
|
|
87
|
+
Each surviving token is SHA-256 hashed and returned as a hex string.
|
|
88
|
+
Stemmed variants are prefixed with "stem:" before hashing.
|
|
89
|
+
The returned array is deduplicated.
|
|
90
|
+
"""
|
|
91
|
+
return totalreclaw_core.generate_blind_indices(text)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Content Fingerprint (Dedup)
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def generate_content_fingerprint(plaintext: str, dedup_key: bytes) -> str:
|
|
100
|
+
"""HMAC-SHA256 content fingerprint. Returns 64-char hex string."""
|
|
101
|
+
return totalreclaw_core.generate_content_fingerprint(plaintext, dedup_key)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Embedding Encryption
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def encrypt_embedding(embedding: list[float], encryption_key: bytes) -> str:
|
|
110
|
+
"""Encrypt embedding: pack as LE float32 array -> base64 -> AES encrypt."""
|
|
111
|
+
buf = struct.pack(f"<{len(embedding)}f", *embedding)
|
|
112
|
+
return encrypt(base64.b64encode(buf).decode("ascii"), encryption_key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def decrypt_embedding(
|
|
116
|
+
encrypted_embedding: str, encryption_key: bytes
|
|
117
|
+
) -> list[float]:
|
|
118
|
+
"""Decrypt embedding back to float array."""
|
|
119
|
+
decrypted_base64 = decrypt(encrypted_embedding, encryption_key)
|
|
120
|
+
buf = base64.b64decode(decrypted_base64)
|
|
121
|
+
count = len(buf) // 4
|
|
122
|
+
return list(struct.unpack(f"<{count}f", buf))
|