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.
- nat_memorysync/__init__.py +15 -0
- nat_memorysync/_api.py +286 -0
- nat_memorysync/_version.py +1 -0
- nat_memorysync/editor.py +337 -0
- nat_memorysync/register.py +72 -0
- nat_memorysync-1.0.0.dist-info/METADATA +174 -0
- nat_memorysync-1.0.0.dist-info/RECORD +9 -0
- nat_memorysync-1.0.0.dist-info/WHEEL +4 -0
- nat_memorysync-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -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"
|
nat_memorysync/editor.py
ADDED
|
@@ -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,,
|