etchmem 0.3.0__tar.gz → 0.4.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.3.0
4
- Summary: Python SDK for etchmem-server 0.0.4 — the Knowledge Consolidation Engine for AI agents
3
+ Version: 0.4.0
4
+ Summary: Python SDK for etchmem-server 0.0.10 — 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,7 +30,9 @@ 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**.
33
+ Compatible with **etchmem-server 0.0.10** (`aolishchuk/etchmem:0.0.10`). Fields added
34
+ after 0.0.4 (`details`, `evidence`, `event_at`, `values`, …) simply stay empty against
35
+ older servers.
34
36
 
35
37
  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.
36
38
 
@@ -65,7 +67,7 @@ for hit in mem.recall("what did Acme sign?", top_k=5):
65
67
  print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
66
68
  ```
67
69
 
68
- ### Three ways to ask
70
+ ### Four ways to ask
69
71
 
70
72
  Which one you want depends on what you already know. Reaching for the wrong one
71
73
  is a quiet source of wrong answers.
@@ -73,6 +75,7 @@ is a quiet source of wrong answers.
73
75
  | You know | Method | Returns |
74
76
  |---|---|---|
75
77
  | the subject | `know(ref)` | every belief about it — exhaustive, ordered by property |
78
+ | the kind of belief (scope, property, subject type) | `etches(...)` | every matching belief across subjects — exhaustive, paged |
76
79
  | only words | `recall(query)` | beliefs ranked by wording, `top_k` |
77
80
  | words, and want what they connect to | `associate(query)` | the above **plus** each subject's full facts **plus** connected nodes |
78
81
 
@@ -83,6 +86,12 @@ for e in facts.settled(min_confidence=0.6):
83
86
  print(e.property, "=", e.current_value)
84
87
  print(facts.contested, "open questions")
85
88
 
89
+ # etches — you know the kind of belief: every QA rule, across all subjects
90
+ for e in mem.iter_etches(scope="content-qa", property="qa_rejection",
91
+ min_confidence=0.8):
92
+ for d in e.details: # label + the author's full wording
93
+ print(e.entity_name, "·", d.value, "—", d.detail)
94
+
86
95
  # recall — you only have words. entity_id chains straight into know()
87
96
  for hit in mem.recall("bathroom leaks", top_k=5):
88
97
  detail = mem.know(hit.entity_id)
@@ -124,10 +133,13 @@ The SDK mirrors the REST endpoints one-to-one:
124
133
  | `remember(data, *, source, scope, extract_mode="deferred", metadata=None, occurred_at=None)` | `POST /remember` | `RememberResult` |
125
134
  | `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
135
  | `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` |
136
+ | `associate(query, *, scope=None, top_k=5, hops=1, as_of=None, min_score=0.15, as_of_basis="ingest")` | `POST /associate` | `Association` |
137
+ | `etches(*, scope=None, property=None, entity_type=None, status=None, min_confidence=None, limit=500, offset=0)` | `GET /etches` | `EtchList` (one page of `Etch`; `.total`, `.has_more`) |
138
+ | `iter_etches(*, page_size=500, **filters)` | `GET /etches` (all pages) | iterator of `Etch` |
128
139
  | `sleep()` | `POST /sleep` | `SleepResult` |
129
140
  | `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
130
141
  | `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
142
+ | `dossier(etch_id)` | `GET /etch/{id}/dossier` | `Dossier` (etch + versions + claims + raw signals) |
131
143
  | `stats()` | `GET /stats` | `Stats` |
132
144
  | `health()` | `GET /health` | `Health` |
133
145
 
@@ -203,6 +215,41 @@ for v in mem.history(etch_id):
203
215
  print(" fact true from", v.event_at, "— we learned it", v.created_at)
204
216
  ```
205
217
 
218
+ ### retracted values
219
+
220
+ From server 0.0.10 a withdrawn fact ("we no longer need CSV export") is kept, not
221
+ deleted. `current_value` lists only what is still in force; `values` lists every value
222
+ ever asserted, heaviest first, with `active`, `weight`, `last_asserted_at` and
223
+ `retracted_at`:
224
+
225
+ ```python
226
+ for e in mem.know("Acme"):
227
+ print(e.property, "=", e.current_value)
228
+ for v in e.retracted: # no longer valid, but once was
229
+ print(" withdrawn:", v.value, "at", v.retracted_at)
230
+ ```
231
+
232
+ ### evidence and dossier
233
+
234
+ `confidence` is built from who said it (source trust declared in the server's
235
+ `ext/*.yaml`) and how many independent sources agree. Every etch and recall hit
236
+ carries the `evidence` behind the number, so you can apply your own rule:
237
+
238
+ ```python
239
+ for hit in mem.recall(paragraph, scope="content-qa", include_signals=False):
240
+ if hit.evidence and hit.evidence.max_trust >= 0.8:
241
+ print(hit.value, [s.source for s in hit.evidence.sources])
242
+ ```
243
+
244
+ `dossier(etch_id)` answers "why does the agent believe that?" in one call: the
245
+ etch, its versions, the claims that formed it (with corroboration counts) and the
246
+ raw signals behind them. Raw signals are withheld (`signals_omitted=True`) when
247
+ the server runs with claims anonymization.
248
+
249
+ `sleep()` also reports `subject_retries`, `relations_resolved` and the
250
+ `entities` resolution counters; `stats().sources_without_trust` lists deposit
251
+ sources nobody declared a trust for.
252
+
206
253
  ---
207
254
 
208
255
  ## Configuration
@@ -2,7 +2,9 @@
2
2
 
3
3
  **Python SDK for [etchmem-server](https://etchmem.io) — the Knowledge Consolidation Engine for AI agents.**
4
4
 
5
- Compatible with **etchmem-server 0.0.4**.
5
+ Compatible with **etchmem-server 0.0.10** (`aolishchuk/etchmem:0.0.10`). Fields added
6
+ after 0.0.4 (`details`, `evidence`, `event_at`, `values`, …) simply stay empty against
7
+ older servers.
6
8
 
7
9
  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
10
 
@@ -37,7 +39,7 @@ for hit in mem.recall("what did Acme sign?", top_k=5):
37
39
  print(f"[{hit.origin}] {hit.content} (confidence={hit.confidence})")
38
40
  ```
39
41
 
40
- ### Three ways to ask
42
+ ### Four ways to ask
41
43
 
42
44
  Which one you want depends on what you already know. Reaching for the wrong one
43
45
  is a quiet source of wrong answers.
@@ -45,6 +47,7 @@ is a quiet source of wrong answers.
45
47
  | You know | Method | Returns |
46
48
  |---|---|---|
47
49
  | the subject | `know(ref)` | every belief about it — exhaustive, ordered by property |
50
+ | the kind of belief (scope, property, subject type) | `etches(...)` | every matching belief across subjects — exhaustive, paged |
48
51
  | only words | `recall(query)` | beliefs ranked by wording, `top_k` |
49
52
  | words, and want what they connect to | `associate(query)` | the above **plus** each subject's full facts **plus** connected nodes |
50
53
 
@@ -55,6 +58,12 @@ for e in facts.settled(min_confidence=0.6):
55
58
  print(e.property, "=", e.current_value)
56
59
  print(facts.contested, "open questions")
57
60
 
61
+ # etches — you know the kind of belief: every QA rule, across all subjects
62
+ for e in mem.iter_etches(scope="content-qa", property="qa_rejection",
63
+ min_confidence=0.8):
64
+ for d in e.details: # label + the author's full wording
65
+ print(e.entity_name, "·", d.value, "—", d.detail)
66
+
58
67
  # recall — you only have words. entity_id chains straight into know()
59
68
  for hit in mem.recall("bathroom leaks", top_k=5):
60
69
  detail = mem.know(hit.entity_id)
@@ -96,10 +105,13 @@ The SDK mirrors the REST endpoints one-to-one:
96
105
  | `remember(data, *, source, scope, extract_mode="deferred", metadata=None, occurred_at=None)` | `POST /remember` | `RememberResult` |
97
106
  | `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
107
  | `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` |
108
+ | `associate(query, *, scope=None, top_k=5, hops=1, as_of=None, min_score=0.15, as_of_basis="ingest")` | `POST /associate` | `Association` |
109
+ | `etches(*, scope=None, property=None, entity_type=None, status=None, min_confidence=None, limit=500, offset=0)` | `GET /etches` | `EtchList` (one page of `Etch`; `.total`, `.has_more`) |
110
+ | `iter_etches(*, page_size=500, **filters)` | `GET /etches` (all pages) | iterator of `Etch` |
100
111
  | `sleep()` | `POST /sleep` | `SleepResult` |
101
112
  | `export()` | `POST /export` | `ExportResult` (list of `Etch`) |
102
113
  | `history(etch_id)` | `GET /etch/{id}/history` | `History` (iterable of `EtchVersion`) |
114
+ | `dossier(etch_id)` | `GET /etch/{id}/dossier` | `Dossier` (etch + versions + claims + raw signals) |
103
115
  | `stats()` | `GET /stats` | `Stats` |
104
116
  | `health()` | `GET /health` | `Health` |
105
117
 
@@ -175,6 +187,41 @@ for v in mem.history(etch_id):
175
187
  print(" fact true from", v.event_at, "— we learned it", v.created_at)
176
188
  ```
177
189
 
190
+ ### retracted values
191
+
192
+ From server 0.0.10 a withdrawn fact ("we no longer need CSV export") is kept, not
193
+ deleted. `current_value` lists only what is still in force; `values` lists every value
194
+ ever asserted, heaviest first, with `active`, `weight`, `last_asserted_at` and
195
+ `retracted_at`:
196
+
197
+ ```python
198
+ for e in mem.know("Acme"):
199
+ print(e.property, "=", e.current_value)
200
+ for v in e.retracted: # no longer valid, but once was
201
+ print(" withdrawn:", v.value, "at", v.retracted_at)
202
+ ```
203
+
204
+ ### evidence and dossier
205
+
206
+ `confidence` is built from who said it (source trust declared in the server's
207
+ `ext/*.yaml`) and how many independent sources agree. Every etch and recall hit
208
+ carries the `evidence` behind the number, so you can apply your own rule:
209
+
210
+ ```python
211
+ for hit in mem.recall(paragraph, scope="content-qa", include_signals=False):
212
+ if hit.evidence and hit.evidence.max_trust >= 0.8:
213
+ print(hit.value, [s.source for s in hit.evidence.sources])
214
+ ```
215
+
216
+ `dossier(etch_id)` answers "why does the agent believe that?" in one call: the
217
+ etch, its versions, the claims that formed it (with corroboration counts) and the
218
+ raw signals behind them. Raw signals are withheld (`signals_omitted=True`) when
219
+ the server runs with claims anonymization.
220
+
221
+ `sleep()` also reports `subject_retries`, `relations_resolved` and the
222
+ `entities` resolution counters; `stats().sources_without_trust` lists deposit
223
+ sources nobody declared a trust for.
224
+
178
225
  ---
179
226
 
180
227
  ## Configuration
@@ -24,8 +24,10 @@ from etchmem.errors import (
24
24
  NotFoundError, ServerError,
25
25
  )
26
26
  from etchmem.models import (
27
- Association, EntityInfo, Etch, EtchVersion, ExportResult, Health, History,
28
- Knowledge, RecallResponse, RecallResult, RememberResult, SleepResult, Stats,
27
+ Association, Claim, DetailItem, Dossier, EntityInfo, Etch, EtchList,
28
+ EtchVersion, Evidence, ExportResult, Health, History, Knowledge,
29
+ RecallResponse, RecallResult, RememberResult, Signal, SleepResult,
30
+ SourceEvidence, Stats, ValueWeight,
29
31
  )
30
32
 
31
33
  __all__ = [
@@ -41,8 +43,16 @@ __all__ = [
41
43
  "SleepResult",
42
44
  "ExportResult",
43
45
  "Etch",
46
+ "EtchList",
44
47
  "EtchVersion",
45
48
  "History",
49
+ "Dossier",
50
+ "Claim",
51
+ "Signal",
52
+ "DetailItem",
53
+ "Evidence",
54
+ "SourceEvidence",
55
+ "ValueWeight",
46
56
  "Stats",
47
57
  "Health",
48
58
  # errors
@@ -54,4 +64,4 @@ __all__ = [
54
64
  "ServerError",
55
65
  ]
56
66
 
57
- __version__ = "0.3.0"
67
+ __version__ = "0.4.0"
@@ -2,15 +2,18 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
+ from collections.abc import AsyncIterator, Sequence
5
6
  from typing import Any
6
7
 
7
8
  import httpx
8
9
 
9
- from etchmem.client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
10
+ from etchmem.client import (
11
+ DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _etch_filters, _ref_path,
12
+ )
10
13
  from etchmem.errors import ConnectionError, error_for_status
11
14
  from etchmem.models import (
12
- ExportResult, Health, History, RecallResponse, RememberResult,
13
- SleepResult, Stats,
15
+ Association, Dossier, Etch, EtchList, ExportResult, Health, History,
16
+ Knowledge, RecallResponse, RememberResult, SleepResult, Stats,
14
17
  )
15
18
 
16
19
 
@@ -115,9 +118,6 @@ class AsyncEtchMem:
115
118
  "as_of_basis": as_of_basis})
116
119
  return RecallResponse.from_dict(payload)
117
120
 
118
-
119
-
120
-
121
121
  async def associate(
122
122
  self,
123
123
  query: str,
@@ -172,9 +172,43 @@ class AsyncEtchMem:
172
172
  if as_of_basis != "ingest":
173
173
  params["as_of_basis"] = as_of_basis
174
174
  payload = await self._request(
175
- "GET", f"/entity/{ref}/etches", params=params or None)
175
+ "GET", f"/entity/{_ref_path(ref)}/etches", params=params or None)
176
176
  return Knowledge.from_dict(payload)
177
177
 
178
+ async def etches(
179
+ self,
180
+ *,
181
+ scope: str | None = None,
182
+ property: str | Sequence[str] | None = None,
183
+ entity_type: str | Sequence[str] | None = None,
184
+ status: str | None = None,
185
+ min_confidence: float | None = None,
186
+ limit: int = 500,
187
+ offset: int = 0,
188
+ ) -> EtchList:
189
+ """EVERY belief matching plain filters — exhaustive, not ranked.
190
+
191
+ Use when you know the *kind* of belief rather than the subject or the
192
+ words: "all qa_rejection rules in scope content-qa", "every contested
193
+ lifecycle_status". ``property`` and ``entity_type`` take one value or a
194
+ list. Returns one page (newest first); ``total`` counts every match, so
195
+ page with ``offset`` or use :meth:`iter_etches`.
196
+ """
197
+ return EtchList.from_dict(await self._request(
198
+ "GET", "/etches", params=_etch_filters(
199
+ scope, property, entity_type, status, min_confidence, limit, offset)))
200
+
201
+ async def iter_etches(self, *, page_size: int = 500, **filters: Any) -> AsyncIterator[Etch]:
202
+ """Every belief matching the filters of :meth:`etches`, across all pages."""
203
+ offset = 0
204
+ while True:
205
+ page = await self.etches(limit=page_size, offset=offset, **filters)
206
+ for etch in page.etches:
207
+ yield etch
208
+ if not page.has_more or not page.etches:
209
+ return
210
+ offset += page.count
211
+
178
212
  async def sleep(self) -> SleepResult:
179
213
  """Run one consolidation tick now (batch → extract → fold)."""
180
214
  return SleepResult.from_dict(await self._request("POST", "/sleep"))
@@ -188,6 +222,15 @@ class AsyncEtchMem:
188
222
  return History.from_dict(
189
223
  await self._request("GET", f"/etch/{etch_id}/history"))
190
224
 
225
+ async def dossier(self, etch_id: str) -> Dossier:
226
+ """Full provenance of one belief: etch, versions, claims, raw signals.
227
+
228
+ The answer to "why does the agent believe that?". Raw signals are
229
+ withheld (``signals_omitted=True``) when the server anonymizes claims.
230
+ """
231
+ return Dossier.from_dict(
232
+ await self._request("GET", f"/etch/{etch_id}/dossier"))
233
+
191
234
  async def stats(self) -> Stats:
192
235
  """Queue depths and store counts."""
193
236
  return Stats.from_dict(await self._request("GET", "/stats"))
@@ -2,20 +2,43 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
+ from collections.abc import Iterator, Sequence
5
6
  from typing import Any
7
+ from urllib.parse import quote
6
8
 
7
9
  import httpx
8
10
 
9
11
  from etchmem.errors import ConnectionError, error_for_status
10
12
  from etchmem.models import (
11
- ExportResult, Health, History, RecallResponse, RememberResult,
12
- SleepResult, Stats,
13
+ Association, Dossier, Etch, EtchList, ExportResult, Health, History,
14
+ Knowledge, RecallResponse, RememberResult, SleepResult, Stats,
13
15
  )
14
16
 
15
17
  DEFAULT_BASE_URL = "http://localhost:8000"
16
18
  DEFAULT_TIMEOUT = 30.0
17
19
 
18
20
 
21
+ def _etch_filters(scope: str | None, property: str | Sequence[str] | None,
22
+ entity_type: str | Sequence[str] | None, status: str | None,
23
+ min_confidence: float | None, limit: int,
24
+ offset: int) -> dict[str, Any]:
25
+ """Query params for GET /etches; list filters become repeated params."""
26
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
27
+ for key, value in (("scope", scope), ("status", status),
28
+ ("min_confidence", min_confidence)):
29
+ if value is not None:
30
+ params[key] = value
31
+ for key, value in (("property", property), ("entity_type", entity_type)):
32
+ if value:
33
+ params[key] = [value] if isinstance(value, str) else list(value)
34
+ return params
35
+
36
+
37
+ def _ref_path(ref: str) -> str:
38
+ """Percent-encode an entity ref for the URL path ("MSM 0808", "R&D #4")."""
39
+ return quote(ref, safe="")
40
+
41
+
19
42
  class EtchMem:
20
43
  """Synchronous etchmem client.
21
44
 
@@ -139,9 +162,6 @@ class EtchMem:
139
162
  "as_of_basis": as_of_basis})
140
163
  return RecallResponse.from_dict(payload)
141
164
 
142
-
143
-
144
-
145
165
  def associate(
146
166
  self,
147
167
  query: str,
@@ -196,9 +216,42 @@ class EtchMem:
196
216
  if as_of_basis != "ingest":
197
217
  params["as_of_basis"] = as_of_basis
198
218
  payload = self._request(
199
- "GET", f"/entity/{ref}/etches", params=params or None)
219
+ "GET", f"/entity/{_ref_path(ref)}/etches", params=params or None)
200
220
  return Knowledge.from_dict(payload)
201
221
 
222
+ def etches(
223
+ self,
224
+ *,
225
+ scope: str | None = None,
226
+ property: str | Sequence[str] | None = None,
227
+ entity_type: str | Sequence[str] | None = None,
228
+ status: str | None = None,
229
+ min_confidence: float | None = None,
230
+ limit: int = 500,
231
+ offset: int = 0,
232
+ ) -> EtchList:
233
+ """EVERY belief matching plain filters — exhaustive, not ranked.
234
+
235
+ Use when you know the *kind* of belief rather than the subject or the
236
+ words: "all qa_rejection rules in scope content-qa", "every contested
237
+ lifecycle_status". ``property`` and ``entity_type`` take one value or a
238
+ list. Returns one page (newest first); ``total`` counts every match, so
239
+ page with ``offset`` or use :meth:`iter_etches`.
240
+ """
241
+ return EtchList.from_dict(self._request(
242
+ "GET", "/etches", params=_etch_filters(
243
+ scope, property, entity_type, status, min_confidence, limit, offset)))
244
+
245
+ def iter_etches(self, *, page_size: int = 500, **filters: Any) -> Iterator[Etch]:
246
+ """Every belief matching the filters of :meth:`etches`, across all pages."""
247
+ offset = 0
248
+ while True:
249
+ page = self.etches(limit=page_size, offset=offset, **filters)
250
+ yield from page.etches
251
+ if not page.has_more or not page.etches:
252
+ return
253
+ offset += page.count
254
+
202
255
  def sleep(self) -> SleepResult:
203
256
  """Run one consolidation tick now (batch → extract → fold)."""
204
257
  return SleepResult.from_dict(self._request("POST", "/sleep"))
@@ -212,6 +265,15 @@ class EtchMem:
212
265
  return History.from_dict(
213
266
  self._request("GET", f"/etch/{etch_id}/history"))
214
267
 
268
+ def dossier(self, etch_id: str) -> Dossier:
269
+ """Full provenance of one belief: etch, versions, claims, raw signals.
270
+
271
+ The answer to "why does the agent believe that?". Raw signals are
272
+ withheld (``signals_omitted=True``) when the server anonymizes claims.
273
+ """
274
+ return Dossier.from_dict(
275
+ self._request("GET", f"/etch/{etch_id}/dossier"))
276
+
215
277
  def stats(self) -> Stats:
216
278
  """Queue depths and store counts."""
217
279
  return Stats.from_dict(self._request("GET", "/stats"))