nat-memorysync 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -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,152 @@
1
+ # nat-memorysync
2
+
3
+ MemorySync memory backend for the [NVIDIA NeMo Agent Toolkit](https://github.com/NVIDIA/NeMo-Agent-Toolkit) (`nvidia-nat`).
4
+
5
+ Registers a `memorysync_memory` client that plugs into workflow YAML as a
6
+ `memory:` section entry — usable from the toolkit's built-in `add_memory` /
7
+ `get_memory` tools, from the automatic `auto_memory_agent` wrapper, and from
8
+ any custom function that requests a memory client from the Builder.
9
+
10
+ ```bash
11
+ pip install nat-memorysync
12
+ ```
13
+
14
+ Requires Python 3.11+ (the toolkit's own floor). Installing this package pulls
15
+ `nvidia-nat-core`; install `nvidia-nat` (or the plugin subpackages you need)
16
+ for the full toolkit.
17
+
18
+ ## Why this instead of the in-repo editors?
19
+
20
+ The toolkit ships example editors for Mem0 and Zep. Both have sharp edges we
21
+ designed against:
22
+
23
+ | Behavior | Mem0 (in-repo) | Zep (in-repo) | **nat-memorysync** |
24
+ |---|---|---|---|
25
+ | `search()` without `user_id` | bare `KeyError` | n/a (thread-scoped) | `ValueError` naming the kwarg |
26
+ | 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 |
27
+ | Your `metadata` dict after `add_items` | **mutated** (keys popped out) | untouched | untouched (copy-first, tested) |
28
+ | Search result shape | items, **scores discarded** | **one joined text blob** | one `MemoryItem` per fact, `similarity_score` populated |
29
+ | `remove_items()` with no kwargs | **silent no-op** | deletes current thread | raises — refuses to guess |
30
+ | Delete blast radius | whole user | whole thread | session-scoped by default; whole user requires explicit `scope="user"` |
31
+ | Slow/down memory backend | blocks the turn | blocks the turn | 1.2 s recall budget, fail-open both directions |
32
+ | Retried writes | duplicated | duplicated | deterministic idempotency seeds — retries converge on one row |
33
+
34
+ ## Wiring mode 1 — explicit memory tools
35
+
36
+ The agent decides when to store and when to recall:
37
+
38
+ ```yaml
39
+ memory:
40
+ saas_memory:
41
+ _type: memorysync_memory # key comes from MEMORYSYNC_API_KEY env var
42
+
43
+ functions:
44
+ add_memory:
45
+ _type: add_memory
46
+ memory: saas_memory
47
+ description: Save any user preference or fact for later conversations.
48
+ get_memory:
49
+ _type: get_memory
50
+ memory: saas_memory
51
+ description: Recall previously saved user preferences and facts.
52
+
53
+ workflow:
54
+ _type: react_agent
55
+ tool_names: [add_memory, get_memory]
56
+ llm_name: my_llm
57
+ ```
58
+
59
+ ## Wiring mode 2 — automatic memory (`auto_memory_agent`)
60
+
61
+ No tools, no prompt changes — every turn is stored and every prompt is
62
+ enriched automatically (requires `nvidia-nat-langchain`):
63
+
64
+ ```yaml
65
+ memory:
66
+ saas_memory:
67
+ _type: memorysync_memory
68
+
69
+ workflow:
70
+ _type: auto_memory_agent
71
+ augmented_fn: my_actual_workflow
72
+ memory: saas_memory
73
+ ```
74
+
75
+ `search` runs inside a hard 1.2 s budget here, so automatic memory can never
76
+ stall a turn.
77
+
78
+ ## Builder API (Python)
79
+
80
+ ```python
81
+ from nat.builder.workflow_builder import WorkflowBuilder
82
+ from nat_memorysync import MemorySyncMemoryConfig
83
+
84
+ async with WorkflowBuilder() as builder:
85
+ await builder.add_memory_client("saas_memory", MemorySyncMemoryConfig())
86
+ editor = await builder.get_memory_client("saas_memory")
87
+
88
+ from nat.memory.models import MemoryItem
89
+ await editor.add_items([
90
+ MemoryItem(
91
+ conversation=[{"role": "user", "content": "I prefer teal dashboards"}],
92
+ user_id="customer-1",
93
+ metadata={"plan": "pro"},
94
+ )
95
+ ])
96
+ items = await editor.search("dashboard preferences", top_k=5, user_id="customer-1")
97
+ for it in items:
98
+ print(it.similarity_score, it.memory)
99
+ ```
100
+
101
+ ## Configuration
102
+
103
+ All fields are optional except the API key (env var or config field):
104
+
105
+ | YAML field | Default | Purpose |
106
+ |---|---|---|
107
+ | `api_key` | `MEMORYSYNC_API_KEY` env var | API key — keep it in the env var so YAML stays credential-free |
108
+ | `base_url` | `https://api.memorysync.io` | Override for self-hosted / staging |
109
+ | `project_id` | – | Optional `X-Project-ID` header |
110
+ | `top_k` | `5` | Default memories per search |
111
+ | `recall_timeout` | `1.2` | Hard recall budget (seconds); slow backend degrades to no memories |
112
+ | `min_query_chars` | `8` | Skip recall for shorter queries |
113
+ | `source` | `nat` | Source label on stored turns |
114
+
115
+ `MemoryBaseConfig` + `RetryMixin` knobs (`num_retries`,
116
+ `retry_on_status_codes`, …) work too — retries are safe because every write
117
+ carries a deterministic idempotency seed.
118
+
119
+ ## Editor semantics
120
+
121
+ - **`add_items(items)`** — each `MemoryItem.conversation` is stored through
122
+ MemorySync's extraction pipeline (facts, dedup, decay), scoped to that
123
+ item's `user_id`. `metadata` keys ride along; `metadata.ignore_roles`
124
+ filters roles out (e.g. `["assistant"]` stores only user turns). Items
125
+ whose extraction fails are logged and skipped — a partial batch never
126
+ raises mid-turn.
127
+ - **`search(query, top_k=..., user_id=...)`** — semantic recall, one
128
+ `MemoryItem` per fact with `similarity_score`. `user_id` is required
129
+ (loud `ValueError`, not a `KeyError`).
130
+ - **`remove_items(user_id=...)`** — deletes this adapter's session rows for
131
+ the user. Add `memory_id="..."` for one row, or `scope="user"` to wipe the
132
+ user's entire memory (explicit opt-in). No kwargs → `ValueError`.
133
+
134
+ Session scope comes from the toolkit's `Context.get().conversation_id`
135
+ ContextVar when set (`nat::<conversation_id>`), else `nat::default` — but
136
+ rows are always additionally keyed by `user_id`, so an unset conversation id
137
+ can never mix users.
138
+
139
+ ## Tests
140
+
141
+ ```bash
142
+ pip install -e . nvidia-nat-core langchain-core pytest pytest-asyncio "httpx>=0.25,<1"
143
+ pytest tests -q # 26 tests
144
+ ```
145
+
146
+ The suite exercises the real `WorkflowBuilder`, NVIDIA's real
147
+ `add_memory`/`get_memory` tool functions driving this editor end to end, plus
148
+ named regression tests for every competitor bug in the table above.
149
+
150
+ ## License
151
+
152
+ MIT © MemorySync.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nat-memorysync"
7
+ dynamic = ["version"]
8
+ description = "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."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11,<3.14"
12
+ authors = [{ name = "MemorySync", email = "support@memorysync.io" }]
13
+ keywords = ["nvidia", "nemo-agent-toolkit", "nvidia-nat", "aiqtoolkit", "agents", "memory", "memorysync", "long-term-memory"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ ]
23
+ dependencies = [
24
+ "nvidia-nat-core>=1.5,<2",
25
+ "httpx>=0.25,<1",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://docs.memorysync.io/guides/nemo-agent-toolkit"
30
+ Documentation = "https://docs.memorysync.io/guides/nemo-agent-toolkit"
31
+ Repository = "https://github.com/Rafay121/memorysync-plugins"
32
+
33
+ # The toolkit discovers plugins through this entry point group: importing
34
+ # the module runs the @register_memory registration.
35
+ [project.entry-points.'nat.components']
36
+ nat_memorysync = "nat_memorysync.register"
37
+
38
+ [tool.hatch.version]
39
+ path = "src/nat_memorysync/_version.py"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/nat_memorysync"]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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"