etchmem 0.2.1__tar.gz → 0.3.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.
- {etchmem-0.2.1 → etchmem-0.3.0}/PKG-INFO +91 -9
- etchmem-0.3.0/README.md +219 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem/__init__.py +6 -3
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem/async_client.py +69 -4
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem/client.py +75 -4
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem/models.py +76 -2
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem.egg-info/PKG-INFO +91 -9
- {etchmem-0.2.1 → etchmem-0.3.0}/pyproject.toml +2 -2
- etchmem-0.2.1/README.md +0 -137
- {etchmem-0.2.1 → etchmem-0.3.0}/LICENSE +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem/errors.py +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem.egg-info/SOURCES.txt +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem.egg-info/dependency_links.txt +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem.egg-info/requires.txt +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/etchmem.egg-info/top_level.txt +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/setup.cfg +0 -0
- {etchmem-0.2.1 → etchmem-0.3.0}/tests/test_sdk.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etchmem
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python SDK for etchmem-server — the Knowledge Consolidation Engine for AI agents
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for etchmem-server 0.0.4 — the Knowledge Consolidation Engine for AI agents
|
|
5
5
|
Author: Andrey Olishchuk
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Repository, https://github.com/vossmoos/etchmem-sdk-python
|
|
@@ -30,6 +30,8 @@ Dynamic: license-file
|
|
|
30
30
|
|
|
31
31
|
**Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
|
|
32
32
|
|
|
33
|
+
Compatible with **etchmem-server 0.0.4**.
|
|
34
|
+
|
|
33
35
|
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
36
|
|
|
35
37
|
This package is the REST client. One dependency (`httpx`), sync and async, fully typed responses.
|
|
@@ -63,6 +65,44 @@ for hit in mem.recall("what did Acme sign?", top_k=5):
|
|
|
63
65
|
print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
### Three ways to ask
|
|
69
|
+
|
|
70
|
+
Which one you want depends on what you already know. Reaching for the wrong one
|
|
71
|
+
is a quiet source of wrong answers.
|
|
72
|
+
|
|
73
|
+
| You know | Method | Returns |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| the subject | `know(ref)` | every belief about it — exhaustive, ordered by property |
|
|
76
|
+
| only words | `recall(query)` | beliefs ranked by wording, `top_k` |
|
|
77
|
+
| words, and want what they connect to | `associate(query)` | the above **plus** each subject's full facts **plus** connected nodes |
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# know — you have the subject
|
|
81
|
+
facts = mem.know("MSM-0808") # id or surface name; variants resolve
|
|
82
|
+
for e in facts.settled(min_confidence=0.6):
|
|
83
|
+
print(e.property, "=", e.current_value)
|
|
84
|
+
print(facts.contested, "open questions")
|
|
85
|
+
|
|
86
|
+
# recall — you only have words. entity_id chains straight into know()
|
|
87
|
+
for hit in mem.recall("bathroom leaks", top_k=5):
|
|
88
|
+
detail = mem.know(hit.entity_id)
|
|
89
|
+
|
|
90
|
+
# associate — words, and what they connect to
|
|
91
|
+
assoc = mem.associate("bathroom leaks")
|
|
92
|
+
for node in assoc.nodes:
|
|
93
|
+
print(node["hops"], node["entity"]["name"], node["reached_via"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`recall` is `ORDER BY similarity LIMIT top_k` with **no threshold** — it returns
|
|
97
|
+
rows whatever their score, so an empty result means an empty scope rather than a
|
|
98
|
+
failed search, and a returned row is not by itself evidence of a match. It also
|
|
99
|
+
matches on the etch *narrative*, so an exact article number is compared by vector
|
|
100
|
+
similarity, not string equality. When you know the subject, use `know`.
|
|
101
|
+
|
|
102
|
+
`associate` follows declared relations one hop in both directions, so a fault
|
|
103
|
+
reached by wording leads to the part that fixes it — which shares no word with
|
|
104
|
+
the query.
|
|
105
|
+
|
|
66
106
|
### Async
|
|
67
107
|
|
|
68
108
|
```python
|
|
@@ -77,12 +117,14 @@ async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
|
77
117
|
|
|
78
118
|
## API surface
|
|
79
119
|
|
|
80
|
-
The SDK mirrors the
|
|
120
|
+
The SDK mirrors the REST endpoints one-to-one:
|
|
81
121
|
|
|
82
122
|
| Method | Endpoint | Returns |
|
|
83
123
|
|---|---|---|
|
|
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`) |
|
|
124
|
+
| `remember(data, *, source, scope, extract_mode="deferred", metadata=None, occurred_at=None)` | `POST /remember` | `RememberResult` |
|
|
125
|
+
| `recall(query, *, scope=None, source=None, top_k=5, include_signals=True, as_of=None, as_of_basis="ingest")` | `POST /recall` | `RecallResponse` (iterable of `RecallResult`) |
|
|
126
|
+
| `know(ref, *, as_of=None, entity_type=None, as_of_basis="ingest")` | `GET /entity/{ref}/etches` | `Knowledge` (iterable of `Etch`) |
|
|
127
|
+
| `associate(query, *, scope=None, top_k=5, hops=1, as_of=None, min_score=0.15)` | `POST /associate` | `Association` |
|
|
86
128
|
| `sleep()` | `POST /sleep` | `SleepResult` |
|
|
87
129
|
| `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
|
|
88
130
|
| `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
|
|
@@ -95,14 +137,51 @@ All responses are plain dataclasses — see `etchmem/models.py`.
|
|
|
95
137
|
|
|
96
138
|
`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
139
|
|
|
98
|
-
|
|
140
|
+
`occurred_at` (ISO-8601 or epoch seconds) declares when the content actually
|
|
141
|
+
*happened*, as distinct from when you sent it. It defaults to ingest time, and
|
|
142
|
+
**you must set it when loading historical records** — otherwise every signal in
|
|
143
|
+
a backfill carries today's date, the server's recency policy has nothing to
|
|
144
|
+
order by, and a 2009 answer competes with a 2022 answer as an equal.
|
|
99
145
|
|
|
100
|
-
|
|
146
|
+
```python
|
|
147
|
+
res = mem.remember(
|
|
148
|
+
"Modul MSM-0808: NT-2405 is the correct supply up to revision C.",
|
|
149
|
+
source="ticket-resolved", scope="innoxel",
|
|
150
|
+
occurred_at="2014-03-17",
|
|
151
|
+
)
|
|
152
|
+
assert res.occurred_at_declared # the parsed value is echoed back — check it
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### know
|
|
156
|
+
|
|
157
|
+
Every belief about one subject, exhaustive and ordered by property. `ref` is an
|
|
158
|
+
entity id (`product_msm_0808`) or a surface name (`MSM-0808`, `MSM 0808`),
|
|
159
|
+
resolved the way ingestion resolved it, so callers never need the internal id
|
|
160
|
+
format. `Knowledge.settled(min_confidence)` filters to what is safe to state as
|
|
161
|
+
fact; `.contested` counts the beliefs the engine could not settle, which belong
|
|
162
|
+
in an internal note rather than a customer-facing answer.
|
|
163
|
+
|
|
164
|
+
### time travel, and which clock
|
|
165
|
+
|
|
166
|
+
Pass `as_of` (ISO-8601) to `recall`, `know` or `associate`. `as_of_basis` picks
|
|
167
|
+
the clock:
|
|
168
|
+
|
|
169
|
+
| Basis | Question it answers |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `"ingest"` (default) | What did we **believe** then — what would this system have told a customer on that date. The question an audit or a dispute asks. |
|
|
172
|
+
| `"event"` | What was **true** then, by the dates on the underlying facts. |
|
|
173
|
+
|
|
174
|
+
They differ whenever a source reports late — a promotion on the 2nd that reached
|
|
175
|
+
you on the 20th is invisible to ingest-basis on the 10th, visible to event-basis:
|
|
101
176
|
|
|
102
177
|
```python
|
|
103
|
-
|
|
178
|
+
mem.know("john@doe.com", as_of="2026-09-10") # believed
|
|
179
|
+
mem.know("john@doe.com", as_of="2026-09-10", as_of_basis="event") # was true
|
|
104
180
|
```
|
|
105
181
|
|
|
182
|
+
Event-basis is only as good as your `occurred_at` discipline: signals deposited
|
|
183
|
+
without it fall back to ingest time, and both bases then give the same answer.
|
|
184
|
+
|
|
106
185
|
### sleep
|
|
107
186
|
|
|
108
187
|
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:
|
|
@@ -114,11 +193,14 @@ print(tick.etches_formed, tick.etches_updated, tick.contested)
|
|
|
114
193
|
|
|
115
194
|
### history
|
|
116
195
|
|
|
117
|
-
|
|
196
|
+
A version is written only when a belief actually **changes**, so six daily
|
|
197
|
+
enrichment runs with one real change give you two versions, not six. Each
|
|
198
|
+
carries both clocks:
|
|
118
199
|
|
|
119
200
|
```python
|
|
120
201
|
for v in mem.history(etch_id):
|
|
121
202
|
print(v.version, v.current_value, v.status, v.confidence)
|
|
203
|
+
print(" fact true from", v.event_at, "— we learned it", v.created_at)
|
|
122
204
|
```
|
|
123
205
|
|
|
124
206
|
---
|
etchmem-0.3.0/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# etchmem
|
|
2
|
+
|
|
3
|
+
**Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
|
|
4
|
+
|
|
5
|
+
Compatible with **etchmem-server 0.0.4**.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
This package is the REST client. One dependency (`httpx`), sync and async, fully typed responses.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install etchmem
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
> **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.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
Run the server (see the etchmem-server repo — `docker compose up`), then:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from etchmem import EtchMem
|
|
25
|
+
|
|
26
|
+
mem = EtchMem("http://localhost:8000") # default; also reads ETCHMEM_BASE_URL
|
|
27
|
+
|
|
28
|
+
# Deposit a raw signal — no pre-structuring needed
|
|
29
|
+
mem.remember(
|
|
30
|
+
"Acme signed the Q3 renewal at $40k. Maria approved the discount.",
|
|
31
|
+
source="agent-33",
|
|
32
|
+
scope="sales",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Semantic recall over consolidated beliefs
|
|
36
|
+
for hit in mem.recall("what did Acme sign?", top_k=5):
|
|
37
|
+
print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Three ways to ask
|
|
41
|
+
|
|
42
|
+
Which one you want depends on what you already know. Reaching for the wrong one
|
|
43
|
+
is a quiet source of wrong answers.
|
|
44
|
+
|
|
45
|
+
| You know | Method | Returns |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| the subject | `know(ref)` | every belief about it — exhaustive, ordered by property |
|
|
48
|
+
| only words | `recall(query)` | beliefs ranked by wording, `top_k` |
|
|
49
|
+
| words, and want what they connect to | `associate(query)` | the above **plus** each subject's full facts **plus** connected nodes |
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# know — you have the subject
|
|
53
|
+
facts = mem.know("MSM-0808") # id or surface name; variants resolve
|
|
54
|
+
for e in facts.settled(min_confidence=0.6):
|
|
55
|
+
print(e.property, "=", e.current_value)
|
|
56
|
+
print(facts.contested, "open questions")
|
|
57
|
+
|
|
58
|
+
# recall — you only have words. entity_id chains straight into know()
|
|
59
|
+
for hit in mem.recall("bathroom leaks", top_k=5):
|
|
60
|
+
detail = mem.know(hit.entity_id)
|
|
61
|
+
|
|
62
|
+
# associate — words, and what they connect to
|
|
63
|
+
assoc = mem.associate("bathroom leaks")
|
|
64
|
+
for node in assoc.nodes:
|
|
65
|
+
print(node["hops"], node["entity"]["name"], node["reached_via"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`recall` is `ORDER BY similarity LIMIT top_k` with **no threshold** — it returns
|
|
69
|
+
rows whatever their score, so an empty result means an empty scope rather than a
|
|
70
|
+
failed search, and a returned row is not by itself evidence of a match. It also
|
|
71
|
+
matches on the etch *narrative*, so an exact article number is compared by vector
|
|
72
|
+
similarity, not string equality. When you know the subject, use `know`.
|
|
73
|
+
|
|
74
|
+
`associate` follows declared relations one hop in both directions, so a fault
|
|
75
|
+
reached by wording leads to the part that fixes it — which shares no word with
|
|
76
|
+
the query.
|
|
77
|
+
|
|
78
|
+
### Async
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from etchmem import AsyncEtchMem
|
|
82
|
+
|
|
83
|
+
async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
84
|
+
await mem.remember("...", source="agent-33", scope="sales")
|
|
85
|
+
hits = await mem.recall("what did Acme sign?")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## API surface
|
|
91
|
+
|
|
92
|
+
The SDK mirrors the REST endpoints one-to-one:
|
|
93
|
+
|
|
94
|
+
| Method | Endpoint | Returns |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `remember(data, *, source, scope, extract_mode="deferred", metadata=None, occurred_at=None)` | `POST /remember` | `RememberResult` |
|
|
97
|
+
| `recall(query, *, scope=None, source=None, top_k=5, include_signals=True, as_of=None, as_of_basis="ingest")` | `POST /recall` | `RecallResponse` (iterable of `RecallResult`) |
|
|
98
|
+
| `know(ref, *, as_of=None, entity_type=None, as_of_basis="ingest")` | `GET /entity/{ref}/etches` | `Knowledge` (iterable of `Etch`) |
|
|
99
|
+
| `associate(query, *, scope=None, top_k=5, hops=1, as_of=None, min_score=0.15)` | `POST /associate` | `Association` |
|
|
100
|
+
| `sleep()` | `POST /sleep` | `SleepResult` |
|
|
101
|
+
| `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
|
|
102
|
+
| `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
|
|
103
|
+
| `stats()` | `GET /stats` | `Stats` |
|
|
104
|
+
| `health()` | `GET /health` | `Health` |
|
|
105
|
+
|
|
106
|
+
All responses are plain dataclasses — see `etchmem/models.py`.
|
|
107
|
+
|
|
108
|
+
### remember
|
|
109
|
+
|
|
110
|
+
`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`.
|
|
111
|
+
|
|
112
|
+
`occurred_at` (ISO-8601 or epoch seconds) declares when the content actually
|
|
113
|
+
*happened*, as distinct from when you sent it. It defaults to ingest time, and
|
|
114
|
+
**you must set it when loading historical records** — otherwise every signal in
|
|
115
|
+
a backfill carries today's date, the server's recency policy has nothing to
|
|
116
|
+
order by, and a 2009 answer competes with a 2022 answer as an equal.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
res = mem.remember(
|
|
120
|
+
"Modul MSM-0808: NT-2405 is the correct supply up to revision C.",
|
|
121
|
+
source="ticket-resolved", scope="innoxel",
|
|
122
|
+
occurred_at="2014-03-17",
|
|
123
|
+
)
|
|
124
|
+
assert res.occurred_at_declared # the parsed value is echoed back — check it
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### know
|
|
128
|
+
|
|
129
|
+
Every belief about one subject, exhaustive and ordered by property. `ref` is an
|
|
130
|
+
entity id (`product_msm_0808`) or a surface name (`MSM-0808`, `MSM 0808`),
|
|
131
|
+
resolved the way ingestion resolved it, so callers never need the internal id
|
|
132
|
+
format. `Knowledge.settled(min_confidence)` filters to what is safe to state as
|
|
133
|
+
fact; `.contested` counts the beliefs the engine could not settle, which belong
|
|
134
|
+
in an internal note rather than a customer-facing answer.
|
|
135
|
+
|
|
136
|
+
### time travel, and which clock
|
|
137
|
+
|
|
138
|
+
Pass `as_of` (ISO-8601) to `recall`, `know` or `associate`. `as_of_basis` picks
|
|
139
|
+
the clock:
|
|
140
|
+
|
|
141
|
+
| Basis | Question it answers |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `"ingest"` (default) | What did we **believe** then — what would this system have told a customer on that date. The question an audit or a dispute asks. |
|
|
144
|
+
| `"event"` | What was **true** then, by the dates on the underlying facts. |
|
|
145
|
+
|
|
146
|
+
They differ whenever a source reports late — a promotion on the 2nd that reached
|
|
147
|
+
you on the 20th is invisible to ingest-basis on the 10th, visible to event-basis:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
mem.know("john@doe.com", as_of="2026-09-10") # believed
|
|
151
|
+
mem.know("john@doe.com", as_of="2026-09-10", as_of_basis="event") # was true
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Event-basis is only as good as your `occurred_at` discipline: signals deposited
|
|
155
|
+
without it fall back to ingest time, and both bases then give the same answer.
|
|
156
|
+
|
|
157
|
+
### sleep
|
|
158
|
+
|
|
159
|
+
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:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
tick = mem.sleep()
|
|
163
|
+
print(tick.etches_formed, tick.etches_updated, tick.contested)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### history
|
|
167
|
+
|
|
168
|
+
A version is written only when a belief actually **changes**, so six daily
|
|
169
|
+
enrichment runs with one real change give you two versions, not six. Each
|
|
170
|
+
carries both clocks:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
for v in mem.history(etch_id):
|
|
174
|
+
print(v.version, v.current_value, v.status, v.confidence)
|
|
175
|
+
print(" fact true from", v.event_at, "— we learned it", v.created_at)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Configuration
|
|
181
|
+
|
|
182
|
+
| Parameter | Env var | Default |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `base_url` | `ETCHMEM_BASE_URL` | `http://localhost:8000` |
|
|
185
|
+
| `api_key` | `ETCHMEM_API_KEY` | none (sent as `Authorization: Bearer …` when set) |
|
|
186
|
+
| `timeout` | — | 30 s |
|
|
187
|
+
|
|
188
|
+
You can also inject your own `httpx.Client` / `httpx.AsyncClient` via the `client` parameter (custom TLS, proxies, retries).
|
|
189
|
+
|
|
190
|
+
## Errors
|
|
191
|
+
|
|
192
|
+
All SDK errors derive from `EtchMemError`:
|
|
193
|
+
|
|
194
|
+
- `ConnectionError` — server unreachable (network / timeout)
|
|
195
|
+
- `APIError` — non-2xx response (`.status_code`, `.detail`), with subclasses `NotFoundError` (404), `AuthenticationError` (401/403), `ServerError` (5xx)
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from etchmem import EtchMem, NotFoundError
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
mem.history("no-such-etch")
|
|
202
|
+
except NotFoundError as e:
|
|
203
|
+
print(e.detail)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
pip install -e ".[dev]"
|
|
212
|
+
python -m pytest tests/
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Releasing to PyPI: see [PIP-DEPLOY.md](PIP-DEPLOY.md).
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT
|
|
@@ -24,8 +24,8 @@ from etchmem.errors import (
|
|
|
24
24
|
NotFoundError, ServerError,
|
|
25
25
|
)
|
|
26
26
|
from etchmem.models import (
|
|
27
|
-
Etch, EtchVersion, ExportResult, Health, History,
|
|
28
|
-
RecallResult, RememberResult, SleepResult, Stats,
|
|
27
|
+
Association, EntityInfo, Etch, EtchVersion, ExportResult, Health, History,
|
|
28
|
+
Knowledge, RecallResponse, RecallResult, RememberResult, SleepResult, Stats,
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
__all__ = [
|
|
@@ -35,6 +35,9 @@ __all__ = [
|
|
|
35
35
|
"RememberResult",
|
|
36
36
|
"RecallResponse",
|
|
37
37
|
"RecallResult",
|
|
38
|
+
"Association",
|
|
39
|
+
"EntityInfo",
|
|
40
|
+
"Knowledge",
|
|
38
41
|
"SleepResult",
|
|
39
42
|
"ExportResult",
|
|
40
43
|
"Etch",
|
|
@@ -51,4 +54,4 @@ __all__ = [
|
|
|
51
54
|
"ServerError",
|
|
52
55
|
]
|
|
53
56
|
|
|
54
|
-
__version__ = "0.
|
|
57
|
+
__version__ = "0.3.0"
|
|
@@ -63,9 +63,10 @@ class AsyncEtchMem:
|
|
|
63
63
|
# ── internal ───────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
65
|
async def _request(self, method: str, path: str,
|
|
66
|
-
json: dict[str, Any] | None = None
|
|
66
|
+
json: dict[str, Any] | None = None,
|
|
67
|
+
params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
67
68
|
try:
|
|
68
|
-
resp = await self._client.request(method, path, json=json)
|
|
69
|
+
resp = await self._client.request(method, path, json=json, params=params)
|
|
69
70
|
except httpx.HTTPError as e:
|
|
70
71
|
raise ConnectionError(
|
|
71
72
|
f"etchmem server unreachable at {self.base_url}: {e}") from e
|
|
@@ -87,11 +88,13 @@ class AsyncEtchMem:
|
|
|
87
88
|
scope: str,
|
|
88
89
|
extract_mode: str = "deferred",
|
|
89
90
|
metadata: dict[str, Any] | None = None,
|
|
91
|
+
occurred_at: str | float | None = None,
|
|
90
92
|
) -> RememberResult:
|
|
91
93
|
"""Deposit a raw text signal (see :meth:`etchmem.EtchMem.remember`)."""
|
|
92
94
|
payload = await self._request("POST", "/remember", json={
|
|
93
95
|
"data": data, "source": source, "scope": scope,
|
|
94
|
-
"extract_mode": extract_mode, "metadata": metadata or {}
|
|
96
|
+
"extract_mode": extract_mode, "metadata": metadata or {},
|
|
97
|
+
"occurred_at": occurred_at})
|
|
95
98
|
return RememberResult.from_dict(payload)
|
|
96
99
|
|
|
97
100
|
async def recall(
|
|
@@ -103,13 +106,75 @@ class AsyncEtchMem:
|
|
|
103
106
|
top_k: int = 5,
|
|
104
107
|
include_signals: bool = True,
|
|
105
108
|
as_of: str | None = None,
|
|
109
|
+
as_of_basis: str = "ingest",
|
|
106
110
|
) -> RecallResponse:
|
|
107
111
|
"""Semantic recall over etches; ``as_of`` enables time-travel."""
|
|
108
112
|
payload = await self._request("POST", "/recall", json={
|
|
109
113
|
"query": query, "scope": scope, "source": source, "top_k": top_k,
|
|
110
|
-
"include_signals": include_signals, "as_of": as_of
|
|
114
|
+
"include_signals": include_signals, "as_of": as_of,
|
|
115
|
+
"as_of_basis": as_of_basis})
|
|
111
116
|
return RecallResponse.from_dict(payload)
|
|
112
117
|
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def associate(
|
|
122
|
+
self,
|
|
123
|
+
query: str,
|
|
124
|
+
*,
|
|
125
|
+
scope: str | None = None,
|
|
126
|
+
top_k: int = 5,
|
|
127
|
+
hops: int = 1,
|
|
128
|
+
as_of: str | None = None,
|
|
129
|
+
min_score: float = 0.15,
|
|
130
|
+
as_of_basis: str = "ingest",
|
|
131
|
+
) -> Association:
|
|
132
|
+
"""What the memory holds for a phrase.
|
|
133
|
+
|
|
134
|
+
Use when you do NOT already know which entity you are asking about.
|
|
135
|
+
:meth:`recall` ranks beliefs by wording; this also returns every fact
|
|
136
|
+
about each matched subject and follows declared relations one hop, so a
|
|
137
|
+
fault reached by wording leads to the part that fixes it — which
|
|
138
|
+
wording alone never would.
|
|
139
|
+
"""
|
|
140
|
+
payload = await self._request("POST", "/associate", json={
|
|
141
|
+
"query": query, "scope": scope, "top_k": top_k, "hops": hops,
|
|
142
|
+
"as_of": as_of, "min_score": min_score,
|
|
143
|
+
"as_of_basis": as_of_basis})
|
|
144
|
+
return Association.from_dict(payload)
|
|
145
|
+
|
|
146
|
+
async def know(
|
|
147
|
+
self,
|
|
148
|
+
ref: str,
|
|
149
|
+
*,
|
|
150
|
+
as_of: str | None = None,
|
|
151
|
+
entity_type: str | None = None,
|
|
152
|
+
as_of_basis: str = "ingest",
|
|
153
|
+
) -> Knowledge:
|
|
154
|
+
"""EVERY belief about one subject — exhaustive, unlike :meth:`recall`.
|
|
155
|
+
|
|
156
|
+
The counterpart to :meth:`recall`: recall retrieves by cue and may miss,
|
|
157
|
+
``know`` is direct access to everything held about a subject.
|
|
158
|
+
|
|
159
|
+
``recall`` ranks by embedding distance and returns ``top_k``, so a fact
|
|
160
|
+
can be absent from the answer without the caller being able to tell.
|
|
161
|
+
When the question is "what do we know about X", use this.
|
|
162
|
+
|
|
163
|
+
``ref`` is an entity id (``product_msm_0808``) or a surface name
|
|
164
|
+
(``MSM-0808``, ``MSM 0808``), resolved the way ingestion resolved it.
|
|
165
|
+
``as_of`` (ISO-8601) gives the beliefs as they stood then.
|
|
166
|
+
"""
|
|
167
|
+
params: dict[str, Any] = {}
|
|
168
|
+
if as_of:
|
|
169
|
+
params["as_of"] = as_of
|
|
170
|
+
if entity_type:
|
|
171
|
+
params["entity_type"] = entity_type
|
|
172
|
+
if as_of_basis != "ingest":
|
|
173
|
+
params["as_of_basis"] = as_of_basis
|
|
174
|
+
payload = await self._request(
|
|
175
|
+
"GET", f"/entity/{ref}/etches", params=params or None)
|
|
176
|
+
return Knowledge.from_dict(payload)
|
|
177
|
+
|
|
113
178
|
async def sleep(self) -> SleepResult:
|
|
114
179
|
"""Run one consolidation tick now (batch → extract → fold)."""
|
|
115
180
|
return SleepResult.from_dict(await self._request("POST", "/sleep"))
|
|
@@ -72,9 +72,10 @@ class EtchMem:
|
|
|
72
72
|
# ── internal ───────────────────────────────────────────────────────
|
|
73
73
|
|
|
74
74
|
def _request(self, method: str, path: str,
|
|
75
|
-
json: dict[str, Any] | None = None
|
|
75
|
+
json: dict[str, Any] | None = None,
|
|
76
|
+
params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
76
77
|
try:
|
|
77
|
-
resp = self._client.request(method, path, json=json)
|
|
78
|
+
resp = self._client.request(method, path, json=json, params=params)
|
|
78
79
|
except httpx.HTTPError as e:
|
|
79
80
|
raise ConnectionError(
|
|
80
81
|
f"etchmem server unreachable at {self.base_url}: {e}") from e
|
|
@@ -96,16 +97,24 @@ class EtchMem:
|
|
|
96
97
|
scope: str,
|
|
97
98
|
extract_mode: str = "deferred",
|
|
98
99
|
metadata: dict[str, Any] | None = None,
|
|
100
|
+
occurred_at: str | float | None = None,
|
|
99
101
|
) -> RememberResult:
|
|
100
102
|
"""Deposit a raw text signal.
|
|
101
103
|
|
|
102
104
|
``extract_mode='immediate'`` marks the signal urgent (extracted on
|
|
103
105
|
the next worker tick); ``'deferred'`` (default) batches it cheaply.
|
|
104
106
|
Idempotent: re-depositing identical content returns ``stored=False``.
|
|
107
|
+
|
|
108
|
+
``occurred_at`` (ISO-8601 or epoch seconds) declares when the content
|
|
109
|
+
actually happened; it defaults to ingest time. Always set it when
|
|
110
|
+
loading historical records — otherwise every signal carries today's
|
|
111
|
+
date and the server cannot order them against each other. The result
|
|
112
|
+
echoes the parsed value so a bulk load can verify it was understood.
|
|
105
113
|
"""
|
|
106
114
|
payload = self._request("POST", "/remember", json={
|
|
107
115
|
"data": data, "source": source, "scope": scope,
|
|
108
|
-
"extract_mode": extract_mode, "metadata": metadata or {}
|
|
116
|
+
"extract_mode": extract_mode, "metadata": metadata or {},
|
|
117
|
+
"occurred_at": occurred_at})
|
|
109
118
|
return RememberResult.from_dict(payload)
|
|
110
119
|
|
|
111
120
|
def recall(
|
|
@@ -117,6 +126,7 @@ class EtchMem:
|
|
|
117
126
|
top_k: int = 5,
|
|
118
127
|
include_signals: bool = True,
|
|
119
128
|
as_of: str | None = None,
|
|
129
|
+
as_of_basis: str = "ingest",
|
|
120
130
|
) -> RecallResponse:
|
|
121
131
|
"""Semantic recall over consolidated etches (plus fresh raw signals).
|
|
122
132
|
|
|
@@ -125,9 +135,70 @@ class EtchMem:
|
|
|
125
135
|
"""
|
|
126
136
|
payload = self._request("POST", "/recall", json={
|
|
127
137
|
"query": query, "scope": scope, "source": source, "top_k": top_k,
|
|
128
|
-
"include_signals": include_signals, "as_of": as_of
|
|
138
|
+
"include_signals": include_signals, "as_of": as_of,
|
|
139
|
+
"as_of_basis": as_of_basis})
|
|
129
140
|
return RecallResponse.from_dict(payload)
|
|
130
141
|
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def associate(
|
|
146
|
+
self,
|
|
147
|
+
query: str,
|
|
148
|
+
*,
|
|
149
|
+
scope: str | None = None,
|
|
150
|
+
top_k: int = 5,
|
|
151
|
+
hops: int = 1,
|
|
152
|
+
as_of: str | None = None,
|
|
153
|
+
min_score: float = 0.15,
|
|
154
|
+
as_of_basis: str = "ingest",
|
|
155
|
+
) -> Association:
|
|
156
|
+
"""What the memory holds for a phrase.
|
|
157
|
+
|
|
158
|
+
Use when you do NOT already know which entity you are asking about.
|
|
159
|
+
:meth:`recall` ranks beliefs by wording; this also returns every fact
|
|
160
|
+
about each matched subject and follows declared relations one hop, so a
|
|
161
|
+
fault reached by wording leads to the part that fixes it — which
|
|
162
|
+
wording alone never would.
|
|
163
|
+
"""
|
|
164
|
+
payload = self._request("POST", "/associate", json={
|
|
165
|
+
"query": query, "scope": scope, "top_k": top_k, "hops": hops,
|
|
166
|
+
"as_of": as_of, "min_score": min_score,
|
|
167
|
+
"as_of_basis": as_of_basis})
|
|
168
|
+
return Association.from_dict(payload)
|
|
169
|
+
|
|
170
|
+
def know(
|
|
171
|
+
self,
|
|
172
|
+
ref: str,
|
|
173
|
+
*,
|
|
174
|
+
as_of: str | None = None,
|
|
175
|
+
entity_type: str | None = None,
|
|
176
|
+
as_of_basis: str = "ingest",
|
|
177
|
+
) -> Knowledge:
|
|
178
|
+
"""EVERY belief about one subject — exhaustive, unlike :meth:`recall`.
|
|
179
|
+
|
|
180
|
+
The counterpart to :meth:`recall`: recall retrieves by cue and may miss,
|
|
181
|
+
``know`` is direct access to everything held about a subject.
|
|
182
|
+
|
|
183
|
+
``recall`` ranks by embedding distance and returns ``top_k``, so a fact
|
|
184
|
+
can be absent from the answer without the caller being able to tell.
|
|
185
|
+
When the question is "what do we know about X", use this.
|
|
186
|
+
|
|
187
|
+
``ref`` is an entity id (``product_msm_0808``) or a surface name
|
|
188
|
+
(``MSM-0808``, ``MSM 0808``), resolved the way ingestion resolved it.
|
|
189
|
+
``as_of`` (ISO-8601) gives the beliefs as they stood then.
|
|
190
|
+
"""
|
|
191
|
+
params: dict[str, Any] = {}
|
|
192
|
+
if as_of:
|
|
193
|
+
params["as_of"] = as_of
|
|
194
|
+
if entity_type:
|
|
195
|
+
params["entity_type"] = entity_type
|
|
196
|
+
if as_of_basis != "ingest":
|
|
197
|
+
params["as_of_basis"] = as_of_basis
|
|
198
|
+
payload = self._request(
|
|
199
|
+
"GET", f"/entity/{ref}/etches", params=params or None)
|
|
200
|
+
return Knowledge.from_dict(payload)
|
|
201
|
+
|
|
131
202
|
def sleep(self) -> SleepResult:
|
|
132
203
|
"""Run one consolidation tick now (batch → extract → fold)."""
|
|
133
204
|
return SleepResult.from_dict(self._request("POST", "/sleep"))
|
|
@@ -16,11 +16,84 @@ class RememberResult:
|
|
|
16
16
|
stored: bool
|
|
17
17
|
status: str # "new" | "duplicate"
|
|
18
18
|
message: str
|
|
19
|
+
occurred_at: float = 0.0 # event time as stored, epoch seconds
|
|
20
|
+
occurred_at_declared: bool = False # False = ingest time was used
|
|
19
21
|
|
|
20
22
|
@classmethod
|
|
21
23
|
def from_dict(cls, d: dict[str, Any]) -> "RememberResult":
|
|
22
24
|
return cls(id=d["id"], stored=d["stored"], status=d["status"],
|
|
23
|
-
message=d.get("message", "")
|
|
25
|
+
message=d.get("message", ""),
|
|
26
|
+
occurred_at=d.get("occurred_at", 0.0) or 0.0,
|
|
27
|
+
occurred_at_declared=bool(d.get("occurred_at_declared", False)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Association:
|
|
32
|
+
"""What the memory holds for a phrase: beliefs, subjects, connected nodes."""
|
|
33
|
+
query: str
|
|
34
|
+
seeds: list[Any]
|
|
35
|
+
nodes: list[Any]
|
|
36
|
+
edges: list[Any]
|
|
37
|
+
contested: int = 0
|
|
38
|
+
as_of: str | None = None
|
|
39
|
+
|
|
40
|
+
def named(self, *types: str) -> list[Any]:
|
|
41
|
+
"""Nodes of these entity types, closest first."""
|
|
42
|
+
return [n for n in self.nodes if not types or n["entity"]["type"] in types]
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, d: dict[str, Any]) -> "Association":
|
|
46
|
+
return cls(query=d["query"], seeds=d.get("seeds") or [],
|
|
47
|
+
nodes=d.get("nodes") or [], edges=d.get("edges") or [],
|
|
48
|
+
contested=d.get("contested", 0), as_of=d.get("as_of"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class EntityInfo:
|
|
53
|
+
"""The canonical subject an Knowledge answer is about."""
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
type: str
|
|
57
|
+
aliases: list[str]
|
|
58
|
+
scope: str | None = None
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, d: dict[str, Any]) -> "EntityInfo":
|
|
62
|
+
return cls(id=d["id"], name=d["name"], type=d["type"],
|
|
63
|
+
aliases=list(d.get("aliases") or []), scope=d.get("scope"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Knowledge:
|
|
68
|
+
"""Every belief about one subject — exhaustive, not similarity-ranked."""
|
|
69
|
+
entity: EntityInfo
|
|
70
|
+
etches: list["Etch"]
|
|
71
|
+
count: int = 0
|
|
72
|
+
as_of: str | None = None
|
|
73
|
+
contested: int = 0
|
|
74
|
+
|
|
75
|
+
def __iter__(self):
|
|
76
|
+
return iter(self.etches)
|
|
77
|
+
|
|
78
|
+
def __len__(self) -> int:
|
|
79
|
+
return len(self.etches)
|
|
80
|
+
|
|
81
|
+
def settled(self, min_confidence: float = 0.0) -> list["Etch"]:
|
|
82
|
+
"""Only beliefs safe to state as fact.
|
|
83
|
+
|
|
84
|
+
A contested belief is one the engine could not settle; surfacing it as
|
|
85
|
+
an answer is the failure this whole design exists to prevent. Use the
|
|
86
|
+
contested ones to raise a question instead.
|
|
87
|
+
"""
|
|
88
|
+
return [e for e in self.etches
|
|
89
|
+
if e.status == "settled" and e.confidence >= min_confidence]
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, d: dict[str, Any]) -> "Knowledge":
|
|
93
|
+
return cls(entity=EntityInfo.from_dict(d["entity"]),
|
|
94
|
+
etches=[Etch.from_dict(e) for e in d.get("etches") or []],
|
|
95
|
+
count=d.get("count", 0), as_of=d.get("as_of"),
|
|
96
|
+
contested=d.get("contested", 0))
|
|
24
97
|
|
|
25
98
|
|
|
26
99
|
@dataclass
|
|
@@ -30,6 +103,7 @@ class RecallResult:
|
|
|
30
103
|
content: str
|
|
31
104
|
score: float
|
|
32
105
|
origin: str # "etch" | "signal"
|
|
106
|
+
entity_id: str | None = None # subject slug — pass straight to know()
|
|
33
107
|
entity_name: str | None = None
|
|
34
108
|
property: str | None = None
|
|
35
109
|
value: str | None = None
|
|
@@ -44,7 +118,7 @@ class RecallResult:
|
|
|
44
118
|
@classmethod
|
|
45
119
|
def from_dict(cls, d: dict[str, Any]) -> "RecallResult":
|
|
46
120
|
return cls(**{k: d.get(k) for k in (
|
|
47
|
-
"id", "content", "score", "origin", "entity_name", "property",
|
|
121
|
+
"id", "content", "score", "origin", "entity_id", "entity_name", "property",
|
|
48
122
|
"value", "status", "confidence", "version", "scope", "source",
|
|
49
123
|
"updated_at")} | {"created_at": d.get("created_at", 0.0)})
|
|
50
124
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etchmem
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python SDK for etchmem-server — the Knowledge Consolidation Engine for AI agents
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for etchmem-server 0.0.4 — the Knowledge Consolidation Engine for AI agents
|
|
5
5
|
Author: Andrey Olishchuk
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Repository, https://github.com/vossmoos/etchmem-sdk-python
|
|
@@ -30,6 +30,8 @@ Dynamic: license-file
|
|
|
30
30
|
|
|
31
31
|
**Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
|
|
32
32
|
|
|
33
|
+
Compatible with **etchmem-server 0.0.4**.
|
|
34
|
+
|
|
33
35
|
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
36
|
|
|
35
37
|
This package is the REST client. One dependency (`httpx`), sync and async, fully typed responses.
|
|
@@ -63,6 +65,44 @@ for hit in mem.recall("what did Acme sign?", top_k=5):
|
|
|
63
65
|
print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
### Three ways to ask
|
|
69
|
+
|
|
70
|
+
Which one you want depends on what you already know. Reaching for the wrong one
|
|
71
|
+
is a quiet source of wrong answers.
|
|
72
|
+
|
|
73
|
+
| You know | Method | Returns |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| the subject | `know(ref)` | every belief about it — exhaustive, ordered by property |
|
|
76
|
+
| only words | `recall(query)` | beliefs ranked by wording, `top_k` |
|
|
77
|
+
| words, and want what they connect to | `associate(query)` | the above **plus** each subject's full facts **plus** connected nodes |
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# know — you have the subject
|
|
81
|
+
facts = mem.know("MSM-0808") # id or surface name; variants resolve
|
|
82
|
+
for e in facts.settled(min_confidence=0.6):
|
|
83
|
+
print(e.property, "=", e.current_value)
|
|
84
|
+
print(facts.contested, "open questions")
|
|
85
|
+
|
|
86
|
+
# recall — you only have words. entity_id chains straight into know()
|
|
87
|
+
for hit in mem.recall("bathroom leaks", top_k=5):
|
|
88
|
+
detail = mem.know(hit.entity_id)
|
|
89
|
+
|
|
90
|
+
# associate — words, and what they connect to
|
|
91
|
+
assoc = mem.associate("bathroom leaks")
|
|
92
|
+
for node in assoc.nodes:
|
|
93
|
+
print(node["hops"], node["entity"]["name"], node["reached_via"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`recall` is `ORDER BY similarity LIMIT top_k` with **no threshold** — it returns
|
|
97
|
+
rows whatever their score, so an empty result means an empty scope rather than a
|
|
98
|
+
failed search, and a returned row is not by itself evidence of a match. It also
|
|
99
|
+
matches on the etch *narrative*, so an exact article number is compared by vector
|
|
100
|
+
similarity, not string equality. When you know the subject, use `know`.
|
|
101
|
+
|
|
102
|
+
`associate` follows declared relations one hop in both directions, so a fault
|
|
103
|
+
reached by wording leads to the part that fixes it — which shares no word with
|
|
104
|
+
the query.
|
|
105
|
+
|
|
66
106
|
### Async
|
|
67
107
|
|
|
68
108
|
```python
|
|
@@ -77,12 +117,14 @@ async with AsyncEtchMem("http://localhost:8000") as mem:
|
|
|
77
117
|
|
|
78
118
|
## API surface
|
|
79
119
|
|
|
80
|
-
The SDK mirrors the
|
|
120
|
+
The SDK mirrors the REST endpoints one-to-one:
|
|
81
121
|
|
|
82
122
|
| Method | Endpoint | Returns |
|
|
83
123
|
|---|---|---|
|
|
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`) |
|
|
124
|
+
| `remember(data, *, source, scope, extract_mode="deferred", metadata=None, occurred_at=None)` | `POST /remember` | `RememberResult` |
|
|
125
|
+
| `recall(query, *, scope=None, source=None, top_k=5, include_signals=True, as_of=None, as_of_basis="ingest")` | `POST /recall` | `RecallResponse` (iterable of `RecallResult`) |
|
|
126
|
+
| `know(ref, *, as_of=None, entity_type=None, as_of_basis="ingest")` | `GET /entity/{ref}/etches` | `Knowledge` (iterable of `Etch`) |
|
|
127
|
+
| `associate(query, *, scope=None, top_k=5, hops=1, as_of=None, min_score=0.15)` | `POST /associate` | `Association` |
|
|
86
128
|
| `sleep()` | `POST /sleep` | `SleepResult` |
|
|
87
129
|
| `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
|
|
88
130
|
| `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
|
|
@@ -95,14 +137,51 @@ All responses are plain dataclasses — see `etchmem/models.py`.
|
|
|
95
137
|
|
|
96
138
|
`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
139
|
|
|
98
|
-
|
|
140
|
+
`occurred_at` (ISO-8601 or epoch seconds) declares when the content actually
|
|
141
|
+
*happened*, as distinct from when you sent it. It defaults to ingest time, and
|
|
142
|
+
**you must set it when loading historical records** — otherwise every signal in
|
|
143
|
+
a backfill carries today's date, the server's recency policy has nothing to
|
|
144
|
+
order by, and a 2009 answer competes with a 2022 answer as an equal.
|
|
99
145
|
|
|
100
|
-
|
|
146
|
+
```python
|
|
147
|
+
res = mem.remember(
|
|
148
|
+
"Modul MSM-0808: NT-2405 is the correct supply up to revision C.",
|
|
149
|
+
source="ticket-resolved", scope="innoxel",
|
|
150
|
+
occurred_at="2014-03-17",
|
|
151
|
+
)
|
|
152
|
+
assert res.occurred_at_declared # the parsed value is echoed back — check it
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### know
|
|
156
|
+
|
|
157
|
+
Every belief about one subject, exhaustive and ordered by property. `ref` is an
|
|
158
|
+
entity id (`product_msm_0808`) or a surface name (`MSM-0808`, `MSM 0808`),
|
|
159
|
+
resolved the way ingestion resolved it, so callers never need the internal id
|
|
160
|
+
format. `Knowledge.settled(min_confidence)` filters to what is safe to state as
|
|
161
|
+
fact; `.contested` counts the beliefs the engine could not settle, which belong
|
|
162
|
+
in an internal note rather than a customer-facing answer.
|
|
163
|
+
|
|
164
|
+
### time travel, and which clock
|
|
165
|
+
|
|
166
|
+
Pass `as_of` (ISO-8601) to `recall`, `know` or `associate`. `as_of_basis` picks
|
|
167
|
+
the clock:
|
|
168
|
+
|
|
169
|
+
| Basis | Question it answers |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `"ingest"` (default) | What did we **believe** then — what would this system have told a customer on that date. The question an audit or a dispute asks. |
|
|
172
|
+
| `"event"` | What was **true** then, by the dates on the underlying facts. |
|
|
173
|
+
|
|
174
|
+
They differ whenever a source reports late — a promotion on the 2nd that reached
|
|
175
|
+
you on the 20th is invisible to ingest-basis on the 10th, visible to event-basis:
|
|
101
176
|
|
|
102
177
|
```python
|
|
103
|
-
|
|
178
|
+
mem.know("john@doe.com", as_of="2026-09-10") # believed
|
|
179
|
+
mem.know("john@doe.com", as_of="2026-09-10", as_of_basis="event") # was true
|
|
104
180
|
```
|
|
105
181
|
|
|
182
|
+
Event-basis is only as good as your `occurred_at` discipline: signals deposited
|
|
183
|
+
without it fall back to ingest time, and both bases then give the same answer.
|
|
184
|
+
|
|
106
185
|
### sleep
|
|
107
186
|
|
|
108
187
|
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:
|
|
@@ -114,11 +193,14 @@ print(tick.etches_formed, tick.etches_updated, tick.contested)
|
|
|
114
193
|
|
|
115
194
|
### history
|
|
116
195
|
|
|
117
|
-
|
|
196
|
+
A version is written only when a belief actually **changes**, so six daily
|
|
197
|
+
enrichment runs with one real change give you two versions, not six. Each
|
|
198
|
+
carries both clocks:
|
|
118
199
|
|
|
119
200
|
```python
|
|
120
201
|
for v in mem.history(etch_id):
|
|
121
202
|
print(v.version, v.current_value, v.status, v.confidence)
|
|
203
|
+
print(" fact true from", v.event_at, "— we learned it", v.created_at)
|
|
122
204
|
```
|
|
123
205
|
|
|
124
206
|
---
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "etchmem"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Python SDK for etchmem-server — the Knowledge Consolidation Engine for AI agents"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Python SDK for etchmem-server 0.0.4 — the Knowledge Consolidation Engine for AI agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.11"
|
etchmem-0.2.1/README.md
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|