maximem-synap-langgraph 0.1.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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-langgraph
3
+ Version: 0.1.0
4
+ Summary: Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,langgraph,ai,agents,checkpointer,store
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: maximem-synap-integrations-common>=0.1.0
12
+ Requires-Dist: langgraph>=1.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
16
+
17
+ # maximem-synap-langgraph
18
+
19
+ Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install maximem-synap-langgraph
25
+ ```
26
+
27
+ Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
28
+
29
+ ## What's in the box
30
+
31
+ - **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
32
+
33
+ - **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from langgraph.graph import StateGraph, START, END
39
+ from maximem_synap import MaximemSynapSDK
40
+ from synap_langgraph import SynapStore, SynapCheckpointSaver
41
+
42
+ sdk = MaximemSynapSDK(api_key="sk-...")
43
+
44
+ store = SynapStore(sdk, user_id="alice", customer_id="acme")
45
+ saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
46
+
47
+ graph = StateGraph(MyState)
48
+ # ... add nodes / edges ...
49
+ app = graph.compile(checkpointer=saver, store=store)
50
+
51
+ # Store usage inside a node:
52
+ async def remember(state, runtime):
53
+ await runtime.store.aput(
54
+ ("alice", "preferences"),
55
+ "language",
56
+ {"value": "English"},
57
+ )
58
+ ```
59
+
60
+ ## Error policy
61
+
62
+ - **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
63
+ - **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
64
+ - **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
65
+
66
+ ## When to use which checkpointer
67
+
68
+ | Goal | Saver |
69
+ | ---- | ----- |
70
+ | Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
71
+ | Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
72
+ | Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
73
+
74
+ Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
@@ -0,0 +1,58 @@
1
+ # maximem-synap-langgraph
2
+
3
+ Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install maximem-synap-langgraph
9
+ ```
10
+
11
+ Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
12
+
13
+ ## What's in the box
14
+
15
+ - **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
16
+
17
+ - **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ from langgraph.graph import StateGraph, START, END
23
+ from maximem_synap import MaximemSynapSDK
24
+ from synap_langgraph import SynapStore, SynapCheckpointSaver
25
+
26
+ sdk = MaximemSynapSDK(api_key="sk-...")
27
+
28
+ store = SynapStore(sdk, user_id="alice", customer_id="acme")
29
+ saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
30
+
31
+ graph = StateGraph(MyState)
32
+ # ... add nodes / edges ...
33
+ app = graph.compile(checkpointer=saver, store=store)
34
+
35
+ # Store usage inside a node:
36
+ async def remember(state, runtime):
37
+ await runtime.store.aput(
38
+ ("alice", "preferences"),
39
+ "language",
40
+ {"value": "English"},
41
+ )
42
+ ```
43
+
44
+ ## Error policy
45
+
46
+ - **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
47
+ - **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
48
+ - **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
49
+
50
+ ## When to use which checkpointer
51
+
52
+ | Goal | Saver |
53
+ | ---- | ----- |
54
+ | Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
55
+ | Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
56
+ | Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
57
+
58
+ Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-langgraph
3
+ Version: 0.1.0
4
+ Summary: Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,langgraph,ai,agents,checkpointer,store
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: maximem-synap-integrations-common>=0.1.0
12
+ Requires-Dist: langgraph>=1.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
16
+
17
+ # maximem-synap-langgraph
18
+
19
+ Synap memory integration for [LangGraph](https://langchain-ai.github.io/langgraph/).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install maximem-synap-langgraph
25
+ ```
26
+
27
+ Requires `langgraph>=1.0`, `maximem-synap>=0.2.0`.
28
+
29
+ ## What's in the box
30
+
31
+ - **`SynapStore`** — implements LangGraph's `BaseStore` for cross-thread long-term memory. Semantic search via `store.search(namespace, query=...)` routes to `sdk.fetch(...)`, so your graph nodes get Synap-powered recall out of the box.
32
+
33
+ - **`SynapCheckpointSaver`** — implements `BaseCheckpointSaver` with **best-effort fuzzy retrieval**. Checkpoint writes succeed durably; reads use `sdk.fetch` which is semantic-search shaped rather than exact KV. Use for observability/audit and demo flows. For production checkpoint durability, pair with `SqliteSaver` / `PostgresSaver`.
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from langgraph.graph import StateGraph, START, END
39
+ from maximem_synap import MaximemSynapSDK
40
+ from synap_langgraph import SynapStore, SynapCheckpointSaver
41
+
42
+ sdk = MaximemSynapSDK(api_key="sk-...")
43
+
44
+ store = SynapStore(sdk, user_id="alice", customer_id="acme")
45
+ saver = SynapCheckpointSaver(sdk, user_id="alice", customer_id="acme")
46
+
47
+ graph = StateGraph(MyState)
48
+ # ... add nodes / edges ...
49
+ app = graph.compile(checkpointer=saver, store=store)
50
+
51
+ # Store usage inside a node:
52
+ async def remember(state, runtime):
53
+ await runtime.store.aput(
54
+ ("alice", "preferences"),
55
+ "language",
56
+ {"value": "English"},
57
+ )
58
+ ```
59
+
60
+ ## Error policy
61
+
62
+ - **Writes** (`SynapStore.put`, `SynapCheckpointSaver.put`, `put_writes`) surface SDK failures as `SynapIntegrationError`. Silent drops would hide ingestion outages.
63
+ - **Reads** (`get`, `search`, `get_tuple`, `list`) degrade gracefully — they log at `ERROR` and return `None`/`[]` rather than crashing the graph.
64
+ - **Deletes** (`SynapStore.delete`, `SynapCheckpointSaver.delete_thread`) warn once and no-op — Synap has no public delete API.
65
+
66
+ ## When to use which checkpointer
67
+
68
+ | Goal | Saver |
69
+ | ---- | ----- |
70
+ | Durable thread checkpoints, exact restore | LangGraph's `SqliteSaver` or `PostgresSaver` |
71
+ | Thread state surfaced in Synap for observability/audit/cross-thread analysis | `SynapCheckpointSaver` |
72
+ | Both | Use Sqlite/Postgres as primary; layer `SynapCheckpointSaver` for the Synap view |
73
+
74
+ Cross-thread long-term memory (`BaseStore`) maps cleanly to Synap — use `SynapStore` as your default.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ maximem_synap_langgraph.egg-info/PKG-INFO
4
+ maximem_synap_langgraph.egg-info/SOURCES.txt
5
+ maximem_synap_langgraph.egg-info/dependency_links.txt
6
+ maximem_synap_langgraph.egg-info/requires.txt
7
+ maximem_synap_langgraph.egg-info/top_level.txt
8
+ synap_langgraph/__init__.py
9
+ synap_langgraph/checkpointer.py
10
+ synap_langgraph/store.py
@@ -0,0 +1,7 @@
1
+ maximem-synap>=0.2.0
2
+ maximem-synap-integrations-common>=0.1.0
3
+ langgraph>=1.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-asyncio>=0.21
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "maximem-synap-langgraph"
7
+ version = "0.1.0"
8
+ description = "Synap memory integration for LangGraph — BaseStore + BaseCheckpointSaver"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ {name = "Synap Team"}
14
+ ]
15
+ keywords = ["synap", "memory", "langgraph", "ai", "agents", "checkpointer", "store"]
16
+
17
+ dependencies = [
18
+ "maximem-synap>=0.2.0",
19
+ "maximem-synap-integrations-common>=0.1.0",
20
+ "langgraph>=1.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=7.0",
26
+ "pytest-asyncio>=0.21",
27
+ ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["synap_langgraph*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """Synap integration for LangGraph.
2
+
3
+ Exposes:
4
+
5
+ - :class:`SynapStore` — a ``BaseStore`` implementation backed by Synap's
6
+ semantic memory (``sdk.memories.create`` + ``sdk.fetch``). Drop it into
7
+ ``StateGraph.compile(store=SynapStore(...))`` for cross-thread long-term
8
+ memory. Semantic search via ``store.asearch`` is the natural fit for our
9
+ retrieval model.
10
+
11
+ - :class:`SynapCheckpointSaver` — a ``BaseCheckpointSaver`` that persists
12
+ thread execution state to Synap. **Best-effort fuzzy retrieval**: reads
13
+ use ``sdk.fetch`` with metadata filters, which is semantic-search-shaped
14
+ rather than exact KV. Pair with a real KV saver (SqliteSaver, PostgresSaver)
15
+ for production-grade checkpoint fidelity; use ``SynapCheckpointSaver`` for
16
+ observability/audit and demo flows.
17
+ """
18
+
19
+ from synap_langgraph.store import SynapStore
20
+ from synap_langgraph.checkpointer import SynapCheckpointSaver
21
+
22
+ __all__ = ["SynapStore", "SynapCheckpointSaver"]
@@ -0,0 +1,421 @@
1
+ """SynapCheckpointSaver — best-effort fuzzy ``BaseCheckpointSaver``.
2
+
3
+ Synap's retrieval is semantic-search shaped, not exact key-value. This saver
4
+ trades off checkpoint fidelity for the benefits of storing thread state in
5
+ Synap: observability, audit, and cross-thread memory sitting next to the
6
+ execution history.
7
+
8
+ For production durability, pair with a proper KV-backed saver
9
+ (``SqliteSaver`` / ``PostgresSaver`` in a subgraph or a composite saver).
10
+
11
+ Storage strategy:
12
+
13
+ - ``aput(config, checkpoint, metadata, new_versions)`` serialises the
14
+ checkpoint blob via ``self.serde.dumps_typed`` → ``(type, bytes)``, then
15
+ ingests as a Synap memory via ``sdk.memories.create`` with the raw bytes
16
+ base64-encoded in ``document`` and metadata tagging the thread/checkpoint
17
+ coordinates.
18
+ - ``aget_tuple(config)`` fetches by thread id and returns the latest
19
+ matching checkpoint.
20
+ - ``alist(...)`` yields the filtered matches from a single fetch.
21
+ - ``aput_writes`` ingests pending writes with a distinguishing metadata type.
22
+ - ``adelete_thread`` warns + no-ops (Synap has no public delete API).
23
+
24
+ Error policy: writes surface ``SynapIntegrationError`` on SDK failure. Reads
25
+ degrade gracefully (return ``None`` / empty iterator).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import json
32
+ import logging
33
+ from typing import Any, AsyncIterator, Iterator, Optional, Sequence
34
+
35
+ from langchain_core.runnables import RunnableConfig
36
+ from langgraph.checkpoint.base import (
37
+ BaseCheckpointSaver,
38
+ ChannelVersions,
39
+ Checkpoint,
40
+ CheckpointMetadata,
41
+ CheckpointTuple,
42
+ )
43
+ from maximem_synap import MaximemSynapSDK
44
+ from synap_integrations_common import (
45
+ run_async,
46
+ wrap_sdk_errors_async,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ _MARKER = "lg_cp"
53
+ _WRITE_MARKER = "lg_cp_writes"
54
+
55
+
56
+ class SynapCheckpointSaver(BaseCheckpointSaver):
57
+ """Synap-backed LangGraph checkpoint saver (best-effort fuzzy)."""
58
+
59
+ def __init__(
60
+ self,
61
+ sdk: MaximemSynapSDK,
62
+ user_id: str,
63
+ customer_id: str = "",
64
+ *,
65
+ mode: str = "accurate",
66
+ ) -> None:
67
+ if sdk is None:
68
+ raise ValueError("SynapCheckpointSaver requires a non-None sdk")
69
+ if not user_id:
70
+ raise ValueError("SynapCheckpointSaver requires a non-empty user_id")
71
+ super().__init__()
72
+ self.sdk = sdk
73
+ self.user_id = user_id
74
+ self.customer_id = customer_id
75
+ self.mode = mode
76
+ self._delete_warned = False
77
+
78
+ # ── put ─────────────────────────────────────────────────────────────────
79
+
80
+ async def aput(
81
+ self,
82
+ config: RunnableConfig,
83
+ checkpoint: Checkpoint,
84
+ metadata: CheckpointMetadata,
85
+ new_versions: ChannelVersions,
86
+ ) -> RunnableConfig:
87
+ configurable = dict(config.get("configurable", {}))
88
+ thread_id = str(configurable.get("thread_id") or "")
89
+ checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
90
+ checkpoint_id = str(checkpoint.get("id") or configurable.get("checkpoint_id") or "")
91
+ parent_checkpoint_id = str(configurable.get("checkpoint_id") or "")
92
+
93
+ serde_type, raw_bytes = self.serde.dumps_typed(checkpoint)
94
+ document = _encode(serde_type, raw_bytes, metadata, new_versions)
95
+
96
+ meta = {
97
+ _MARKER: True,
98
+ "thread_id": thread_id,
99
+ "checkpoint_ns": checkpoint_ns,
100
+ "checkpoint_id": checkpoint_id,
101
+ "parent_checkpoint_id": parent_checkpoint_id,
102
+ }
103
+
104
+ async with wrap_sdk_errors_async(
105
+ "langgraph.checkpointer.put",
106
+ logger,
107
+ thread_id=thread_id,
108
+ checkpoint_id=checkpoint_id,
109
+ ):
110
+ await self.sdk.memories.create(
111
+ document=document,
112
+ user_id=self.user_id,
113
+ customer_id=self.customer_id or None,
114
+ metadata=meta,
115
+ )
116
+
117
+ return {
118
+ "configurable": {
119
+ "thread_id": thread_id,
120
+ "checkpoint_ns": checkpoint_ns,
121
+ "checkpoint_id": checkpoint_id,
122
+ }
123
+ }
124
+
125
+ def put(
126
+ self,
127
+ config: RunnableConfig,
128
+ checkpoint: Checkpoint,
129
+ metadata: CheckpointMetadata,
130
+ new_versions: ChannelVersions,
131
+ ) -> RunnableConfig:
132
+ return run_async(self.aput(config, checkpoint, metadata, new_versions))
133
+
134
+ # ── put_writes ──────────────────────────────────────────────────────────
135
+
136
+ async def aput_writes(
137
+ self,
138
+ config: RunnableConfig,
139
+ writes: Sequence[tuple[str, Any]],
140
+ task_id: str,
141
+ task_path: str = "",
142
+ ) -> None:
143
+ configurable = dict(config.get("configurable", {}))
144
+ thread_id = str(configurable.get("thread_id") or "")
145
+ checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
146
+ checkpoint_id = str(configurable.get("checkpoint_id") or "")
147
+
148
+ # Serialise each write individually so deserialisation fidelity survives
149
+ # per-channel. Each write is (channel, value).
150
+ encoded_writes: list[dict[str, Any]] = []
151
+ for channel, value in writes:
152
+ t, b = self.serde.dumps_typed(value)
153
+ encoded_writes.append({
154
+ "channel": channel,
155
+ "type": t,
156
+ "value_b64": base64.b64encode(b).decode("ascii"),
157
+ })
158
+
159
+ document = json.dumps({
160
+ "task_id": task_id,
161
+ "task_path": task_path,
162
+ "writes": encoded_writes,
163
+ })
164
+
165
+ meta = {
166
+ _WRITE_MARKER: True,
167
+ "thread_id": thread_id,
168
+ "checkpoint_ns": checkpoint_ns,
169
+ "checkpoint_id": checkpoint_id,
170
+ "task_id": task_id,
171
+ }
172
+
173
+ async with wrap_sdk_errors_async(
174
+ "langgraph.checkpointer.put_writes",
175
+ logger,
176
+ thread_id=thread_id,
177
+ checkpoint_id=checkpoint_id,
178
+ task_id=task_id,
179
+ ):
180
+ await self.sdk.memories.create(
181
+ document=document,
182
+ user_id=self.user_id,
183
+ customer_id=self.customer_id or None,
184
+ metadata=meta,
185
+ )
186
+
187
+ def put_writes(
188
+ self,
189
+ config: RunnableConfig,
190
+ writes: Sequence[tuple[str, Any]],
191
+ task_id: str,
192
+ task_path: str = "",
193
+ ) -> None:
194
+ run_async(self.aput_writes(config, writes, task_id, task_path))
195
+
196
+ # ── get_tuple ───────────────────────────────────────────────────────────
197
+
198
+ async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
199
+ configurable = dict(config.get("configurable", {}))
200
+ thread_id = str(configurable.get("thread_id") or "")
201
+ if not thread_id:
202
+ return None
203
+ checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
204
+ target_cp_id = configurable.get("checkpoint_id")
205
+
206
+ tuples = await self._fetch_checkpoint_tuples(
207
+ thread_id=thread_id,
208
+ checkpoint_ns=checkpoint_ns,
209
+ target_cp_id=str(target_cp_id) if target_cp_id else None,
210
+ limit=10,
211
+ )
212
+ if not tuples:
213
+ return None
214
+ return tuples[0]
215
+
216
+ def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
217
+ return run_async(self.aget_tuple(config))
218
+
219
+ # ── list ────────────────────────────────────────────────────────────────
220
+
221
+ async def alist(
222
+ self,
223
+ config: Optional[RunnableConfig],
224
+ *,
225
+ filter: Optional[dict[str, Any]] = None,
226
+ before: Optional[RunnableConfig] = None,
227
+ limit: Optional[int] = None,
228
+ ) -> AsyncIterator[CheckpointTuple]:
229
+ configurable = dict((config or {}).get("configurable", {}))
230
+ thread_id = str(configurable.get("thread_id") or "")
231
+ if not thread_id:
232
+ return
233
+ checkpoint_ns = str(configurable.get("checkpoint_ns") or "")
234
+ before_id: Optional[str] = None
235
+ if before:
236
+ before_id = str(
237
+ (before.get("configurable") or {}).get("checkpoint_id") or ""
238
+ ) or None
239
+
240
+ tuples = await self._fetch_checkpoint_tuples(
241
+ thread_id=thread_id,
242
+ checkpoint_ns=checkpoint_ns,
243
+ target_cp_id=None,
244
+ limit=max(limit or 25, 25),
245
+ )
246
+
247
+ count = 0
248
+ for tup in tuples:
249
+ if before_id and tup.config["configurable"].get("checkpoint_id") == before_id:
250
+ break
251
+ if filter and not _metadata_matches_filter(tup.metadata, filter):
252
+ continue
253
+ yield tup
254
+ count += 1
255
+ if limit and count >= limit:
256
+ return
257
+
258
+ def list(
259
+ self,
260
+ config: Optional[RunnableConfig],
261
+ *,
262
+ filter: Optional[dict[str, Any]] = None,
263
+ before: Optional[RunnableConfig] = None,
264
+ limit: Optional[int] = None,
265
+ ) -> Iterator[CheckpointTuple]:
266
+ async def _collect() -> list[CheckpointTuple]:
267
+ out: list[CheckpointTuple] = []
268
+ async for t in self.alist(config, filter=filter, before=before, limit=limit):
269
+ out.append(t)
270
+ return out
271
+
272
+ return iter(run_async(_collect()))
273
+
274
+ # ── delete_thread ───────────────────────────────────────────────────────
275
+
276
+ async def adelete_thread(self, thread_id: str) -> None:
277
+ if not self._delete_warned:
278
+ logger.warning(
279
+ "SynapCheckpointSaver.delete_thread: Synap has no public delete "
280
+ "API; this is a no-op. This warning fires once."
281
+ )
282
+ self._delete_warned = True
283
+
284
+ def delete_thread(self, thread_id: str) -> None:
285
+ run_async(self.adelete_thread(thread_id))
286
+
287
+ # ── fetch helpers ───────────────────────────────────────────────────────
288
+
289
+ async def _fetch_checkpoint_tuples(
290
+ self,
291
+ *,
292
+ thread_id: str,
293
+ checkpoint_ns: str,
294
+ target_cp_id: Optional[str],
295
+ limit: int,
296
+ ) -> list[CheckpointTuple]:
297
+ try:
298
+ response = await self.sdk.fetch(
299
+ user_id=self.user_id,
300
+ customer_id=self.customer_id or None,
301
+ search_query=[f"{thread_id}"],
302
+ max_results=limit,
303
+ mode=self.mode,
304
+ include_conversation_context=False,
305
+ )
306
+ except Exception as exc: # noqa: BLE001
307
+ logger.error(
308
+ "SynapCheckpointSaver: sdk.fetch failed thread_id=%s error=%s",
309
+ thread_id, exc, exc_info=True,
310
+ )
311
+ return []
312
+
313
+ # Collect candidate facts: must be our checkpoint marker + same thread.
314
+ candidates: list[tuple[Any, dict[str, Any]]] = []
315
+ for fact in getattr(response, "facts", None) or []:
316
+ md = getattr(fact, "metadata", None) or {}
317
+ if not md.get(_MARKER):
318
+ continue
319
+ if md.get("thread_id") != thread_id:
320
+ continue
321
+ if checkpoint_ns and md.get("checkpoint_ns") != checkpoint_ns:
322
+ continue
323
+ if target_cp_id and md.get("checkpoint_id") != target_cp_id:
324
+ continue
325
+ candidates.append((fact, md))
326
+
327
+ # Most recent first by extracted_at.
328
+ candidates.sort(
329
+ key=lambda pair: getattr(pair[0], "extracted_at", 0) or 0,
330
+ reverse=True,
331
+ )
332
+
333
+ tuples: list[CheckpointTuple] = []
334
+ for fact, md in candidates:
335
+ try:
336
+ checkpoint, cp_metadata = _decode(
337
+ getattr(fact, "content", "") or "",
338
+ self.serde,
339
+ )
340
+ except Exception as exc: # noqa: BLE001
341
+ logger.error(
342
+ "SynapCheckpointSaver: failed to decode checkpoint "
343
+ "thread_id=%s id=%s error=%s",
344
+ thread_id, md.get("checkpoint_id"), exc, exc_info=True,
345
+ )
346
+ continue
347
+
348
+ cfg: RunnableConfig = {
349
+ "configurable": {
350
+ "thread_id": thread_id,
351
+ "checkpoint_ns": md.get("checkpoint_ns") or "",
352
+ "checkpoint_id": md.get("checkpoint_id") or "",
353
+ }
354
+ }
355
+ parent_cfg: Optional[RunnableConfig] = None
356
+ parent_id = md.get("parent_checkpoint_id")
357
+ if parent_id:
358
+ parent_cfg = {
359
+ "configurable": {
360
+ "thread_id": thread_id,
361
+ "checkpoint_ns": md.get("checkpoint_ns") or "",
362
+ "checkpoint_id": parent_id,
363
+ }
364
+ }
365
+ tuples.append(
366
+ CheckpointTuple(
367
+ config=cfg,
368
+ checkpoint=checkpoint,
369
+ metadata=cp_metadata,
370
+ parent_config=parent_cfg,
371
+ pending_writes=None,
372
+ )
373
+ )
374
+ return tuples
375
+
376
+
377
+ # ── encode / decode helpers ─────────────────────────────────────────────────
378
+
379
+
380
+ def _encode(
381
+ serde_type: str,
382
+ raw_bytes: bytes,
383
+ metadata: CheckpointMetadata,
384
+ new_versions: ChannelVersions,
385
+ ) -> str:
386
+ """Pack the checkpoint + metadata into a single JSON string document."""
387
+ return json.dumps({
388
+ "serde_type": serde_type,
389
+ "checkpoint_b64": base64.b64encode(raw_bytes).decode("ascii"),
390
+ "metadata": _safe_json(dict(metadata or {})),
391
+ "new_versions": _safe_json(dict(new_versions or {})),
392
+ })
393
+
394
+
395
+ def _decode(document: str, serde: Any) -> tuple[Checkpoint, CheckpointMetadata]:
396
+ payload = json.loads(document)
397
+ t = payload["serde_type"]
398
+ b = base64.b64decode(payload["checkpoint_b64"])
399
+ checkpoint = serde.loads_typed((t, b))
400
+ metadata: CheckpointMetadata = payload.get("metadata") or {} # type: ignore[assignment]
401
+ return checkpoint, metadata
402
+
403
+
404
+ def _safe_json(obj: Any) -> Any:
405
+ """Coerce an arbitrary dict into json-serialisable form (best effort)."""
406
+ try:
407
+ json.dumps(obj)
408
+ return obj
409
+ except TypeError:
410
+ return {k: str(v) for k, v in obj.items()} if isinstance(obj, dict) else str(obj)
411
+
412
+
413
+ def _metadata_matches_filter(
414
+ metadata: CheckpointMetadata,
415
+ filter_: dict[str, Any],
416
+ ) -> bool:
417
+ md = dict(metadata or {})
418
+ for k, v in filter_.items():
419
+ if md.get(k) != v:
420
+ return False
421
+ return True
@@ -0,0 +1,316 @@
1
+ """SynapStore — LangGraph ``BaseStore`` backed by Synap semantic memory.
2
+
3
+ Implements ``batch`` / ``abatch`` (the only abstract methods) and dispatches
4
+ individual ops (:class:`PutOp`, :class:`GetOp`, :class:`SearchOp`,
5
+ :class:`ListNamespacesOp`) to async helpers. The convenience API
6
+ (``put``/``get``/``search``/``delete``/``list_namespaces`` + async variants)
7
+ is inherited unchanged from ``BaseStore`` and rides through ``(a)batch``.
8
+
9
+ Storage strategy:
10
+
11
+ - Each ``put`` is ingested as a Synap memory via ``sdk.memories.create`` with
12
+ the value JSON-encoded in ``document`` and ``metadata`` tagging the
13
+ namespace tuple (joined by ``/``) plus the key.
14
+ - ``get`` retrieves via ``sdk.fetch`` with ``search_query=[namespace+key]``
15
+ and filters the returned facts by the namespace/key metadata markers.
16
+ - ``search`` uses ``sdk.fetch(search_query=[query])`` and filters by
17
+ namespace-prefix in metadata. Scores flow through from ``confidence``.
18
+ - ``delete`` and ``list_namespaces`` warn + no-op (Synap has no public
19
+ delete API, same pattern as synap-crewai).
20
+
21
+ Error policy:
22
+
23
+ - Writes surface ``SynapIntegrationError`` on SDK failure — silent drops
24
+ would hide ingestion outages.
25
+ - Reads degrade gracefully — return ``None``/``[]`` with an ERROR log so an
26
+ SDK blip doesn't poison an agent turn.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ from datetime import datetime, timezone
34
+ from typing import Any, Iterable, Optional
35
+
36
+ from langgraph.store.base import (
37
+ BaseStore,
38
+ GetOp,
39
+ Item,
40
+ ListNamespacesOp,
41
+ Op,
42
+ PutOp,
43
+ Result,
44
+ SearchItem,
45
+ SearchOp,
46
+ )
47
+ from maximem_synap import MaximemSynapSDK
48
+ from synap_integrations_common import (
49
+ SynapIntegrationError,
50
+ run_async,
51
+ wrap_sdk_errors_async,
52
+ )
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Metadata keys we stamp on every Synap memory we create through SynapStore
57
+ # so we can identify / filter them on retrieval.
58
+ _MARKER = "lg_store"
59
+ _NS = "lg_store_ns"
60
+ _KEY = "lg_store_key"
61
+
62
+
63
+ def _ns_str(namespace: tuple[str, ...]) -> str:
64
+ """Stringify a namespace tuple for metadata / search tokens."""
65
+ return "/".join(namespace)
66
+
67
+
68
+ def _matches_namespace_prefix(
69
+ item_ns: str,
70
+ prefix: tuple[str, ...],
71
+ ) -> bool:
72
+ """Return True iff ``item_ns`` sits at or below ``prefix``."""
73
+ if not prefix:
74
+ return True
75
+ pref_str = _ns_str(prefix)
76
+ return item_ns == pref_str or item_ns.startswith(pref_str + "/")
77
+
78
+
79
+ class SynapStore(BaseStore):
80
+ """LangGraph cross-thread long-term memory store backed by Synap.
81
+
82
+ Example::
83
+
84
+ from langgraph.graph import StateGraph, START, END
85
+ from maximem_synap import MaximemSynapSDK
86
+ from synap_langgraph import SynapStore
87
+
88
+ sdk = MaximemSynapSDK(api_key="sk-...")
89
+ store = SynapStore(sdk, user_id="alice", customer_id="acme")
90
+
91
+ graph = StateGraph(MyState)
92
+ # ... add nodes / edges ...
93
+ app = graph.compile(store=store)
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ sdk: MaximemSynapSDK,
99
+ user_id: str,
100
+ customer_id: str = "",
101
+ *,
102
+ mode: str = "accurate",
103
+ ) -> None:
104
+ if sdk is None:
105
+ raise ValueError("SynapStore requires a non-None sdk")
106
+ if not user_id:
107
+ raise ValueError("SynapStore requires a non-empty user_id")
108
+
109
+ super().__init__()
110
+ self.sdk = sdk
111
+ self.user_id = user_id
112
+ self.customer_id = customer_id
113
+ self.mode = mode
114
+ self._delete_warned = False
115
+ self._listns_warned = False
116
+
117
+ # ── abstract methods ────────────────────────────────────────────────────
118
+
119
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
120
+ return [await self._dispatch(op) for op in ops]
121
+
122
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
123
+ return run_async(self.abatch(list(ops)))
124
+
125
+ # ── dispatcher ──────────────────────────────────────────────────────────
126
+
127
+ async def _dispatch(self, op: Op) -> Result:
128
+ if isinstance(op, PutOp):
129
+ # value=None on PutOp signals delete (matches the base-class pattern)
130
+ if op.value is None:
131
+ return await self._adelete(op.namespace, op.key)
132
+ return await self._aput(op.namespace, op.key, op.value, op.index)
133
+ if isinstance(op, GetOp):
134
+ return await self._aget(op.namespace, op.key)
135
+ if isinstance(op, SearchOp):
136
+ return await self._asearch(
137
+ op.namespace_prefix, op.query, op.filter, op.limit, op.offset,
138
+ )
139
+ if isinstance(op, ListNamespacesOp):
140
+ return await self._alist_namespaces(op)
141
+ raise ValueError(f"SynapStore: unsupported op type {type(op).__name__}")
142
+
143
+ # ── op implementations ─────────────────────────────────────────────────
144
+
145
+ async def _aput(
146
+ self,
147
+ namespace: tuple[str, ...],
148
+ key: str,
149
+ value: dict[str, Any],
150
+ index: Any = None,
151
+ ) -> None:
152
+ ns = _ns_str(namespace)
153
+ document = json.dumps(value, default=str)
154
+ metadata = {
155
+ _MARKER: True,
156
+ _NS: ns,
157
+ _KEY: key,
158
+ }
159
+ async with wrap_sdk_errors_async(
160
+ "langgraph.store.put",
161
+ logger,
162
+ namespace=ns,
163
+ key=key,
164
+ ):
165
+ await self.sdk.memories.create(
166
+ document=document,
167
+ user_id=self.user_id,
168
+ customer_id=self.customer_id or None,
169
+ metadata=metadata,
170
+ )
171
+
172
+ async def _aget(
173
+ self,
174
+ namespace: tuple[str, ...],
175
+ key: str,
176
+ ) -> Optional[Item]:
177
+ ns = _ns_str(namespace)
178
+ try:
179
+ response = await self.sdk.fetch(
180
+ user_id=self.user_id,
181
+ customer_id=self.customer_id or None,
182
+ search_query=[f"{ns} {key}"],
183
+ max_results=50,
184
+ mode=self.mode,
185
+ include_conversation_context=False,
186
+ )
187
+ except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
188
+ logger.error(
189
+ "SynapStore.get: sdk.fetch failed namespace=%s key=%s error=%s",
190
+ ns, key, exc, exc_info=True,
191
+ )
192
+ return None
193
+
194
+ for fact in getattr(response, "facts", None) or []:
195
+ md = getattr(fact, "metadata", None) or {}
196
+ if md.get(_MARKER) and md.get(_NS) == ns and md.get(_KEY) == key:
197
+ return _fact_to_item(fact, namespace, key)
198
+ return None
199
+
200
+ async def _asearch(
201
+ self,
202
+ namespace_prefix: tuple[str, ...],
203
+ query: Optional[str],
204
+ filter_: Optional[dict[str, Any]],
205
+ limit: int,
206
+ offset: int,
207
+ ) -> list[SearchItem]:
208
+ q = query or _ns_str(namespace_prefix) or ""
209
+ try:
210
+ response = await self.sdk.fetch(
211
+ user_id=self.user_id,
212
+ customer_id=self.customer_id or None,
213
+ search_query=[q] if q else None,
214
+ max_results=max(limit + offset, 10),
215
+ mode=self.mode,
216
+ include_conversation_context=False,
217
+ )
218
+ except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
219
+ logger.error(
220
+ "SynapStore.search: sdk.fetch failed namespace_prefix=%s query=%s error=%s",
221
+ _ns_str(namespace_prefix), query, exc, exc_info=True,
222
+ )
223
+ return []
224
+
225
+ matches: list[SearchItem] = []
226
+ for fact in getattr(response, "facts", None) or []:
227
+ md = getattr(fact, "metadata", None) or {}
228
+ if not md.get(_MARKER):
229
+ continue
230
+ item_ns_str = md.get(_NS) or ""
231
+ if not _matches_namespace_prefix(item_ns_str, namespace_prefix):
232
+ continue
233
+ if filter_ and not _filter_matches(md, filter_):
234
+ continue
235
+ item_ns = tuple(item_ns_str.split("/")) if item_ns_str else ()
236
+ key = md.get(_KEY) or str(fact.id)
237
+ matches.append(_fact_to_search_item(fact, item_ns, key))
238
+
239
+ return matches[offset : offset + limit]
240
+
241
+ async def _adelete(
242
+ self,
243
+ namespace: tuple[str, ...],
244
+ key: str,
245
+ ) -> None:
246
+ if not self._delete_warned:
247
+ logger.warning(
248
+ "SynapStore.delete: Synap has no public delete API. Delete/update "
249
+ "operations are no-ops; data is write-only. This warning fires once."
250
+ )
251
+ self._delete_warned = True
252
+
253
+ async def _alist_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
254
+ if not self._listns_warned:
255
+ logger.warning(
256
+ "SynapStore.list_namespaces: Synap does not expose a namespace "
257
+ "enumeration API; returning []. This warning fires once."
258
+ )
259
+ self._listns_warned = True
260
+ return []
261
+
262
+
263
+ # ── helpers ────────────────────────────────────────────────────────────────
264
+
265
+
266
+ def _fact_to_item(fact: Any, namespace: tuple[str, ...], key: str) -> Item:
267
+ value = _parse_value(getattr(fact, "content", "") or "")
268
+ now = _as_datetime(getattr(fact, "extracted_at", None))
269
+ return Item(
270
+ value=value,
271
+ key=key,
272
+ namespace=namespace,
273
+ created_at=now,
274
+ updated_at=now,
275
+ )
276
+
277
+
278
+ def _fact_to_search_item(
279
+ fact: Any,
280
+ namespace: tuple[str, ...],
281
+ key: str,
282
+ ) -> SearchItem:
283
+ value = _parse_value(getattr(fact, "content", "") or "")
284
+ now = _as_datetime(getattr(fact, "extracted_at", None))
285
+ score = getattr(fact, "confidence", None)
286
+ return SearchItem(
287
+ namespace=namespace,
288
+ key=key,
289
+ value=value,
290
+ created_at=now,
291
+ updated_at=now,
292
+ score=float(score) if isinstance(score, (int, float)) else None,
293
+ )
294
+
295
+
296
+ def _parse_value(document: str) -> dict[str, Any]:
297
+ if not document:
298
+ return {}
299
+ try:
300
+ parsed = json.loads(document)
301
+ except (ValueError, TypeError):
302
+ return {"_raw": document}
303
+ if isinstance(parsed, dict):
304
+ return parsed
305
+ return {"_value": parsed}
306
+
307
+
308
+ def _as_datetime(value: Any) -> datetime:
309
+ if isinstance(value, datetime):
310
+ return value
311
+ return datetime.now(timezone.utc)
312
+
313
+
314
+ # Silence the unused-import complaint — SynapIntegrationError is re-exported
315
+ # via wrap_sdk_errors_async raising it. Keep the symbol visible for typing.
316
+ _ = SynapIntegrationError