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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etchmem
3
- Version: 0.2.1
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 seven REST endpoints one-to-one:
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
- ### recall with time-travel
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
- Pass `as_of` (ISO-8601) to recall what the system believed at that moment — for auditing decisions or incident forensics:
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
- hits = mem.recall("Acme contract value", as_of="2026-06-01T00:00:00Z")
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
- Every belief change writes an immutable version snapshot:
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
  ---
@@ -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, RecallResponse,
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.2.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) -> dict[str, Any]:
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) -> dict[str, Any]:
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.2.1
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 seven REST endpoints one-to-one:
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
- ### recall with time-travel
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
- Pass `as_of` (ISO-8601) to recall what the system believed at that moment — for auditing decisions or incident forensics:
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
- hits = mem.recall("Acme contract value", as_of="2026-06-01T00:00:00Z")
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
- Every belief change writes an immutable version snapshot:
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.2.1"
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