nat-memorysync 1.0.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,15 @@
1
+ """MemorySync for the NVIDIA NeMo Agent Toolkit."""
2
+
3
+ from ._api import MemorySyncAPIError, fnv1a64
4
+ from ._version import __version__
5
+ from .editor import MemorySyncEditor
6
+ from .register import MemorySyncMemoryConfig, memorysync_memory_client
7
+
8
+ __all__ = [
9
+ "MemorySyncEditor",
10
+ "MemorySyncMemoryConfig",
11
+ "memorysync_memory_client",
12
+ "MemorySyncAPIError",
13
+ "fnv1a64",
14
+ "__version__",
15
+ ]
nat_memorysync/_api.py ADDED
@@ -0,0 +1,286 @@
1
+ """Async client for the MemorySync v1 data plane used by this adapter.
2
+
3
+ Conversation turns persist through the *episodic* ingestion path
4
+ (``POST /v1/memory/add_turn``), which stores text verbatim — no fact
5
+ extraction, no low-value-chatter gate, no rewriting. An agent transcript
6
+ must round-trip byte-for-byte; a plane that second-guessed it would
7
+ corrupt the user's history.
8
+
9
+ Everything here is async-native: the toolkit's ``MemoryEditor`` methods
10
+ are all coroutines running on the main asyncio loop, so the client rides
11
+ along without any bridging.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ import httpx
20
+
21
+ from ._version import __version__
22
+
23
+ DEFAULT_BASE_URL = "https://api.memorysync.io"
24
+ _USER_AGENT = f"nat-memorysync/{__version__}"
25
+
26
+ #: Namespace used when the key cannot list projects (see resolve_tenant_id).
27
+ FALLBACK_TENANT = "default"
28
+
29
+ #: One turn beyond this length is truncated before storage.
30
+ MAX_TURN_CHARS = 16000
31
+
32
+
33
+ class MemorySyncAPIError(Exception):
34
+ """A MemorySync call failed. Carries the status code and server detail."""
35
+
36
+ def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
37
+ super().__init__(message)
38
+ self.status_code = status_code
39
+
40
+
41
+ def resolve_api_key(api_key: Optional[str]) -> str:
42
+ key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
43
+ if not key or not key.strip():
44
+ raise ValueError(
45
+ "A MemorySync API key is required. Pass api_key=... or set the "
46
+ "MEMORYSYNC_API_KEY environment variable."
47
+ )
48
+ return key.strip()
49
+
50
+
51
+ def resolve_base_url(base_url: Optional[str]) -> str:
52
+ url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
53
+ return url.rstrip("/")
54
+
55
+
56
+ def fnv1a64(value: str) -> str:
57
+ """FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
58
+
59
+ Over UTF-16 code units — not code points, not UTF-8 bytes — so the
60
+ output matches every other MemorySync adapter (Python and JS)
61
+ character for character. Identical seeds across surfaces mean a turn
62
+ persisted here and again elsewhere converge on one stored row.
63
+ """
64
+ prime = 0x100000001B3
65
+ mask = 0xFFFFFFFFFFFFFFFF
66
+ h = 0xCBF29CE484222325
67
+ data = value.encode("utf-16-le")
68
+ for i in range(0, len(data), 2):
69
+ unit = data[i] | (data[i + 1] << 8)
70
+ h ^= unit
71
+ h = (h * prime) & mask
72
+ return format(h, "016x")
73
+
74
+
75
+ class AsyncV1Api:
76
+ """Minimal asynchronous v1 client: add_turn, recall, query, list, forget."""
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ api_key: str,
82
+ base_url: str,
83
+ project_id: Optional[str] = None,
84
+ timeout: float = 30.0,
85
+ transport: Optional[httpx.AsyncBaseTransport] = None,
86
+ ) -> None:
87
+ self._api_key = api_key
88
+ self._base_url = base_url.rstrip("/")
89
+ self._project_id = project_id
90
+ self._timeout = timeout
91
+ self._transport = transport
92
+ self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
93
+ self._tenant_id: Optional[str] = None
94
+ self._tenant_is_fallback = False
95
+
96
+ async def aclose(self) -> None:
97
+ await self._http.aclose()
98
+
99
+ @property
100
+ def base_url(self) -> str:
101
+ return self._base_url
102
+
103
+ # ── plumbing ─────────────────────────────────────────────────────
104
+
105
+ def _headers(self, *, end_user_id: Optional[str] = None) -> Dict[str, str]:
106
+ h = {
107
+ "X-API-Key": self._api_key,
108
+ "Accept": "application/json",
109
+ "User-Agent": _USER_AGENT,
110
+ }
111
+ if self._project_id:
112
+ h["X-Project-ID"] = self._project_id
113
+ if end_user_id:
114
+ h["X-End-User-ID"] = end_user_id
115
+ return h
116
+
117
+ async def _request(
118
+ self,
119
+ method: str,
120
+ path: str,
121
+ *,
122
+ json: Optional[Dict[str, Any]] = None,
123
+ params: Optional[Dict[str, Any]] = None,
124
+ end_user_id: Optional[str] = None,
125
+ ) -> Any:
126
+ url = f"{self._base_url}{path}"
127
+ try:
128
+ response = await self._http.request(
129
+ method,
130
+ url,
131
+ headers=self._headers(end_user_id=end_user_id),
132
+ json=json,
133
+ params=params,
134
+ )
135
+ except httpx.TimeoutException as e:
136
+ raise MemorySyncAPIError(f"Request timed out: {e}") from e
137
+ except httpx.HTTPError as e:
138
+ raise MemorySyncAPIError(f"Network error: {e}") from e
139
+
140
+ if response.status_code == 204:
141
+ return None
142
+ try:
143
+ body: Any = response.json()
144
+ except ValueError:
145
+ body = response.text or None
146
+ if response.status_code >= 400:
147
+ detail = body.get("detail") if isinstance(body, dict) else body
148
+ raise MemorySyncAPIError(
149
+ f"{method} {path} failed with HTTP {response.status_code}: {detail}",
150
+ status_code=response.status_code,
151
+ )
152
+ return body
153
+
154
+ # ── calls ────────────────────────────────────────────────────────
155
+
156
+ async def resolve_tenant_id(self) -> str:
157
+ """The tenant id, which the v1 routes need in path or body.
158
+
159
+ Derived from the project listing rather than asked for. Cached for
160
+ the lifetime of this client. Keys without the ``projects:read``
161
+ scope (evaluation keys) fall back to the fixed namespace
162
+ ``"default"`` — deterministic, so every read and write through
163
+ this client lands in one namespace. Only a definite 401/403
164
+ triggers the fallback; a transient server error re-raises rather
165
+ than silently switching namespaces.
166
+ """
167
+ if self._tenant_id:
168
+ return self._tenant_id
169
+ try:
170
+ projects = await self._request("GET", "/org/projects")
171
+ except MemorySyncAPIError as exc:
172
+ if exc.status_code in (401, 403):
173
+ self._tenant_id = FALLBACK_TENANT
174
+ self._tenant_is_fallback = True
175
+ return self._tenant_id
176
+ raise
177
+ first = projects[0] if isinstance(projects, list) and projects else None
178
+ tenant = first.get("tenant_id") if isinstance(first, dict) else None
179
+ if not tenant:
180
+ raise MemorySyncAPIError(
181
+ "Could not determine the tenant for this API key. Pass "
182
+ "tenant_id explicitly, or verify the key with `memorysync doctor`."
183
+ )
184
+ self._tenant_id = str(tenant)
185
+ return self._tenant_id
186
+
187
+ def set_tenant_id(self, tenant_id: str) -> None:
188
+ self._tenant_id = tenant_id
189
+
190
+ async def add_turn(
191
+ self,
192
+ *,
193
+ tenant_id: str,
194
+ user_id: str,
195
+ text: str,
196
+ speaker: Optional[str] = None,
197
+ metadata: Optional[Dict[str, Any]] = None,
198
+ source: str = "nat",
199
+ sync_embed: bool = False,
200
+ ) -> Dict[str, Any]:
201
+ """Store one item verbatim (episodic ingestion).
202
+
203
+ ``speaker`` participates in the server's idempotency seed, so
204
+ retrying an identical payload is recognised
205
+ (``already_exists: true``) instead of stored twice.
206
+ """
207
+ body: Dict[str, Any] = {
208
+ "tenant_id": tenant_id,
209
+ "user_id": user_id,
210
+ "source": source,
211
+ "text": text,
212
+ "sync_embed": sync_embed,
213
+ }
214
+ if speaker is not None:
215
+ body["speaker"] = speaker
216
+ if metadata is not None:
217
+ body["metadata"] = metadata
218
+ return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
219
+
220
+ async def query(
221
+ self,
222
+ *,
223
+ tenant_id: str,
224
+ user_id: str,
225
+ prompt: str,
226
+ k: Optional[int] = None,
227
+ ) -> Dict[str, Any]:
228
+ """Plain semantic search over the pair's memories (episodic included)."""
229
+ body: Dict[str, Any] = {
230
+ "tenant_id": tenant_id,
231
+ "user_id": user_id,
232
+ "prompt": prompt,
233
+ }
234
+ if k is not None:
235
+ body["k"] = k
236
+ return await self._request("POST", "/v1/memory/query", json=body) or {}
237
+
238
+ async def list_memories(
239
+ self,
240
+ *,
241
+ tenant_id: str,
242
+ user_id: str,
243
+ limit: int = 0,
244
+ ) -> List[Dict[str, Any]]:
245
+ """Every memory for the tenant/user pair, newest first."""
246
+ from urllib.parse import quote
247
+
248
+ raw = await self._request(
249
+ "GET",
250
+ f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
251
+ params={"limit": limit},
252
+ )
253
+ memories = raw.get("memories") if isinstance(raw, dict) else None
254
+ return list(memories) if isinstance(memories, list) else []
255
+
256
+ async def forget(self, *, user_id: str, memory_ids: List[int]) -> None:
257
+ """Delete specific memories by numeric id, scoped to one end user."""
258
+ if not memory_ids:
259
+ return
260
+ for start in range(0, len(memory_ids), 100):
261
+ batch = memory_ids[start : start + 100]
262
+ await self._request(
263
+ "DELETE",
264
+ "/memory/forget",
265
+ json={"memory_ids": batch},
266
+ end_user_id=user_id,
267
+ )
268
+
269
+ async def add_memory(
270
+ self,
271
+ *,
272
+ user_id: str,
273
+ text: str,
274
+ source: str = "nat",
275
+ metadata: Optional[Dict[str, Any]] = None,
276
+ ) -> Dict[str, Any]:
277
+ """Store a fact through the extraction path (server-side gating)."""
278
+ body: Dict[str, Any] = {"text": text, "source": source}
279
+ if metadata is not None:
280
+ body["metadata"] = metadata
281
+ return (
282
+ await self._request(
283
+ "POST", "/memory/add", json=body, end_user_id=user_id
284
+ )
285
+ or {}
286
+ )
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,337 @@
1
+ """MemorySync MemoryEditor for the NVIDIA NeMo Agent Toolkit.
2
+
3
+ Design rules, in priority order:
4
+
5
+ 1. **A workflow is never stalled and never broken.** ``search`` runs on
6
+ every response when wired through ``auto_memory_agent``, so it waits
7
+ at most ``recall_timeout`` seconds (default 1.2) and fails open to no
8
+ memories. ``add_items`` is fail-open too — a memory outage must not
9
+ turn a successful agent response into a failure. Deterministic
10
+ idempotency seeds make retries (including the toolkit's RetryMixin
11
+ patching) converge on one stored row instead of duplicating.
12
+
13
+ 2. **Multi-tenant bleed is impossible by construction.** Every stored
14
+ row is scoped by the item's ``user_id``; the session scope only adds
15
+ a per-user transcript label. The in-repo Zep editor routes everything
16
+ to one shared ``"default_zep_thread"`` when no conversation id is set
17
+ — two users' turns land in the same thread. Here that cannot happen.
18
+
19
+ 3. **Deletes cannot nuke a customer by accident.**
20
+ ``remove_items(user_id=...)`` deletes only this adapter's session
21
+ rows by default; wiping the user's entire memory requires the
22
+ explicit ``scope="user"`` opt-in. Calling with no kwargs raises — the
23
+ in-repo Mem0 editor silently does nothing.
24
+
25
+ 4. **Contracts are loud and typed.** ``search`` without ``user_id``
26
+ raises a ValueError naming the kwarg (Mem0 throws a bare KeyError);
27
+ caller items and metadata are never mutated (Mem0 pops keys out of
28
+ the dict you handed it); similarity scores are returned, not
29
+ discarded (Mem0 drops them); search returns one MemoryItem PER FACT
30
+ (Zep returns a single joined blob).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ from typing import Any, Dict, List, Optional
38
+
39
+ import httpx
40
+
41
+ from nat.builder.context import Context
42
+ from nat.memory.interfaces import MemoryEditor
43
+ from nat.memory.models import MemoryItem
44
+
45
+ from ._api import (
46
+ MAX_TURN_CHARS,
47
+ AsyncV1Api,
48
+ MemorySyncAPIError,
49
+ fnv1a64,
50
+ resolve_api_key,
51
+ resolve_base_url,
52
+ )
53
+
54
+ logger = logging.getLogger("nat_memorysync")
55
+
56
+ _ROLE_MAP = {"user": "human", "human": "human", "assistant": "ai", "ai": "ai"}
57
+
58
+
59
+ class MemorySyncEditor(MemoryEditor):
60
+ """The MemorySync-backed ``MemoryEditor``.
61
+
62
+ Works with both toolkit wiring modes: the ``add_memory`` /
63
+ ``get_memory`` tool functions, and the automatic
64
+ ``auto_memory_agent`` wrapper.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ api_key: str,
71
+ base_url: Optional[str] = None,
72
+ project_id: Optional[str] = None,
73
+ top_k: int = 5,
74
+ recall_timeout: float = 1.2,
75
+ min_query_chars: int = 8,
76
+ source: str = "nat",
77
+ transport: Optional[httpx.AsyncBaseTransport] = None,
78
+ ) -> None:
79
+ self.top_k = top_k
80
+ self.recall_timeout = recall_timeout
81
+ self.min_query_chars = min_query_chars
82
+ self.source = source
83
+ self._api = AsyncV1Api(
84
+ api_key=resolve_api_key(api_key),
85
+ base_url=resolve_base_url(base_url),
86
+ project_id=project_id,
87
+ timeout=max(recall_timeout * 4, 8.0),
88
+ transport=transport,
89
+ )
90
+
91
+ # ── scope ────────────────────────────────────────────────────────
92
+
93
+ @staticmethod
94
+ def _session_scope() -> str:
95
+ """Per-run transcript label from the toolkit's async-safe Context.
96
+
97
+ The scope only labels the transcript INSIDE one user's memory —
98
+ rows are always additionally keyed by the item's own user_id, so
99
+ an unset conversation id can never mix two users' data (the
100
+ bleed the in-repo Zep editor has with its shared default thread).
101
+ """
102
+ conversation_id = None
103
+ try:
104
+ conversation_id = Context.get().conversation_id
105
+ except Exception: # pragma: no cover - context outside a workflow
106
+ pass
107
+ return f"nat::{conversation_id or 'default'}"
108
+
109
+ # ── add_items: duplicate-proof, fail-open ────────────────────────
110
+
111
+ async def add_items(self, items: list[MemoryItem]) -> None:
112
+ """Persist items verbatim — per-item user scoping, never mutating
113
+ the caller's objects, never raising into the workflow."""
114
+ scope = self._session_scope()
115
+ stored = 0
116
+ skipped = 0
117
+ try:
118
+ tenant_id = await self._api.resolve_tenant_id()
119
+ coroutines = []
120
+ for item in items or []:
121
+ user_id = (item.user_id or "").strip()
122
+ if not user_id:
123
+ skipped += 1
124
+ continue
125
+ # Copies only — the in-repo Mem0 editor pops keys out of
126
+ # the metadata dict the caller handed it.
127
+ metadata = dict(item.metadata or {})
128
+ ignore_roles = {
129
+ str(r).lower() for r in (metadata.pop("ignore_roles", []) or [])
130
+ }
131
+ conversation = list(item.conversation or [])
132
+ if not conversation and item.memory:
133
+ # Single-fact shorthand goes through the extraction
134
+ # path, where the server decides durability.
135
+ coroutines.append(
136
+ self._api.add_memory(
137
+ user_id=user_id,
138
+ text=str(item.memory),
139
+ source=self.source,
140
+ metadata=metadata or None,
141
+ )
142
+ )
143
+ continue
144
+ for turn in conversation:
145
+ if not isinstance(turn, dict):
146
+ skipped += 1
147
+ continue
148
+ role = _ROLE_MAP.get(str(turn.get("role") or "").lower())
149
+ text = str(turn.get("content") or "").strip()
150
+ if role is None or not text:
151
+ skipped += 1
152
+ continue
153
+ if str(turn.get("role") or "").lower() in ignore_roles:
154
+ skipped += 1
155
+ continue
156
+ if len(text) > MAX_TURN_CHARS:
157
+ text = text[:MAX_TURN_CHARS]
158
+ seed = f"{role}@{scope}#h{fnv1a64(f'{role}:{text}')}"
159
+ payload_metadata: Dict[str, Any] = {"session_id": scope}
160
+ if metadata:
161
+ payload_metadata.update(metadata)
162
+ coroutines.append(
163
+ self._api.add_turn(
164
+ tenant_id=tenant_id,
165
+ user_id=user_id,
166
+ text=f"{role}: {text}",
167
+ speaker=seed,
168
+ metadata=payload_metadata,
169
+ source=self.source,
170
+ )
171
+ )
172
+ if coroutines:
173
+ results = await asyncio.gather(*coroutines, return_exceptions=True)
174
+ for result in results:
175
+ if isinstance(result, Exception):
176
+ skipped += 1
177
+ logger.warning("MemorySync add skipped one item (%s)", result)
178
+ else:
179
+ stored += 1
180
+ except Exception as exc:
181
+ logger.warning(
182
+ "MemorySync add_items degraded (%s) — stored %d, skipped %d; "
183
+ "idempotency seeds make a retry safe.",
184
+ exc,
185
+ stored,
186
+ skipped,
187
+ )
188
+
189
+ # ── search: budgeted, per-fact, fail-open ────────────────────────
190
+
191
+ async def search(self, query: str, top_k: int = 5, **kwargs) -> list[MemoryItem]:
192
+ """Semantic search returning one MemoryItem PER FACT, with
193
+ similarity scores populated.
194
+
195
+ ``user_id`` must be passed as a keyword argument — that is how
196
+ every built-in caller (the get_memory tool and
197
+ auto_memory_agent) invokes editors.
198
+ """
199
+ user_id = kwargs.get("user_id")
200
+ if not isinstance(user_id, str) or not user_id.strip():
201
+ # Mem0's editor does kwargs.pop("user_id") and throws a bare
202
+ # KeyError; a typed, descriptive error is kinder.
203
+ raise ValueError(
204
+ "search() requires a non-empty user_id keyword argument "
205
+ "(e.g. editor.search(query, top_k=5, user_id='customer-42'))."
206
+ )
207
+ user_id = user_id.strip()
208
+ text = (query or "").strip()
209
+ if len(text) < self.min_query_chars:
210
+ return []
211
+ limit = top_k or self.top_k
212
+
213
+ try:
214
+ tenant_id = await asyncio.wait_for(
215
+ self._api.resolve_tenant_id(), timeout=self.recall_timeout
216
+ )
217
+ response = await asyncio.wait_for(
218
+ self._api.query(
219
+ tenant_id=tenant_id, user_id=user_id, prompt=text, k=limit
220
+ ),
221
+ timeout=self.recall_timeout,
222
+ )
223
+ except Exception as exc:
224
+ logger.warning(
225
+ "MemorySync recall unavailable this call (%s) — continuing without memories.",
226
+ exc,
227
+ )
228
+ return []
229
+
230
+ raw = response.get("memories") if isinstance(response, dict) else []
231
+ items: List[MemoryItem] = []
232
+ for entry in raw if isinstance(raw, list) else []:
233
+ if not isinstance(entry, dict):
234
+ continue
235
+ memory_text = (
236
+ entry.get("value") or entry.get("text") or entry.get("raw_text") or ""
237
+ )
238
+ if not isinstance(memory_text, str) or not memory_text:
239
+ continue
240
+ metadata: Dict[str, Any] = {}
241
+ if entry.get("memory_id") is not None:
242
+ metadata["memory_id"] = str(entry["memory_id"])
243
+ score = entry.get("score")
244
+ items.append(
245
+ MemoryItem(
246
+ user_id=user_id,
247
+ memory=memory_text,
248
+ metadata=metadata,
249
+ similarity_score=float(score)
250
+ if isinstance(score, (int, float))
251
+ else None,
252
+ )
253
+ )
254
+ return items
255
+
256
+ # ── remove_items: scoped by default, loud contracts ──────────────
257
+
258
+ async def remove_items(self, **kwargs) -> None:
259
+ """Delete memories — session-scoped by default.
260
+
261
+ - ``memory_id=...`` (with ``user_id``): delete one specific item.
262
+ - ``user_id=...``: delete only THIS adapter's session rows
263
+ (``nat::<conversation>`` scope) for that user.
264
+ - ``user_id=..., scope="user"``: explicit opt-in to wipe the
265
+ user's entire memory.
266
+ - No kwargs: raises ValueError (the in-repo Mem0 editor silently
267
+ does nothing — a latent data-management bug).
268
+ """
269
+ memory_id = kwargs.get("memory_id")
270
+ user_id = kwargs.get("user_id")
271
+ scope = kwargs.get("scope", "session")
272
+ if scope not in ("session", "user"):
273
+ raise ValueError('remove_items scope must be "session" or "user"')
274
+ if memory_id is None and (not isinstance(user_id, str) or not user_id.strip()):
275
+ raise ValueError(
276
+ "remove_items() needs memory_id=... and/or a non-empty user_id=... "
277
+ "keyword argument; refusing to guess what to delete."
278
+ )
279
+
280
+ if memory_id is not None:
281
+ if not isinstance(user_id, str) or not user_id.strip():
282
+ raise ValueError(
283
+ "remove_items(memory_id=...) also needs user_id=... — "
284
+ "deletion is always scoped to one end user."
285
+ )
286
+ parsed = self._parse_id(memory_id)
287
+ if parsed is None:
288
+ raise ValueError(f"Unparseable memory_id: {memory_id!r} (expected m_<n> or <n>)")
289
+ await self._api.forget(user_id=user_id.strip(), memory_ids=[parsed])
290
+ return
291
+
292
+ user_id = user_id.strip()
293
+ tenant_id = await self._api.resolve_tenant_id()
294
+ rows = await self._api.list_memories(tenant_id=tenant_id, user_id=user_id)
295
+ session_scope = self._session_scope()
296
+ ids: List[int] = []
297
+ for row in rows:
298
+ if scope == "session" and not self._in_session(row, session_scope):
299
+ continue
300
+ parsed = self._parse_id(row.get("memory_id"))
301
+ if parsed is not None:
302
+ ids.append(parsed)
303
+ await self._api.forget(user_id=user_id, memory_ids=ids)
304
+
305
+ @staticmethod
306
+ def _in_session(row: Dict[str, Any], session_scope: str) -> bool:
307
+ # Production nests client metadata one level deeper
308
+ # (metadata.metadata.session_id); older shapes were flat. Accept
309
+ # both — the same dual-envelope rule as every other adapter.
310
+ outer = row.get("metadata")
311
+ if not isinstance(outer, dict):
312
+ return False
313
+ inner = outer.get("metadata")
314
+ session = None
315
+ if isinstance(inner, dict):
316
+ session = inner.get("session_id")
317
+ if session is None:
318
+ session = outer.get("session_id")
319
+ return session == session_scope
320
+
321
+ @staticmethod
322
+ def _parse_id(raw: Any) -> Optional[int]:
323
+ if raw is None:
324
+ return None
325
+ value = str(raw)
326
+ if value.startswith("m_"):
327
+ value = value[2:]
328
+ try:
329
+ return int(value)
330
+ except ValueError:
331
+ return None
332
+
333
+ # ── lifecycle ────────────────────────────────────────────────────
334
+
335
+ async def aclose(self) -> None:
336
+ """Release the HTTP client (called by the registration factory)."""
337
+ await self._api.aclose()
@@ -0,0 +1,72 @@
1
+ """Config + registration: makes ``_type: memorysync_memory`` resolvable
2
+ in workflow YAML and through the Builder API."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ from pydantic import Field
9
+
10
+ from nat.builder.builder import Builder
11
+ from nat.cli.register_workflow import register_memory
12
+ from nat.data_models.memory import MemoryBaseConfig
13
+ from nat.data_models.retry_mixin import RetryMixin
14
+
15
+
16
+ class MemorySyncMemoryConfig(MemoryBaseConfig, RetryMixin, name="memorysync_memory"):
17
+ """MemorySync memory client configuration.
18
+
19
+ The API key is deliberately NOT a config field default: it comes
20
+ from the ``MEMORYSYNC_API_KEY`` environment variable unless
21
+ explicitly provided, so workflow YAML files never carry credentials.
22
+ RetryMixin adds the toolkit's standard retry knobs — safe here
23
+ because every write carries a deterministic idempotency seed, so a
24
+ retried add converges on one stored row.
25
+ """
26
+
27
+ base_url: str | None = Field(
28
+ default=None,
29
+ description="MemorySync API base URL (default https://api.memorysync.io).",
30
+ )
31
+ api_key: str | None = Field(
32
+ default=None,
33
+ description="MemorySync API key. Falls back to the MEMORYSYNC_API_KEY env var.",
34
+ )
35
+ project_id: str | None = Field(
36
+ default=None, description="Optional X-Project-ID header value."
37
+ )
38
+ top_k: int = Field(default=5, description="Default memories per search.")
39
+ recall_timeout: float = Field(
40
+ default=1.2,
41
+ description="Hard recall budget in seconds; a slow backend degrades to no memories.",
42
+ )
43
+ min_query_chars: int = Field(
44
+ default=8, description="Skip recall for queries shorter than this."
45
+ )
46
+ source: str = Field(default="nat", description="Source label on stored turns.")
47
+
48
+
49
+ @register_memory(config_type=MemorySyncMemoryConfig)
50
+ async def memorysync_memory_client(config: MemorySyncMemoryConfig, builder: Builder):
51
+ from .editor import MemorySyncEditor
52
+
53
+ api_key = config.api_key or os.environ.get("MEMORYSYNC_API_KEY")
54
+ if not api_key or not api_key.strip():
55
+ raise RuntimeError(
56
+ "MemorySync memory client needs an API key: set the "
57
+ "MEMORYSYNC_API_KEY environment variable or the api_key config "
58
+ "field (get one at https://app.memorysync.io)."
59
+ )
60
+ editor = MemorySyncEditor(
61
+ api_key=api_key,
62
+ base_url=config.base_url,
63
+ project_id=config.project_id,
64
+ top_k=config.top_k,
65
+ recall_timeout=config.recall_timeout,
66
+ min_query_chars=config.min_query_chars,
67
+ source=config.source,
68
+ )
69
+ try:
70
+ yield editor
71
+ finally:
72
+ await editor.aclose()
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.5
2
+ Name: nat-memorysync
3
+ Version: 1.0.0
4
+ Summary: MemorySync for the NVIDIA NeMo Agent Toolkit: a MemoryEditor plugin with budgeted per-fact recall, duplicate-proof verbatim persistence, bleed-proof multi-tenant scoping, and session-scoped deletes.
5
+ Project-URL: Homepage, https://docs.memorysync.io/guides/nemo-agent-toolkit
6
+ Project-URL: Documentation, https://docs.memorysync.io/guides/nemo-agent-toolkit
7
+ Project-URL: Repository, https://github.com/Rafay121/memorysync-plugins
8
+ Author-email: MemorySync <support@memorysync.io>
9
+ License-Expression: MIT
10
+ Keywords: agents,aiqtoolkit,long-term-memory,memory,memorysync,nemo-agent-toolkit,nvidia,nvidia-nat
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: <3.14,>=3.11
19
+ Requires-Dist: httpx<1,>=0.25
20
+ Requires-Dist: nvidia-nat-core<2,>=1.5
21
+ Description-Content-Type: text/markdown
22
+
23
+ # nat-memorysync
24
+
25
+ MemorySync memory backend for the [NVIDIA NeMo Agent Toolkit](https://github.com/NVIDIA/NeMo-Agent-Toolkit) (`nvidia-nat`).
26
+
27
+ Registers a `memorysync_memory` client that plugs into workflow YAML as a
28
+ `memory:` section entry — usable from the toolkit's built-in `add_memory` /
29
+ `get_memory` tools, from the automatic `auto_memory_agent` wrapper, and from
30
+ any custom function that requests a memory client from the Builder.
31
+
32
+ ```bash
33
+ pip install nat-memorysync
34
+ ```
35
+
36
+ Requires Python 3.11+ (the toolkit's own floor). Installing this package pulls
37
+ `nvidia-nat-core`; install `nvidia-nat` (or the plugin subpackages you need)
38
+ for the full toolkit.
39
+
40
+ ## Why this instead of the in-repo editors?
41
+
42
+ The toolkit ships example editors for Mem0 and Zep. Both have sharp edges we
43
+ designed against:
44
+
45
+ | Behavior | Mem0 (in-repo) | Zep (in-repo) | **nat-memorysync** |
46
+ |---|---|---|---|
47
+ | `search()` without `user_id` | bare `KeyError` | n/a (thread-scoped) | `ValueError` naming the kwarg |
48
+ | Multi-user isolation with no conversation id | per-call `user_id` | **all users share `"default_zep_thread"`** | rows always keyed by item `user_id` — bleed impossible |
49
+ | Your `metadata` dict after `add_items` | **mutated** (keys popped out) | untouched | untouched (copy-first, tested) |
50
+ | Search result shape | items, **scores discarded** | **one joined text blob** | one `MemoryItem` per fact, `similarity_score` populated |
51
+ | `remove_items()` with no kwargs | **silent no-op** | deletes current thread | raises — refuses to guess |
52
+ | Delete blast radius | whole user | whole thread | session-scoped by default; whole user requires explicit `scope="user"` |
53
+ | Slow/down memory backend | blocks the turn | blocks the turn | 1.2 s recall budget, fail-open both directions |
54
+ | Retried writes | duplicated | duplicated | deterministic idempotency seeds — retries converge on one row |
55
+
56
+ ## Wiring mode 1 — explicit memory tools
57
+
58
+ The agent decides when to store and when to recall:
59
+
60
+ ```yaml
61
+ memory:
62
+ saas_memory:
63
+ _type: memorysync_memory # key comes from MEMORYSYNC_API_KEY env var
64
+
65
+ functions:
66
+ add_memory:
67
+ _type: add_memory
68
+ memory: saas_memory
69
+ description: Save any user preference or fact for later conversations.
70
+ get_memory:
71
+ _type: get_memory
72
+ memory: saas_memory
73
+ description: Recall previously saved user preferences and facts.
74
+
75
+ workflow:
76
+ _type: react_agent
77
+ tool_names: [add_memory, get_memory]
78
+ llm_name: my_llm
79
+ ```
80
+
81
+ ## Wiring mode 2 — automatic memory (`auto_memory_agent`)
82
+
83
+ No tools, no prompt changes — every turn is stored and every prompt is
84
+ enriched automatically (requires `nvidia-nat-langchain`):
85
+
86
+ ```yaml
87
+ memory:
88
+ saas_memory:
89
+ _type: memorysync_memory
90
+
91
+ workflow:
92
+ _type: auto_memory_agent
93
+ augmented_fn: my_actual_workflow
94
+ memory: saas_memory
95
+ ```
96
+
97
+ `search` runs inside a hard 1.2 s budget here, so automatic memory can never
98
+ stall a turn.
99
+
100
+ ## Builder API (Python)
101
+
102
+ ```python
103
+ from nat.builder.workflow_builder import WorkflowBuilder
104
+ from nat_memorysync import MemorySyncMemoryConfig
105
+
106
+ async with WorkflowBuilder() as builder:
107
+ await builder.add_memory_client("saas_memory", MemorySyncMemoryConfig())
108
+ editor = await builder.get_memory_client("saas_memory")
109
+
110
+ from nat.memory.models import MemoryItem
111
+ await editor.add_items([
112
+ MemoryItem(
113
+ conversation=[{"role": "user", "content": "I prefer teal dashboards"}],
114
+ user_id="customer-1",
115
+ metadata={"plan": "pro"},
116
+ )
117
+ ])
118
+ items = await editor.search("dashboard preferences", top_k=5, user_id="customer-1")
119
+ for it in items:
120
+ print(it.similarity_score, it.memory)
121
+ ```
122
+
123
+ ## Configuration
124
+
125
+ All fields are optional except the API key (env var or config field):
126
+
127
+ | YAML field | Default | Purpose |
128
+ |---|---|---|
129
+ | `api_key` | `MEMORYSYNC_API_KEY` env var | API key — keep it in the env var so YAML stays credential-free |
130
+ | `base_url` | `https://api.memorysync.io` | Override for self-hosted / staging |
131
+ | `project_id` | – | Optional `X-Project-ID` header |
132
+ | `top_k` | `5` | Default memories per search |
133
+ | `recall_timeout` | `1.2` | Hard recall budget (seconds); slow backend degrades to no memories |
134
+ | `min_query_chars` | `8` | Skip recall for shorter queries |
135
+ | `source` | `nat` | Source label on stored turns |
136
+
137
+ `MemoryBaseConfig` + `RetryMixin` knobs (`num_retries`,
138
+ `retry_on_status_codes`, …) work too — retries are safe because every write
139
+ carries a deterministic idempotency seed.
140
+
141
+ ## Editor semantics
142
+
143
+ - **`add_items(items)`** — each `MemoryItem.conversation` is stored through
144
+ MemorySync's extraction pipeline (facts, dedup, decay), scoped to that
145
+ item's `user_id`. `metadata` keys ride along; `metadata.ignore_roles`
146
+ filters roles out (e.g. `["assistant"]` stores only user turns). Items
147
+ whose extraction fails are logged and skipped — a partial batch never
148
+ raises mid-turn.
149
+ - **`search(query, top_k=..., user_id=...)`** — semantic recall, one
150
+ `MemoryItem` per fact with `similarity_score`. `user_id` is required
151
+ (loud `ValueError`, not a `KeyError`).
152
+ - **`remove_items(user_id=...)`** — deletes this adapter's session rows for
153
+ the user. Add `memory_id="..."` for one row, or `scope="user"` to wipe the
154
+ user's entire memory (explicit opt-in). No kwargs → `ValueError`.
155
+
156
+ Session scope comes from the toolkit's `Context.get().conversation_id`
157
+ ContextVar when set (`nat::<conversation_id>`), else `nat::default` — but
158
+ rows are always additionally keyed by `user_id`, so an unset conversation id
159
+ can never mix users.
160
+
161
+ ## Tests
162
+
163
+ ```bash
164
+ pip install -e . nvidia-nat-core langchain-core pytest pytest-asyncio "httpx>=0.25,<1"
165
+ pytest tests -q # 26 tests
166
+ ```
167
+
168
+ The suite exercises the real `WorkflowBuilder`, NVIDIA's real
169
+ `add_memory`/`get_memory` tool functions driving this editor end to end, plus
170
+ named regression tests for every competitor bug in the table above.
171
+
172
+ ## License
173
+
174
+ MIT © MemorySync.
@@ -0,0 +1,9 @@
1
+ nat_memorysync/__init__.py,sha256=rwURV3clCc6XZXn3hdTa7javwhZGaZsrNK2tQroEjgg,417
2
+ nat_memorysync/_api.py,sha256=zA47WuQLfoMS7PwXToDKqtVQoLWLncxhof-qE_NBeo0,10276
3
+ nat_memorysync/_version.py,sha256=ZhzQKWZ8RFrTIkj7z87B144DI95e8LMfA5w8NDWQDtg,23
4
+ nat_memorysync/editor.py,sha256=FTpI2W0H0-3Wgcb1yMEhAxMXC5_kpQfLFdj5Q_CEXEw,14365
5
+ nat_memorysync/register.py,sha256=so9AtPbvy_3gQBn3F1iiwN1fi4WJe1zmQcRDvbDRT9E,2741
6
+ nat_memorysync-1.0.0.dist-info/METADATA,sha256=KIssxtgxvqC1X45kAkKCZGuYAyw00sLhKhRLOK_Q8dk,7110
7
+ nat_memorysync-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ nat_memorysync-1.0.0.dist-info/entry_points.txt,sha256=sXjYilcjCVtrzWxYGCOeccGD2ay34xJ7usIQXRwlThM,58
9
+ nat_memorysync-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [nat.components]
2
+ nat_memorysync = nat_memorysync.register