etchmem 0.1.6__tar.gz → 0.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.
- etchmem-0.2.1/PKG-INFO +165 -0
- etchmem-0.2.1/README.md +137 -0
- etchmem-0.2.1/etchmem/__init__.py +54 -0
- etchmem-0.2.1/etchmem/async_client.py +132 -0
- etchmem-0.2.1/etchmem/client.py +150 -0
- etchmem-0.2.1/etchmem/errors.py +44 -0
- etchmem-0.2.1/etchmem/models.py +211 -0
- etchmem-0.2.1/etchmem.egg-info/PKG-INFO +165 -0
- etchmem-0.2.1/etchmem.egg-info/SOURCES.txt +14 -0
- etchmem-0.2.1/etchmem.egg-info/requires.txt +7 -0
- {etchmem-0.1.6 → etchmem-0.2.1}/pyproject.toml +7 -7
- etchmem-0.2.1/tests/test_sdk.py +202 -0
- etchmem-0.1.6/PKG-INFO +0 -147
- etchmem-0.1.6/README.md +0 -119
- etchmem-0.1.6/etchmem/__init__.py +0 -25
- etchmem-0.1.6/etchmem/config.py +0 -109
- etchmem-0.1.6/etchmem/connector/__init__.py +0 -0
- etchmem-0.1.6/etchmem/connector/base.py +0 -35
- etchmem-0.1.6/etchmem/connector/remote.py +0 -121
- etchmem-0.1.6/etchmem/consolidate/__init__.py +0 -0
- etchmem-0.1.6/etchmem/consolidate/worker.py +0 -590
- etchmem-0.1.6/etchmem/engine.py +0 -262
- etchmem-0.1.6/etchmem/models.py +0 -148
- etchmem-0.1.6/etchmem/recall/__init__.py +0 -0
- etchmem-0.1.6/etchmem/recall/recaller.py +0 -165
- etchmem-0.1.6/etchmem/stores/__init__.py +0 -0
- etchmem-0.1.6/etchmem/stores/base.py +0 -171
- etchmem-0.1.6/etchmem/stores/chroma_store.py +0 -649
- etchmem-0.1.6/etchmem/util/__init__.py +0 -0
- etchmem-0.1.6/etchmem/util/affectability.py +0 -104
- etchmem-0.1.6/etchmem/util/hashing.py +0 -49
- etchmem-0.1.6/etchmem.egg-info/PKG-INFO +0 -147
- etchmem-0.1.6/etchmem.egg-info/SOURCES.txt +0 -31
- etchmem-0.1.6/etchmem.egg-info/requires.txt +0 -7
- etchmem-0.1.6/tests/test_consolidate_idempotent.py +0 -211
- etchmem-0.1.6/tests/test_export.py +0 -182
- etchmem-0.1.6/tests/test_formation_buckets.py +0 -189
- etchmem-0.1.6/tests/test_recall.py +0 -133
- etchmem-0.1.6/tests/test_reconsolidation.py +0 -206
- etchmem-0.1.6/tests/test_remember_recall.py +0 -113
- {etchmem-0.1.6 → etchmem-0.2.1}/LICENSE +0 -0
- {etchmem-0.1.6 → etchmem-0.2.1}/etchmem.egg-info/dependency_links.txt +0 -0
- {etchmem-0.1.6 → etchmem-0.2.1}/etchmem.egg-info/top_level.txt +0 -0
- {etchmem-0.1.6 → etchmem-0.2.1}/setup.cfg +0 -0
etchmem-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etchmem
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python SDK for etchmem-server — the Knowledge Consolidation Engine for AI agents
|
|
5
|
+
Author: Andrey Olishchuk
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/vossmoos/etchmem-sdk-python
|
|
8
|
+
Keywords: llm,agent,memory,knowledge,consolidation,rag,sdk,rest,client,etchmem
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# etchmem
|
|
30
|
+
|
|
31
|
+
**Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
|
|
32
|
+
|
|
33
|
+
Your agents' daily activity is full of hard-won facts — deals signed, decisions made, preferences learned. etchmem-server turns that stream of raw signals into a clean, typed, versioned knowledge base: one consolidated belief per fact, with confidence, provenance, conflict status, and full version history — queryable at any point in time.
|
|
34
|
+
|
|
35
|
+
This package is the REST client. One dependency (`httpx`), sync and async, fully typed responses.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install etchmem
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> **Note on MCP:** etchmem-server also exposes an MCP interface at `/mcp` with the same operations as tools. MCP clients (Claude, agent frameworks, IDEs) connect to it directly over the MCP protocol — no SDK needed. This package is for REST integrations only.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
Run the server (see the etchmem-server repo — `docker compose up`), then:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from etchmem import EtchMem
|
|
51
|
+
|
|
52
|
+
mem = EtchMem("http://localhost:8000") # default; also reads ETCHMEM_BASE_URL
|
|
53
|
+
|
|
54
|
+
# Deposit a raw signal — no pre-structuring needed
|
|
55
|
+
mem.remember(
|
|
56
|
+
"Acme signed the Q3 renewal at $40k. Maria approved the discount.",
|
|
57
|
+
source="agent-33",
|
|
58
|
+
scope="sales",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Semantic recall over consolidated beliefs
|
|
62
|
+
for hit in mem.recall("what did Acme sign?", top_k=5):
|
|
63
|
+
print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Async
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from etchmem import AsyncEtchMem
|
|
70
|
+
|
|
71
|
+
async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
72
|
+
await mem.remember("...", source="agent-33", scope="sales")
|
|
73
|
+
hits = await mem.recall("what did Acme sign?")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## API surface
|
|
79
|
+
|
|
80
|
+
The SDK mirrors the seven REST endpoints one-to-one:
|
|
81
|
+
|
|
82
|
+
| Method | Endpoint | Returns |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `remember(data, *, source, scope, extract_mode="deferred", metadata=None)` | `POST /remember` | `RememberResult` |
|
|
85
|
+
| `recall(query, *, scope=None, source=None, top_k=5, include_signals=True, as_of=None)` | `POST /recall` | `RecallResponse` (iterable of `RecallResult`) |
|
|
86
|
+
| `sleep()` | `POST /sleep` | `SleepResult` |
|
|
87
|
+
| `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
|
|
88
|
+
| `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
|
|
89
|
+
| `stats()` | `GET /stats` | `Stats` |
|
|
90
|
+
| `health()` | `GET /health` | `Health` |
|
|
91
|
+
|
|
92
|
+
All responses are plain dataclasses — see `etchmem/models.py`.
|
|
93
|
+
|
|
94
|
+
### remember
|
|
95
|
+
|
|
96
|
+
`extract_mode="immediate"` marks the signal urgent (claims extracted on the next worker tick); `"deferred"` (default) batches it cheaply. Deposits are idempotent — identical content returns `stored=False`.
|
|
97
|
+
|
|
98
|
+
### recall with time-travel
|
|
99
|
+
|
|
100
|
+
Pass `as_of` (ISO-8601) to recall what the system believed at that moment — for auditing decisions or incident forensics:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
hits = mem.recall("Acme contract value", as_of="2026-06-01T00:00:00Z")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### sleep
|
|
107
|
+
|
|
108
|
+
The server consolidates on its own cadence. `mem.sleep()` forces one pipeline tick now (batch → extract claims → fold into etches) — useful in tests and demos:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
tick = mem.sleep()
|
|
112
|
+
print(tick.etches_formed, tick.etches_updated, tick.contested)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### history
|
|
116
|
+
|
|
117
|
+
Every belief change writes an immutable version snapshot:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
for v in mem.history(etch_id):
|
|
121
|
+
print(v.version, v.current_value, v.status, v.confidence)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
| Parameter | Env var | Default |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `base_url` | `ETCHMEM_BASE_URL` | `http://localhost:8000` |
|
|
131
|
+
| `api_key` | `ETCHMEM_API_KEY` | none (sent as `Authorization: Bearer …` when set) |
|
|
132
|
+
| `timeout` | — | 30 s |
|
|
133
|
+
|
|
134
|
+
You can also inject your own `httpx.Client` / `httpx.AsyncClient` via the `client` parameter (custom TLS, proxies, retries).
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
All SDK errors derive from `EtchMemError`:
|
|
139
|
+
|
|
140
|
+
- `ConnectionError` — server unreachable (network / timeout)
|
|
141
|
+
- `APIError` — non-2xx response (`.status_code`, `.detail`), with subclasses `NotFoundError` (404), `AuthenticationError` (401/403), `ServerError` (5xx)
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from etchmem import EtchMem, NotFoundError
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
mem.history("no-such-etch")
|
|
148
|
+
except NotFoundError as e:
|
|
149
|
+
print(e.detail)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
python -m pytest tests/
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Releasing to PyPI: see [PIP-DEPLOY.md](PIP-DEPLOY.md).
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
etchmem-0.2.1/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# etchmem
|
|
2
|
+
|
|
3
|
+
**Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
|
|
4
|
+
|
|
5
|
+
Your agents' daily activity is full of hard-won facts — deals signed, decisions made, preferences learned. etchmem-server turns that stream of raw signals into a clean, typed, versioned knowledge base: one consolidated belief per fact, with confidence, provenance, conflict status, and full version history — queryable at any point in time.
|
|
6
|
+
|
|
7
|
+
This package is the REST client. One dependency (`httpx`), sync and async, fully typed responses.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install etchmem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Note on MCP:** etchmem-server also exposes an MCP interface at `/mcp` with the same operations as tools. MCP clients (Claude, agent frameworks, IDEs) connect to it directly over the MCP protocol — no SDK needed. This package is for REST integrations only.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
Run the server (see the etchmem-server repo — `docker compose up`), then:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from etchmem import EtchMem
|
|
23
|
+
|
|
24
|
+
mem = EtchMem("http://localhost:8000") # default; also reads ETCHMEM_BASE_URL
|
|
25
|
+
|
|
26
|
+
# Deposit a raw signal — no pre-structuring needed
|
|
27
|
+
mem.remember(
|
|
28
|
+
"Acme signed the Q3 renewal at $40k. Maria approved the discount.",
|
|
29
|
+
source="agent-33",
|
|
30
|
+
scope="sales",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Semantic recall over consolidated beliefs
|
|
34
|
+
for hit in mem.recall("what did Acme sign?", top_k=5):
|
|
35
|
+
print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Async
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from etchmem import AsyncEtchMem
|
|
42
|
+
|
|
43
|
+
async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
44
|
+
await mem.remember("...", source="agent-33", scope="sales")
|
|
45
|
+
hits = await mem.recall("what did Acme sign?")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## API surface
|
|
51
|
+
|
|
52
|
+
The SDK mirrors the seven REST endpoints one-to-one:
|
|
53
|
+
|
|
54
|
+
| Method | Endpoint | Returns |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `remember(data, *, source, scope, extract_mode="deferred", metadata=None)` | `POST /remember` | `RememberResult` |
|
|
57
|
+
| `recall(query, *, scope=None, source=None, top_k=5, include_signals=True, as_of=None)` | `POST /recall` | `RecallResponse` (iterable of `RecallResult`) |
|
|
58
|
+
| `sleep()` | `POST /sleep` | `SleepResult` |
|
|
59
|
+
| `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
|
|
60
|
+
| `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
|
|
61
|
+
| `stats()` | `GET /stats` | `Stats` |
|
|
62
|
+
| `health()` | `GET /health` | `Health` |
|
|
63
|
+
|
|
64
|
+
All responses are plain dataclasses — see `etchmem/models.py`.
|
|
65
|
+
|
|
66
|
+
### remember
|
|
67
|
+
|
|
68
|
+
`extract_mode="immediate"` marks the signal urgent (claims extracted on the next worker tick); `"deferred"` (default) batches it cheaply. Deposits are idempotent — identical content returns `stored=False`.
|
|
69
|
+
|
|
70
|
+
### recall with time-travel
|
|
71
|
+
|
|
72
|
+
Pass `as_of` (ISO-8601) to recall what the system believed at that moment — for auditing decisions or incident forensics:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
hits = mem.recall("Acme contract value", as_of="2026-06-01T00:00:00Z")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### sleep
|
|
79
|
+
|
|
80
|
+
The server consolidates on its own cadence. `mem.sleep()` forces one pipeline tick now (batch → extract claims → fold into etches) — useful in tests and demos:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
tick = mem.sleep()
|
|
84
|
+
print(tick.etches_formed, tick.etches_updated, tick.contested)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### history
|
|
88
|
+
|
|
89
|
+
Every belief change writes an immutable version snapshot:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
for v in mem.history(etch_id):
|
|
93
|
+
print(v.version, v.current_value, v.status, v.confidence)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| Parameter | Env var | Default |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `base_url` | `ETCHMEM_BASE_URL` | `http://localhost:8000` |
|
|
103
|
+
| `api_key` | `ETCHMEM_API_KEY` | none (sent as `Authorization: Bearer …` when set) |
|
|
104
|
+
| `timeout` | — | 30 s |
|
|
105
|
+
|
|
106
|
+
You can also inject your own `httpx.Client` / `httpx.AsyncClient` via the `client` parameter (custom TLS, proxies, retries).
|
|
107
|
+
|
|
108
|
+
## Errors
|
|
109
|
+
|
|
110
|
+
All SDK errors derive from `EtchMemError`:
|
|
111
|
+
|
|
112
|
+
- `ConnectionError` — server unreachable (network / timeout)
|
|
113
|
+
- `APIError` — non-2xx response (`.status_code`, `.detail`), with subclasses `NotFoundError` (404), `AuthenticationError` (401/403), `ServerError` (5xx)
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from etchmem import EtchMem, NotFoundError
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
mem.history("no-such-etch")
|
|
120
|
+
except NotFoundError as e:
|
|
121
|
+
print(e.detail)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
python -m pytest tests/
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Releasing to PyPI: see [PIP-DEPLOY.md](PIP-DEPLOY.md).
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
etchmem — Python SDK for the etchmem-server REST API.
|
|
3
|
+
|
|
4
|
+
The Knowledge Consolidation Engine for AI agents: raw signals in,
|
|
5
|
+
consolidated, versioned beliefs (etches) out.
|
|
6
|
+
|
|
7
|
+
Public surface::
|
|
8
|
+
|
|
9
|
+
from etchmem import EtchMem, AsyncEtchMem
|
|
10
|
+
|
|
11
|
+
mem = EtchMem("http://localhost:8000")
|
|
12
|
+
mem.remember("Acme signed the Q3 renewal at $40k.",
|
|
13
|
+
source="agent-33", scope="sales")
|
|
14
|
+
hits = mem.recall("what did Acme sign?")
|
|
15
|
+
|
|
16
|
+
Note: the server also exposes an MCP interface at ``/mcp``. MCP clients
|
|
17
|
+
connect to it directly with the MCP protocol — no SDK needed there. This
|
|
18
|
+
package is for REST integrations.
|
|
19
|
+
"""
|
|
20
|
+
from etchmem.async_client import AsyncEtchMem
|
|
21
|
+
from etchmem.client import EtchMem
|
|
22
|
+
from etchmem.errors import (
|
|
23
|
+
APIError, AuthenticationError, ConnectionError, EtchMemError,
|
|
24
|
+
NotFoundError, ServerError,
|
|
25
|
+
)
|
|
26
|
+
from etchmem.models import (
|
|
27
|
+
Etch, EtchVersion, ExportResult, Health, History, RecallResponse,
|
|
28
|
+
RecallResult, RememberResult, SleepResult, Stats,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EtchMem",
|
|
33
|
+
"AsyncEtchMem",
|
|
34
|
+
# models
|
|
35
|
+
"RememberResult",
|
|
36
|
+
"RecallResponse",
|
|
37
|
+
"RecallResult",
|
|
38
|
+
"SleepResult",
|
|
39
|
+
"ExportResult",
|
|
40
|
+
"Etch",
|
|
41
|
+
"EtchVersion",
|
|
42
|
+
"History",
|
|
43
|
+
"Stats",
|
|
44
|
+
"Health",
|
|
45
|
+
# errors
|
|
46
|
+
"EtchMemError",
|
|
47
|
+
"APIError",
|
|
48
|
+
"ConnectionError",
|
|
49
|
+
"AuthenticationError",
|
|
50
|
+
"NotFoundError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Asynchronous client for the etchmem-server REST API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from etchmem.client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
|
|
10
|
+
from etchmem.errors import ConnectionError, error_for_status
|
|
11
|
+
from etchmem.models import (
|
|
12
|
+
ExportResult, Health, History, RecallResponse, RememberResult,
|
|
13
|
+
SleepResult, Stats,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncEtchMem:
|
|
18
|
+
"""Asynchronous etchmem client (same surface as :class:`etchmem.EtchMem`).
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
from etchmem import AsyncEtchMem
|
|
23
|
+
|
|
24
|
+
async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
25
|
+
await mem.remember("Acme signed the Q3 renewal at $40k.",
|
|
26
|
+
source="agent-33", scope="sales")
|
|
27
|
+
hits = await mem.recall("what did Acme sign?")
|
|
28
|
+
|
|
29
|
+
Configuration falls back to environment variables:
|
|
30
|
+
``ETCHMEM_BASE_URL`` and ``ETCHMEM_API_KEY``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
*,
|
|
37
|
+
api_key: str | None = None,
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
39
|
+
client: httpx.AsyncClient | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.base_url = (base_url
|
|
42
|
+
or os.environ.get("ETCHMEM_BASE_URL")
|
|
43
|
+
or DEFAULT_BASE_URL).rstrip("/")
|
|
44
|
+
self.api_key = api_key or os.environ.get("ETCHMEM_API_KEY")
|
|
45
|
+
headers = {"Accept": "application/json"}
|
|
46
|
+
if self.api_key:
|
|
47
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
48
|
+
self._client = client or httpx.AsyncClient(
|
|
49
|
+
base_url=self.base_url, timeout=timeout)
|
|
50
|
+
self._client.headers.update(headers)
|
|
51
|
+
|
|
52
|
+
# ── lifecycle ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
await self._client.aclose()
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "AsyncEtchMem":
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
61
|
+
await self.close()
|
|
62
|
+
|
|
63
|
+
# ── internal ───────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async def _request(self, method: str, path: str,
|
|
66
|
+
json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
67
|
+
try:
|
|
68
|
+
resp = await self._client.request(method, path, json=json)
|
|
69
|
+
except httpx.HTTPError as e:
|
|
70
|
+
raise ConnectionError(
|
|
71
|
+
f"etchmem server unreachable at {self.base_url}: {e}") from e
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
try:
|
|
74
|
+
detail = resp.json().get("detail", resp.text)
|
|
75
|
+
except Exception:
|
|
76
|
+
detail = resp.text
|
|
77
|
+
raise error_for_status(resp.status_code, detail)
|
|
78
|
+
return resp.json()
|
|
79
|
+
|
|
80
|
+
# ── API surface ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async def remember(
|
|
83
|
+
self,
|
|
84
|
+
data: str,
|
|
85
|
+
*,
|
|
86
|
+
source: str,
|
|
87
|
+
scope: str,
|
|
88
|
+
extract_mode: str = "deferred",
|
|
89
|
+
metadata: dict[str, Any] | None = None,
|
|
90
|
+
) -> RememberResult:
|
|
91
|
+
"""Deposit a raw text signal (see :meth:`etchmem.EtchMem.remember`)."""
|
|
92
|
+
payload = await self._request("POST", "/remember", json={
|
|
93
|
+
"data": data, "source": source, "scope": scope,
|
|
94
|
+
"extract_mode": extract_mode, "metadata": metadata or {}})
|
|
95
|
+
return RememberResult.from_dict(payload)
|
|
96
|
+
|
|
97
|
+
async def recall(
|
|
98
|
+
self,
|
|
99
|
+
query: str,
|
|
100
|
+
*,
|
|
101
|
+
scope: str | None = None,
|
|
102
|
+
source: str | None = None,
|
|
103
|
+
top_k: int = 5,
|
|
104
|
+
include_signals: bool = True,
|
|
105
|
+
as_of: str | None = None,
|
|
106
|
+
) -> RecallResponse:
|
|
107
|
+
"""Semantic recall over etches; ``as_of`` enables time-travel."""
|
|
108
|
+
payload = await self._request("POST", "/recall", json={
|
|
109
|
+
"query": query, "scope": scope, "source": source, "top_k": top_k,
|
|
110
|
+
"include_signals": include_signals, "as_of": as_of})
|
|
111
|
+
return RecallResponse.from_dict(payload)
|
|
112
|
+
|
|
113
|
+
async def sleep(self) -> SleepResult:
|
|
114
|
+
"""Run one consolidation tick now (batch → extract → fold)."""
|
|
115
|
+
return SleepResult.from_dict(await self._request("POST", "/sleep"))
|
|
116
|
+
|
|
117
|
+
async def export(self) -> ExportResult:
|
|
118
|
+
"""Dump all etches to JSON files on the server; returns them too."""
|
|
119
|
+
return ExportResult.from_dict(await self._request("POST", "/export"))
|
|
120
|
+
|
|
121
|
+
async def history(self, etch_id: str) -> History:
|
|
122
|
+
"""Version timeline of one etch (immutable snapshots)."""
|
|
123
|
+
return History.from_dict(
|
|
124
|
+
await self._request("GET", f"/etch/{etch_id}/history"))
|
|
125
|
+
|
|
126
|
+
async def stats(self) -> Stats:
|
|
127
|
+
"""Queue depths and store counts."""
|
|
128
|
+
return Stats.from_dict(await self._request("GET", "/stats"))
|
|
129
|
+
|
|
130
|
+
async def health(self) -> Health:
|
|
131
|
+
"""Liveness probe plus active server configuration."""
|
|
132
|
+
return Health.from_dict(await self._request("GET", "/health"))
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Synchronous client for the etchmem-server REST API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from etchmem.errors import ConnectionError, error_for_status
|
|
10
|
+
from etchmem.models import (
|
|
11
|
+
ExportResult, Health, History, RecallResponse, RememberResult,
|
|
12
|
+
SleepResult, Stats,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EtchMem:
|
|
20
|
+
"""Synchronous etchmem client.
|
|
21
|
+
|
|
22
|
+
Usage::
|
|
23
|
+
|
|
24
|
+
from etchmem import EtchMem
|
|
25
|
+
|
|
26
|
+
mem = EtchMem("http://localhost:8000")
|
|
27
|
+
mem.remember("Acme signed the Q3 renewal at $40k.",
|
|
28
|
+
source="agent-33", scope="sales")
|
|
29
|
+
for hit in mem.recall("what did Acme sign?"):
|
|
30
|
+
print(hit.content, hit.confidence)
|
|
31
|
+
|
|
32
|
+
Can be used as a context manager to close the underlying HTTP
|
|
33
|
+
connection pool deterministically::
|
|
34
|
+
|
|
35
|
+
with EtchMem() as mem:
|
|
36
|
+
mem.remember(...)
|
|
37
|
+
|
|
38
|
+
Configuration falls back to environment variables:
|
|
39
|
+
``ETCHMEM_BASE_URL`` and ``ETCHMEM_API_KEY``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
base_url: str | None = None,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
48
|
+
client: httpx.Client | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.base_url = (base_url
|
|
51
|
+
or os.environ.get("ETCHMEM_BASE_URL")
|
|
52
|
+
or DEFAULT_BASE_URL).rstrip("/")
|
|
53
|
+
self.api_key = api_key or os.environ.get("ETCHMEM_API_KEY")
|
|
54
|
+
headers = {"Accept": "application/json"}
|
|
55
|
+
if self.api_key:
|
|
56
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
57
|
+
self._client = client or httpx.Client(
|
|
58
|
+
base_url=self.base_url, timeout=timeout)
|
|
59
|
+
self._client.headers.update(headers)
|
|
60
|
+
|
|
61
|
+
# ── lifecycle ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._client.close()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> "EtchMem":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc: Any) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
# ── internal ───────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _request(self, method: str, path: str,
|
|
75
|
+
json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
76
|
+
try:
|
|
77
|
+
resp = self._client.request(method, path, json=json)
|
|
78
|
+
except httpx.HTTPError as e:
|
|
79
|
+
raise ConnectionError(
|
|
80
|
+
f"etchmem server unreachable at {self.base_url}: {e}") from e
|
|
81
|
+
if resp.status_code >= 400:
|
|
82
|
+
try:
|
|
83
|
+
detail = resp.json().get("detail", resp.text)
|
|
84
|
+
except Exception:
|
|
85
|
+
detail = resp.text
|
|
86
|
+
raise error_for_status(resp.status_code, detail)
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
# ── API surface ────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def remember(
|
|
92
|
+
self,
|
|
93
|
+
data: str,
|
|
94
|
+
*,
|
|
95
|
+
source: str,
|
|
96
|
+
scope: str,
|
|
97
|
+
extract_mode: str = "deferred",
|
|
98
|
+
metadata: dict[str, Any] | None = None,
|
|
99
|
+
) -> RememberResult:
|
|
100
|
+
"""Deposit a raw text signal.
|
|
101
|
+
|
|
102
|
+
``extract_mode='immediate'`` marks the signal urgent (extracted on
|
|
103
|
+
the next worker tick); ``'deferred'`` (default) batches it cheaply.
|
|
104
|
+
Idempotent: re-depositing identical content returns ``stored=False``.
|
|
105
|
+
"""
|
|
106
|
+
payload = self._request("POST", "/remember", json={
|
|
107
|
+
"data": data, "source": source, "scope": scope,
|
|
108
|
+
"extract_mode": extract_mode, "metadata": metadata or {}})
|
|
109
|
+
return RememberResult.from_dict(payload)
|
|
110
|
+
|
|
111
|
+
def recall(
|
|
112
|
+
self,
|
|
113
|
+
query: str,
|
|
114
|
+
*,
|
|
115
|
+
scope: str | None = None,
|
|
116
|
+
source: str | None = None,
|
|
117
|
+
top_k: int = 5,
|
|
118
|
+
include_signals: bool = True,
|
|
119
|
+
as_of: str | None = None,
|
|
120
|
+
) -> RecallResponse:
|
|
121
|
+
"""Semantic recall over consolidated etches (plus fresh raw signals).
|
|
122
|
+
|
|
123
|
+
Pass ``as_of`` (ISO-8601) for time-travel recall: results reflect
|
|
124
|
+
what the system believed at that ingest time.
|
|
125
|
+
"""
|
|
126
|
+
payload = self._request("POST", "/recall", json={
|
|
127
|
+
"query": query, "scope": scope, "source": source, "top_k": top_k,
|
|
128
|
+
"include_signals": include_signals, "as_of": as_of})
|
|
129
|
+
return RecallResponse.from_dict(payload)
|
|
130
|
+
|
|
131
|
+
def sleep(self) -> SleepResult:
|
|
132
|
+
"""Run one consolidation tick now (batch → extract → fold)."""
|
|
133
|
+
return SleepResult.from_dict(self._request("POST", "/sleep"))
|
|
134
|
+
|
|
135
|
+
def export(self) -> ExportResult:
|
|
136
|
+
"""Dump all etches to JSON files on the server; returns them too."""
|
|
137
|
+
return ExportResult.from_dict(self._request("POST", "/export"))
|
|
138
|
+
|
|
139
|
+
def history(self, etch_id: str) -> History:
|
|
140
|
+
"""Version timeline of one etch (immutable snapshots)."""
|
|
141
|
+
return History.from_dict(
|
|
142
|
+
self._request("GET", f"/etch/{etch_id}/history"))
|
|
143
|
+
|
|
144
|
+
def stats(self) -> Stats:
|
|
145
|
+
"""Queue depths and store counts."""
|
|
146
|
+
return Stats.from_dict(self._request("GET", "/stats"))
|
|
147
|
+
|
|
148
|
+
def health(self) -> Health:
|
|
149
|
+
"""Liveness probe plus active server configuration."""
|
|
150
|
+
return Health.from_dict(self._request("GET", "/health"))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exception hierarchy for the etchmem SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EtchMemError(Exception):
|
|
8
|
+
"""Base class for all etchmem SDK errors."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionError(EtchMemError):
|
|
12
|
+
"""The server could not be reached (network error, timeout, DNS)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(EtchMemError):
|
|
16
|
+
"""The server returned a non-2xx HTTP status."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status_code: int, detail: Any = None) -> None:
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.detail = detail
|
|
21
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotFoundError(APIError):
|
|
25
|
+
"""404 — resource (e.g. etch id) does not exist."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(APIError):
|
|
29
|
+
"""401 / 403 — missing or invalid API key."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ServerError(APIError):
|
|
33
|
+
"""5xx — the server failed to process the request."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def error_for_status(status_code: int, detail: Any) -> APIError:
|
|
37
|
+
"""Map an HTTP status code to the most specific APIError subclass."""
|
|
38
|
+
if status_code == 404:
|
|
39
|
+
return NotFoundError(status_code, detail)
|
|
40
|
+
if status_code in (401, 403):
|
|
41
|
+
return AuthenticationError(status_code, detail)
|
|
42
|
+
if status_code >= 500:
|
|
43
|
+
return ServerError(status_code, detail)
|
|
44
|
+
return APIError(status_code, detail)
|