tikuu-memory 0.1.0__py3-none-any.whl

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,17 @@
1
+ """Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer."""
2
+
3
+ from tikuu_memory.client import TikuuMemoryClient
4
+ from tikuu_memory.errors import (
5
+ MemoryClientError,
6
+ MemoryConfigurationError,
7
+ MemoryNotFoundError,
8
+ )
9
+ from tikuu_memory.saver import TikuuMemorySaver
10
+
11
+ __all__ = [
12
+ "MemoryClientError",
13
+ "MemoryConfigurationError",
14
+ "MemoryNotFoundError",
15
+ "TikuuMemoryClient",
16
+ "TikuuMemorySaver",
17
+ ]
tikuu_memory/client.py ADDED
@@ -0,0 +1,284 @@
1
+ """HTTP client for tikuu-memory-service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from tikuu_memory.config import MemorySettings, settings_from_env
10
+ from tikuu_memory.errors import MemoryClientError, MemoryNotFoundError
11
+ from tikuu_memory.models import CheckpointSnapshot
12
+
13
+
14
+ class TikuuMemoryClient:
15
+ """Thin sync/async HTTP client for checkpoint snapshot routes.
16
+
17
+ TODO(auth): Send a session token header when tikuu-api-server starts
18
+ injecting one into agent containers.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ base_url: str,
25
+ agent_id: str,
26
+ timeout: float = 30.0,
27
+ ) -> None:
28
+ self.base_url = base_url.rstrip("/")
29
+ self.agent_id = agent_id
30
+ self._timeout = timeout
31
+ self._sync: httpx.Client | None = None
32
+ self._async: httpx.AsyncClient | None = None
33
+
34
+ @classmethod
35
+ def from_env(cls, environ: dict[str, str] | None = None) -> TikuuMemoryClient:
36
+ """Build a client from ``TIKUU_*`` environment variables."""
37
+ settings = settings_from_env(environ)
38
+ return cls.from_settings(settings)
39
+
40
+ @classmethod
41
+ def from_settings(cls, settings: MemorySettings) -> TikuuMemoryClient:
42
+ """Build a client from resolved settings."""
43
+ return cls(base_url=settings.base_url, agent_id=settings.agent_id)
44
+
45
+ def _sync_client(self) -> httpx.Client:
46
+ if self._sync is None:
47
+ self._sync = httpx.Client(
48
+ base_url=self.base_url,
49
+ timeout=self._timeout,
50
+ )
51
+ return self._sync
52
+
53
+ def _async_client(self) -> httpx.AsyncClient:
54
+ if self._async is None:
55
+ self._async = httpx.AsyncClient(
56
+ base_url=self.base_url,
57
+ timeout=self._timeout,
58
+ )
59
+ return self._async
60
+
61
+ def close(self) -> None:
62
+ """Close the sync HTTP client if open."""
63
+ if self._sync is not None:
64
+ self._sync.close()
65
+ self._sync = None
66
+
67
+ async def aclose(self) -> None:
68
+ """Close the async HTTP client if open."""
69
+ if self._async is not None:
70
+ await self._async.aclose()
71
+ self._async = None
72
+
73
+ def put_checkpoint(
74
+ self,
75
+ *,
76
+ thread_id: str,
77
+ checkpoint_ns: str,
78
+ checkpoint_id: str,
79
+ parent_checkpoint_id: str | None,
80
+ metadata_blob: str,
81
+ checkpoint_blob: str,
82
+ ) -> CheckpointSnapshot:
83
+ """Upsert one snapshot (sync)."""
84
+ response = self._sync_client().put(
85
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
86
+ json={
87
+ "checkpoint_ns": checkpoint_ns,
88
+ "checkpoint_id": checkpoint_id,
89
+ "parent_checkpoint_id": parent_checkpoint_id,
90
+ "metadata_blob": metadata_blob,
91
+ "checkpoint_blob": checkpoint_blob,
92
+ },
93
+ )
94
+ return self._parse_snapshot(response)
95
+
96
+ async def aput_checkpoint(
97
+ self,
98
+ *,
99
+ thread_id: str,
100
+ checkpoint_ns: str,
101
+ checkpoint_id: str,
102
+ parent_checkpoint_id: str | None,
103
+ metadata_blob: str,
104
+ checkpoint_blob: str,
105
+ ) -> CheckpointSnapshot:
106
+ """Upsert one snapshot (async)."""
107
+ response = await self._async_client().put(
108
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
109
+ json={
110
+ "checkpoint_ns": checkpoint_ns,
111
+ "checkpoint_id": checkpoint_id,
112
+ "parent_checkpoint_id": parent_checkpoint_id,
113
+ "metadata_blob": metadata_blob,
114
+ "checkpoint_blob": checkpoint_blob,
115
+ },
116
+ )
117
+ return self._parse_snapshot(response)
118
+
119
+ def get_latest(
120
+ self,
121
+ *,
122
+ thread_id: str,
123
+ checkpoint_ns: str = "",
124
+ ) -> CheckpointSnapshot:
125
+ """Fetch the latest snapshot (sync)."""
126
+ response = self._sync_client().get(
127
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/latest",
128
+ params={"checkpoint_ns": checkpoint_ns},
129
+ )
130
+ return self._parse_snapshot(response)
131
+
132
+ async def aget_latest(
133
+ self,
134
+ *,
135
+ thread_id: str,
136
+ checkpoint_ns: str = "",
137
+ ) -> CheckpointSnapshot:
138
+ """Fetch the latest snapshot (async)."""
139
+ response = await self._async_client().get(
140
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/latest",
141
+ params={"checkpoint_ns": checkpoint_ns},
142
+ )
143
+ return self._parse_snapshot(response)
144
+
145
+ def get_checkpoint(
146
+ self,
147
+ *,
148
+ thread_id: str,
149
+ checkpoint_id: str,
150
+ checkpoint_ns: str = "",
151
+ ) -> CheckpointSnapshot:
152
+ """Fetch one snapshot by id (sync)."""
153
+ response = self._sync_client().get(
154
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/"
155
+ f"{checkpoint_id}",
156
+ params={"checkpoint_ns": checkpoint_ns},
157
+ )
158
+ return self._parse_snapshot(response)
159
+
160
+ async def aget_checkpoint(
161
+ self,
162
+ *,
163
+ thread_id: str,
164
+ checkpoint_id: str,
165
+ checkpoint_ns: str = "",
166
+ ) -> CheckpointSnapshot:
167
+ """Fetch one snapshot by id (async)."""
168
+ response = await self._async_client().get(
169
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints/"
170
+ f"{checkpoint_id}",
171
+ params={"checkpoint_ns": checkpoint_ns},
172
+ )
173
+ return self._parse_snapshot(response)
174
+
175
+ def list_checkpoints(
176
+ self,
177
+ *,
178
+ thread_id: str,
179
+ checkpoint_ns: str = "",
180
+ before: str | None = None,
181
+ limit: int | None = None,
182
+ ) -> list[CheckpointSnapshot]:
183
+ """List snapshots newest-first (sync)."""
184
+ params: dict[str, Any] = {"checkpoint_ns": checkpoint_ns}
185
+ if before is not None:
186
+ params["before"] = before
187
+ if limit is not None:
188
+ params["limit"] = limit
189
+ response = self._sync_client().get(
190
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
191
+ params=params,
192
+ )
193
+ return self._parse_list(response)
194
+
195
+ async def alist_checkpoints(
196
+ self,
197
+ *,
198
+ thread_id: str,
199
+ checkpoint_ns: str = "",
200
+ before: str | None = None,
201
+ limit: int | None = None,
202
+ ) -> list[CheckpointSnapshot]:
203
+ """List snapshots newest-first (async)."""
204
+ params: dict[str, Any] = {"checkpoint_ns": checkpoint_ns}
205
+ if before is not None:
206
+ params["before"] = before
207
+ if limit is not None:
208
+ params["limit"] = limit
209
+ response = await self._async_client().get(
210
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}/checkpoints",
211
+ params=params,
212
+ )
213
+ return self._parse_list(response)
214
+
215
+ def delete_thread(
216
+ self,
217
+ *,
218
+ thread_id: str,
219
+ checkpoint_ns: str | None = None,
220
+ ) -> None:
221
+ """Delete all snapshots for a thread (sync)."""
222
+ params: dict[str, Any] = {}
223
+ if checkpoint_ns is not None:
224
+ params["checkpoint_ns"] = checkpoint_ns
225
+ response = self._sync_client().delete(
226
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}",
227
+ params=params,
228
+ )
229
+ self._raise_for_status(response, allow_204=True)
230
+
231
+ async def adelete_thread(
232
+ self,
233
+ *,
234
+ thread_id: str,
235
+ checkpoint_ns: str | None = None,
236
+ ) -> None:
237
+ """Delete all snapshots for a thread (async)."""
238
+ params: dict[str, Any] = {}
239
+ if checkpoint_ns is not None:
240
+ params["checkpoint_ns"] = checkpoint_ns
241
+ response = await self._async_client().delete(
242
+ f"/v1/agents/{self.agent_id}/threads/{thread_id}",
243
+ params=params,
244
+ )
245
+ self._raise_for_status(response, allow_204=True)
246
+
247
+ def _parse_snapshot(self, response: httpx.Response) -> CheckpointSnapshot:
248
+ self._raise_for_status(response)
249
+ return CheckpointSnapshot.from_api(response.json())
250
+
251
+ def _parse_list(self, response: httpx.Response) -> list[CheckpointSnapshot]:
252
+ self._raise_for_status(response)
253
+ payload = response.json()
254
+ items = payload.get("items", [])
255
+ return [CheckpointSnapshot.from_api(item) for item in items]
256
+
257
+ @staticmethod
258
+ def _raise_for_status(
259
+ response: httpx.Response,
260
+ *,
261
+ allow_204: bool = False,
262
+ ) -> None:
263
+ if allow_204 and response.status_code == 204:
264
+ return
265
+ if response.status_code == 404:
266
+ detail = _detail(response)
267
+ raise MemoryNotFoundError(detail or "Checkpoint not found")
268
+ if response.is_success:
269
+ return
270
+ detail = _detail(response)
271
+ raise MemoryClientError(
272
+ detail or f"Memory service error (HTTP {response.status_code})",
273
+ status_code=response.status_code,
274
+ )
275
+
276
+
277
+ def _detail(response: httpx.Response) -> str | None:
278
+ try:
279
+ data = response.json()
280
+ except Exception:
281
+ return response.text or None
282
+ if isinstance(data, dict) and "detail" in data:
283
+ return str(data["detail"])
284
+ return None
tikuu_memory/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Load memory client settings from the process environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ from tikuu_memory.constants import ENV_AGENT_ID, ENV_MEMORY_SERVICE_URL
9
+ from tikuu_memory.errors import MemoryConfigurationError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class MemorySettings:
14
+ """Resolved settings for talking to tikuu-memory-service.
15
+
16
+ Attributes:
17
+ base_url: Memory service origin without a trailing slash.
18
+ agent_id: Agent identity used as the storage partition key.
19
+ """
20
+
21
+ base_url: str
22
+ agent_id: str
23
+
24
+
25
+ def settings_from_env(
26
+ environ: dict[str, str] | None = None,
27
+ ) -> MemorySettings:
28
+ """Read ``TIKUU_MEMORY_SERVICE_URL`` and ``TIKUU_AGENT_ID``.
29
+
30
+ Args:
31
+ environ: Mapping to read; defaults to ``os.environ``.
32
+
33
+ Raises:
34
+ MemoryConfigurationError: Either value is missing or blank.
35
+ """
36
+ env = environ if environ is not None else os.environ
37
+ raw_url = (env.get(ENV_MEMORY_SERVICE_URL) or "").strip()
38
+ agent_id = (env.get(ENV_AGENT_ID) or "").strip()
39
+ if not raw_url:
40
+ raise MemoryConfigurationError(
41
+ f"{ENV_MEMORY_SERVICE_URL} is required and must be non-empty"
42
+ )
43
+ if not agent_id:
44
+ raise MemoryConfigurationError(
45
+ f"{ENV_AGENT_ID} is required and must be non-empty"
46
+ )
47
+ return MemorySettings(base_url=raw_url.rstrip("/"), agent_id=agent_id)
@@ -0,0 +1,5 @@
1
+ """Shared constants for the tikuu-memory SDK."""
2
+
3
+ # Env vars injected by tikuu-api-server into every agent container (v1).
4
+ ENV_MEMORY_SERVICE_URL = "TIKUU_MEMORY_SERVICE_URL"
5
+ ENV_AGENT_ID = "TIKUU_AGENT_ID"
tikuu_memory/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Errors raised by the tikuu-memory SDK."""
2
+
3
+
4
+ class MemoryConfigurationError(Exception):
5
+ """Raised when required memory configuration is missing or invalid."""
6
+
7
+
8
+ class MemoryClientError(Exception):
9
+ """Raised when the memory service returns an unexpected HTTP error."""
10
+
11
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
12
+ self.status_code = status_code
13
+ super().__init__(message)
14
+
15
+
16
+ class MemoryNotFoundError(Exception):
17
+ """Raised when a requested checkpoint does not exist (HTTP 404)."""
tikuu_memory/models.py ADDED
@@ -0,0 +1,47 @@
1
+ """Dataclasses mirroring memory-service checkpoint payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CheckpointSnapshot:
13
+ """One checkpoint snapshot as returned by the memory service."""
14
+
15
+ id: UUID
16
+ agent_id: str
17
+ thread_id: str
18
+ checkpoint_ns: str
19
+ checkpoint_id: str
20
+ parent_checkpoint_id: str | None
21
+ metadata_blob: str
22
+ checkpoint_blob: str
23
+ created_at: datetime
24
+
25
+ @classmethod
26
+ def from_api(cls, data: dict[str, Any]) -> CheckpointSnapshot:
27
+ """Build a snapshot from a decoded JSON object."""
28
+ created_raw = data["created_at"]
29
+ if isinstance(created_raw, str):
30
+ created_at = datetime.fromisoformat(created_raw.replace("Z", "+00:00"))
31
+ else:
32
+ created_at = created_raw
33
+ return cls(
34
+ id=UUID(str(data["id"])),
35
+ agent_id=str(data["agent_id"]),
36
+ thread_id=str(data["thread_id"]),
37
+ checkpoint_ns=str(data.get("checkpoint_ns", "")),
38
+ checkpoint_id=str(data["checkpoint_id"]),
39
+ parent_checkpoint_id=(
40
+ str(data["parent_checkpoint_id"])
41
+ if data.get("parent_checkpoint_id") is not None
42
+ else None
43
+ ),
44
+ metadata_blob=str(data["metadata_blob"]),
45
+ checkpoint_blob=str(data["checkpoint_blob"]),
46
+ created_at=created_at,
47
+ )
tikuu_memory/saver.py ADDED
@@ -0,0 +1,314 @@
1
+ """LangGraph ``BaseCheckpointSaver`` backed by tikuu-memory-service.
2
+
3
+ V1 is snapshot-only: each ``put`` stores a complete checkpoint including
4
+ ``channel_values``. Pending writes are not persisted.
5
+
6
+ TODO(writes): Persist ``put_writes`` for interrupt/resume and mid-step
7
+ crash recovery. The no-op implementation exists so LangGraph's default
8
+ durability does not crash.
9
+
10
+ TODO(actor_id): Optional end-user / tenant partition key.
11
+
12
+ TODO(legacy): Non-snapshot event-log reconstruction.
13
+
14
+ TODO(auth): Forward session token header via the HTTP client.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ from collections.abc import AsyncIterator, Iterator, Sequence
21
+ from typing import Any
22
+
23
+ from langchain_core.runnables import RunnableConfig
24
+ from langgraph.checkpoint.base import (
25
+ BaseCheckpointSaver,
26
+ ChannelVersions,
27
+ Checkpoint,
28
+ CheckpointMetadata,
29
+ CheckpointTuple,
30
+ SerializerProtocol,
31
+ get_checkpoint_id,
32
+ get_checkpoint_metadata,
33
+ )
34
+
35
+ from tikuu_memory.client import TikuuMemoryClient
36
+ from tikuu_memory.errors import MemoryConfigurationError, MemoryNotFoundError
37
+ from tikuu_memory.models import CheckpointSnapshot
38
+ from tikuu_memory.serde import SnapshotSerde
39
+
40
+
41
+ class TikuuMemorySaver(BaseCheckpointSaver[str]):
42
+ """Snapshot checkpointer that stores state in tikuu-memory-service."""
43
+
44
+ def __init__(
45
+ self,
46
+ client: TikuuMemoryClient,
47
+ *,
48
+ serde: SerializerProtocol | None = None,
49
+ ) -> None:
50
+ super().__init__(serde=serde)
51
+ self.client = client
52
+ self._snapshot_serde = SnapshotSerde(self.serde)
53
+
54
+ @classmethod
55
+ def from_env(
56
+ cls,
57
+ environ: dict[str, str] | None = None,
58
+ *,
59
+ serde: SerializerProtocol | None = None,
60
+ ) -> TikuuMemorySaver:
61
+ """Build a saver from ``TIKUU_MEMORY_SERVICE_URL`` and ``TIKUU_AGENT_ID``.
62
+
63
+ Raises:
64
+ MemoryConfigurationError: Required env vars are missing/blank.
65
+ """
66
+ return cls(TikuuMemoryClient.from_env(environ), serde=serde)
67
+
68
+ def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
69
+ """Load a checkpoint tuple (exact id or latest)."""
70
+ thread_id, checkpoint_ns, checkpoint_id = _parse_config(config)
71
+ try:
72
+ if checkpoint_id:
73
+ snapshot = self.client.get_checkpoint(
74
+ thread_id=thread_id,
75
+ checkpoint_id=checkpoint_id,
76
+ checkpoint_ns=checkpoint_ns,
77
+ )
78
+ else:
79
+ snapshot = self.client.get_latest(
80
+ thread_id=thread_id,
81
+ checkpoint_ns=checkpoint_ns,
82
+ )
83
+ except MemoryNotFoundError:
84
+ return None
85
+ return self._to_tuple(snapshot, thread_id=thread_id, checkpoint_ns=checkpoint_ns)
86
+
87
+ def list(
88
+ self,
89
+ config: RunnableConfig | None,
90
+ *,
91
+ filter: dict[str, Any] | None = None,
92
+ before: RunnableConfig | None = None,
93
+ limit: int | None = None,
94
+ ) -> Iterator[CheckpointTuple]:
95
+ """List checkpoints newest-first.
96
+
97
+ Metadata ``filter`` is ignored in v1 (documented; does not raise).
98
+ """
99
+ del filter # unsupported in v1
100
+ if config is None:
101
+ return
102
+ thread_id, checkpoint_ns, _ = _parse_config(config)
103
+ before_id = get_checkpoint_id(before) if before else None
104
+ snapshots = self.client.list_checkpoints(
105
+ thread_id=thread_id,
106
+ checkpoint_ns=checkpoint_ns,
107
+ before=before_id,
108
+ limit=limit,
109
+ )
110
+ for snapshot in snapshots:
111
+ yield self._to_tuple(
112
+ snapshot,
113
+ thread_id=thread_id,
114
+ checkpoint_ns=checkpoint_ns,
115
+ )
116
+
117
+ def put(
118
+ self,
119
+ config: RunnableConfig,
120
+ checkpoint: Checkpoint,
121
+ metadata: CheckpointMetadata,
122
+ new_versions: ChannelVersions,
123
+ ) -> RunnableConfig:
124
+ """Persist a complete checkpoint snapshot."""
125
+ del new_versions # snapshot stores full channel_values
126
+ thread_id, checkpoint_ns, parent_checkpoint_id = _parse_config(config)
127
+ checkpoint_id = checkpoint["id"]
128
+ metadata_blob = self._snapshot_serde.dumps_blob(
129
+ dict(get_checkpoint_metadata(config, metadata))
130
+ )
131
+ checkpoint_blob = self._snapshot_serde.dumps_blob(dict(checkpoint))
132
+ self.client.put_checkpoint(
133
+ thread_id=thread_id,
134
+ checkpoint_ns=checkpoint_ns,
135
+ checkpoint_id=checkpoint_id,
136
+ parent_checkpoint_id=parent_checkpoint_id,
137
+ metadata_blob=metadata_blob,
138
+ checkpoint_blob=checkpoint_blob,
139
+ )
140
+ return {
141
+ "configurable": {
142
+ "thread_id": thread_id,
143
+ "checkpoint_ns": checkpoint_ns,
144
+ "checkpoint_id": checkpoint_id,
145
+ }
146
+ }
147
+
148
+ def put_writes(
149
+ self,
150
+ config: RunnableConfig,
151
+ writes: Sequence[tuple[str, Any]],
152
+ task_id: str,
153
+ task_path: str = "",
154
+ ) -> None:
155
+ """No-op in v1.
156
+
157
+ TODO(writes): Persist pending writes for interrupt/resume.
158
+ """
159
+ del config, writes, task_id, task_path
160
+
161
+ def delete_thread(self, thread_id: str) -> None:
162
+ """Delete all snapshots for ``thread_id`` under this agent."""
163
+ self.client.delete_thread(thread_id=thread_id)
164
+
165
+ def get_next_version(
166
+ self,
167
+ current: str | int | None,
168
+ channel: None = None,
169
+ ) -> str:
170
+ """Generate the next monotonic channel version string."""
171
+ del channel
172
+ if current is None:
173
+ current_v = 0
174
+ elif isinstance(current, int):
175
+ current_v = current
176
+ else:
177
+ current_v = int(str(current).split(".")[0])
178
+ next_v = current_v + 1
179
+ next_h = random.random()
180
+ return f"{next_v:032}.{next_h:016}"
181
+
182
+ async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
183
+ thread_id, checkpoint_ns, checkpoint_id = _parse_config(config)
184
+ try:
185
+ if checkpoint_id:
186
+ snapshot = await self.client.aget_checkpoint(
187
+ thread_id=thread_id,
188
+ checkpoint_id=checkpoint_id,
189
+ checkpoint_ns=checkpoint_ns,
190
+ )
191
+ else:
192
+ snapshot = await self.client.aget_latest(
193
+ thread_id=thread_id,
194
+ checkpoint_ns=checkpoint_ns,
195
+ )
196
+ except MemoryNotFoundError:
197
+ return None
198
+ return self._to_tuple(snapshot, thread_id=thread_id, checkpoint_ns=checkpoint_ns)
199
+
200
+ async def alist(
201
+ self,
202
+ config: RunnableConfig | None,
203
+ *,
204
+ filter: dict[str, Any] | None = None,
205
+ before: RunnableConfig | None = None,
206
+ limit: int | None = None,
207
+ ) -> AsyncIterator[CheckpointTuple]:
208
+ del filter
209
+ if config is None:
210
+ return
211
+ thread_id, checkpoint_ns, _ = _parse_config(config)
212
+ before_id = get_checkpoint_id(before) if before else None
213
+ snapshots = await self.client.alist_checkpoints(
214
+ thread_id=thread_id,
215
+ checkpoint_ns=checkpoint_ns,
216
+ before=before_id,
217
+ limit=limit,
218
+ )
219
+ for snapshot in snapshots:
220
+ yield self._to_tuple(
221
+ snapshot,
222
+ thread_id=thread_id,
223
+ checkpoint_ns=checkpoint_ns,
224
+ )
225
+
226
+ async def aput(
227
+ self,
228
+ config: RunnableConfig,
229
+ checkpoint: Checkpoint,
230
+ metadata: CheckpointMetadata,
231
+ new_versions: ChannelVersions,
232
+ ) -> RunnableConfig:
233
+ del new_versions
234
+ thread_id, checkpoint_ns, parent_checkpoint_id = _parse_config(config)
235
+ checkpoint_id = checkpoint["id"]
236
+ metadata_blob = self._snapshot_serde.dumps_blob(
237
+ dict(get_checkpoint_metadata(config, metadata))
238
+ )
239
+ checkpoint_blob = self._snapshot_serde.dumps_blob(dict(checkpoint))
240
+ await self.client.aput_checkpoint(
241
+ thread_id=thread_id,
242
+ checkpoint_ns=checkpoint_ns,
243
+ checkpoint_id=checkpoint_id,
244
+ parent_checkpoint_id=parent_checkpoint_id,
245
+ metadata_blob=metadata_blob,
246
+ checkpoint_blob=checkpoint_blob,
247
+ )
248
+ return {
249
+ "configurable": {
250
+ "thread_id": thread_id,
251
+ "checkpoint_ns": checkpoint_ns,
252
+ "checkpoint_id": checkpoint_id,
253
+ }
254
+ }
255
+
256
+ async def aput_writes(
257
+ self,
258
+ config: RunnableConfig,
259
+ writes: Sequence[tuple[str, Any]],
260
+ task_id: str,
261
+ task_path: str = "",
262
+ ) -> None:
263
+ """Async no-op; see ``put_writes``."""
264
+ del config, writes, task_id, task_path
265
+
266
+ async def adelete_thread(self, thread_id: str) -> None:
267
+ await self.client.adelete_thread(thread_id=thread_id)
268
+
269
+ def _to_tuple(
270
+ self,
271
+ snapshot: CheckpointSnapshot,
272
+ *,
273
+ thread_id: str,
274
+ checkpoint_ns: str,
275
+ ) -> CheckpointTuple:
276
+ checkpoint = self._snapshot_serde.loads_blob(snapshot.checkpoint_blob)
277
+ metadata = self._snapshot_serde.loads_blob(snapshot.metadata_blob)
278
+ parent_config = None
279
+ if snapshot.parent_checkpoint_id:
280
+ parent_config = {
281
+ "configurable": {
282
+ "thread_id": thread_id,
283
+ "checkpoint_ns": checkpoint_ns,
284
+ "checkpoint_id": snapshot.parent_checkpoint_id,
285
+ }
286
+ }
287
+ return CheckpointTuple(
288
+ config={
289
+ "configurable": {
290
+ "thread_id": thread_id,
291
+ "checkpoint_ns": checkpoint_ns,
292
+ "checkpoint_id": snapshot.checkpoint_id,
293
+ }
294
+ },
295
+ checkpoint=checkpoint,
296
+ metadata=metadata,
297
+ parent_config=parent_config,
298
+ pending_writes=[],
299
+ )
300
+
301
+
302
+ def _parse_config(config: RunnableConfig) -> tuple[str, str, str | None]:
303
+ """Extract thread_id, checkpoint_ns, and optional checkpoint_id."""
304
+ configurable = config.get("configurable") or {}
305
+ thread_id = configurable.get("thread_id")
306
+ if not thread_id:
307
+ raise MemoryConfigurationError(
308
+ "RunnableConfig must contain configurable.thread_id"
309
+ )
310
+ checkpoint_ns = configurable.get("checkpoint_ns") or ""
311
+ checkpoint_id = configurable.get("checkpoint_id")
312
+ return str(thread_id), str(checkpoint_ns), (
313
+ str(checkpoint_id) if checkpoint_id else None
314
+ )
tikuu_memory/serde.py ADDED
@@ -0,0 +1,40 @@
1
+ """Serialize checkpoint and metadata values for opaque blob storage.
2
+
3
+ Uses LangGraph's ``JsonPlusSerializer`` (typed base64 inside a small JSON
4
+ envelope), matching the AgentCore checkpointer approach.
5
+
6
+ TODO(serde): JsonPlus can embed pickle for some Python types. Prefer a
7
+ JSON-only serializer later if agents only need standard LangGraph state.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ from typing import Any
15
+
16
+ from langgraph.checkpoint.serde.base import SerializerProtocol
17
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
18
+
19
+
20
+ class SnapshotSerde:
21
+ """Encode/decode checkpoint and metadata blobs for the memory service."""
22
+
23
+ def __init__(self, serde: SerializerProtocol | None = None) -> None:
24
+ self.serde = serde or JsonPlusSerializer()
25
+
26
+ def dumps_blob(self, value: Any) -> str:
27
+ """Serialize ``value`` to a JSON string for ``*_blob`` columns."""
28
+ type_tag, binary_data = self.serde.dumps_typed(value)
29
+ payload = {
30
+ "type": type_tag,
31
+ "data": base64.b64encode(binary_data).decode("utf-8"),
32
+ }
33
+ return json.dumps(payload)
34
+
35
+ def loads_blob(self, blob: str) -> Any:
36
+ """Deserialize a JSON blob string produced by ``dumps_blob``."""
37
+ payload = json.loads(blob)
38
+ type_tag = payload["type"]
39
+ binary_data = base64.b64decode(payload["data"])
40
+ return self.serde.loads_typed((type_tag, binary_data))
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: tikuu-memory
3
+ Version: 0.1.0
4
+ Summary: Tikuu memory SDK: HTTP client and LangGraph snapshot checkpointer
5
+ Author: Utkarsh Tiwari
6
+ Author-email: Utkarsh Tiwari <tiwariutkarsh66@gmail.com>
7
+ Requires-Dist: httpx>=0.28.0
8
+ Requires-Dist: langgraph>=1.0.0
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # tikuu-memory
13
+
14
+ Python SDK for Tikuu short-term memory: HTTP client for
15
+ [`tikuu-memory-service`](../tikuu-memory-service/) and a LangGraph
16
+ `BaseCheckpointSaver` (`TikuuMemorySaver`).
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.13+
21
+ - [uv](https://docs.astral.sh/uv/)
22
+ - A running `tikuu-memory-service`
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv add tikuu-memory
28
+ ```
29
+
30
+ ## Env (required)
31
+
32
+ | Variable | Meaning |
33
+ | --- | --- |
34
+ | `TIKUU_MEMORY_SERVICE_URL` | Memory service base URL (no trailing slash) |
35
+ | `TIKUU_AGENT_ID` | Agent UUID used as the storage partition |
36
+
37
+ `TikuuMemorySaver.from_env()` / `TikuuMemoryClient.from_env()` raise
38
+ `MemoryConfigurationError` if either is missing or blank.
39
+
40
+ Inside Docker agent containers, tikuu-api-server injects both. For host-local
41
+ runs use e.g. `http://127.0.0.1:8100`.
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from tikuu_memory import TikuuMemorySaver
47
+
48
+ checkpointer = TikuuMemorySaver.from_env()
49
+ # pass checkpointer into create_agent(..., checkpointer=checkpointer)
50
+
51
+ config = {"configurable": {"thread_id": session_id}}
52
+ ```
53
+
54
+ The CLI template sets `thread_id` to the Tikuu `X-Session-Id`. The saver
55
+ never forces that mapping; any `thread_id` string is accepted.
56
+
57
+ Storage key: `(agent_id, thread_id, checkpoint_ns)`.
58
+
59
+ ## Session-id-as-thread-id caveats
60
+
61
+ When using the Tikuu session UUID as `thread_id`:
62
+
63
+ - Omitting `X-Session-Id` on a later call mints a new session → new empty thread.
64
+ - One session ⇒ one LangGraph thread ⇒ one container `{agent_id}--{session_id}`.
65
+ - Concurrent invokes on the same session race (last snapshot wins); use one
66
+ in-flight invoke per session.
67
+ - Custom `thread_id` values are allowed in user code.
68
+
69
+ ## V1 limitations
70
+
71
+ - **Snapshot-only** storage (intentional).
72
+ - **`put_writes` is a no-op** so LangGraph does not crash; interrupt/resume
73
+ and mid-step crash recovery are not supported yet.
74
+ - Metadata `filter` on `list` is ignored.
75
+ - No auth headers.
76
+
77
+ ## Deferred work
78
+
79
+ - **TODO(auth):** Send session token header when API server injects one.
80
+ - **TODO(writes):** Persist pending writes for interrupt/resume.
81
+ - **TODO(actor_id):** Optional end-user / tenant key.
82
+ - **TODO(legacy):** Non-snapshot event-log formats.
83
+ - **TODO(serde):** Prefer JSON-only serde if pickle via JsonPlus is undesirable.
84
+
85
+ ## Docs
86
+
87
+ - [Usage](docs/USAGE.md)
88
+ - [Architecture](docs/ARCHITECTURE.md)
89
+ - [End-to-end smoke path](../docs/MEMORY_SMOKE.md)
@@ -0,0 +1,11 @@
1
+ tikuu_memory/__init__.py,sha256=3K9ul_nhin9SGKuflhnJ6D5NQjwK9ei28-W8Ee32ttY,434
2
+ tikuu_memory/client.py,sha256=3Ss1bQXzAAwHlYRBR6dkKmXbcxzF5XvgpgDscJ105P4,9261
3
+ tikuu_memory/config.py,sha256=1dtWJ_nJeX6JjqjnyNbm4b1GEa0UAEbDNpM37cNUv5o,1423
4
+ tikuu_memory/constants.py,sha256=2wYgAgLOfRNJjUili0igmPi1TI_bhHs5xmCoSx6fvR0,207
5
+ tikuu_memory/errors.py,sha256=VmTXtljYOgZA9FRXp_PwNCJ3wcr_qA7D8c9Om4pgm-M,545
6
+ tikuu_memory/models.py,sha256=KoJDxrQ-t9hShjax1WQ9EJd2XFyoIO9rxSJ6-GkXwlY,1510
7
+ tikuu_memory/saver.py,sha256=vckpphA1z88tfUcseK85S_TjAqHaE2JTlgBz7uea2_0,10656
8
+ tikuu_memory/serde.py,sha256=O4cGo0Z2Fm4ItfRsUXHsKkknS_p9IpOI-7SFb9ceXWM,1445
9
+ tikuu_memory-0.1.0.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
10
+ tikuu_memory-0.1.0.dist-info/METADATA,sha256=MavDkJ-VPHBxCPDyM6ttigrxJC9dq3MGPANnUCOCcoQ,2679
11
+ tikuu_memory-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any