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