syntarus 0.2.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.
syntarus-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Syntarus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: syntarus
3
+ Version: 0.2.0
4
+ Summary: Project-scoped memory client for Syntarus
5
+ Project-URL: Documentation, https://www.syntarus.com/pages/developers.html
6
+ Project-URL: Homepage, https://www.syntarus.com
7
+ Project-URL: Source, https://github.com/sujalkherawat25-stack/memoryos/tree/main/sdk
8
+ Author: Syntarus
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Syntarus
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: agents,ai,llm,memory
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3 :: Only
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Requires-Python: >=3.10
40
+ Requires-Dist: httpx<1,>=0.27
41
+ Provides-Extra: test
42
+ Requires-Dist: build<2,>=1.2; extra == 'test'
43
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'test'
44
+ Requires-Dist: pytest<9,>=8; extra == 'test'
45
+ Requires-Dist: twine<7,>=5; extra == 'test'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # Syntarus Python SDK
49
+
50
+ The official Python client for the project-scoped Syntarus memory API.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install syntarus
56
+ ```
57
+
58
+ Python 3.10 or newer is required. Keep project API keys in a server-side
59
+ secret manager; never ship them in browser or mobile applications.
60
+
61
+ ## Add and search memory
62
+
63
+ ```python
64
+ from syntarus import MemoryClient
65
+
66
+ with MemoryClient(api_key="sk_mem_...") as memory:
67
+ accepted = memory.add(
68
+ user_id="customer_123",
69
+ agent_id="support_agent",
70
+ run_id="ticket_456",
71
+ messages=[
72
+ {"role": "user", "content": "I prefer vegetarian food."},
73
+ {"role": "assistant", "content": "I will remember that."},
74
+ ],
75
+ metadata={"channel": "voice"},
76
+ idempotency_key="call-456-final",
77
+ )
78
+ memory.wait_for_event(accepted["event_id"])
79
+ result = memory.search(
80
+ "What food does the customer prefer?",
81
+ user_id="customer_123",
82
+ agent_id="support_agent",
83
+ )
84
+ print(result["context"])
85
+ ```
86
+
87
+ `agent_id` creates an isolated memory namespace. Omit it for a user profile
88
+ shared by every agent in the project. `run_id` groups the conversation history
89
+ used during ingestion. `metadata` is event metadata and is returned by event
90
+ status and webhook payloads; it is not currently a memory-search filter.
91
+
92
+ ## Async client
93
+
94
+ ```python
95
+ from syntarus import AsyncMemoryClient
96
+
97
+ async with AsyncMemoryClient(api_key="sk_mem_...") as memory:
98
+ accepted = await memory.add(
99
+ user_id="customer_123",
100
+ messages=[{"role": "user", "content": "Call me after 5 PM."}],
101
+ )
102
+ await memory.wait_for_event(accepted["event_id"])
103
+ ```
104
+
105
+ ## Lifecycle operations
106
+
107
+ ```python
108
+ memory.export(user_id="customer_123", agent_id="support_agent")
109
+ memory.delete("memory-uuid")
110
+ memory.delete_user(user_id="customer_123", agent_id="support_agent")
111
+ ```
112
+
113
+ Deleting a user without `agent_id` deletes only the shared user namespace.
114
+ Delete each agent namespace separately when an application uses agent-scoped
115
+ memory.
116
+
117
+ ## Webhooks
118
+
119
+ ```python
120
+ created = memory.create_webhook("https://example.com/syntarus/events")
121
+ signing_secret = created["signing_secret"] # returned once
122
+ memory.list_webhooks()
123
+ memory.webhook_deliveries(webhook_id=created["webhook"]["id"])
124
+ memory.delete_webhook(created["webhook"]["id"])
125
+ ```
126
+
127
+ Deliveries include `X-Syntarus-Timestamp`, `X-Syntarus-Delivery`, and
128
+ `X-Syntarus-Signature-256`. Verify the signature as HMAC-SHA256 over
129
+ `<timestamp>.<raw_request_body>` and reject timestamps outside a five-minute
130
+ window. A non-2xx response is retried durably and eventually becomes a
131
+ dead-letter delivery visible through `webhook_deliveries`.
132
+
133
+ ## Errors
134
+
135
+ The SDK raises typed exceptions derived from `SyntarusError`:
136
+
137
+ - `AuthenticationError`
138
+ - `PermissionDenied`
139
+ - `RateLimitError` (`retry_after` is available when the API sends it)
140
+ - `APIConnectionError`
141
+ - `APITimeoutError`
142
+ - `EventFailedError` (`event` contains the terminal event record)
@@ -0,0 +1,95 @@
1
+ # Syntarus Python SDK
2
+
3
+ The official Python client for the project-scoped Syntarus memory API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install syntarus
9
+ ```
10
+
11
+ Python 3.10 or newer is required. Keep project API keys in a server-side
12
+ secret manager; never ship them in browser or mobile applications.
13
+
14
+ ## Add and search memory
15
+
16
+ ```python
17
+ from syntarus import MemoryClient
18
+
19
+ with MemoryClient(api_key="sk_mem_...") as memory:
20
+ accepted = memory.add(
21
+ user_id="customer_123",
22
+ agent_id="support_agent",
23
+ run_id="ticket_456",
24
+ messages=[
25
+ {"role": "user", "content": "I prefer vegetarian food."},
26
+ {"role": "assistant", "content": "I will remember that."},
27
+ ],
28
+ metadata={"channel": "voice"},
29
+ idempotency_key="call-456-final",
30
+ )
31
+ memory.wait_for_event(accepted["event_id"])
32
+ result = memory.search(
33
+ "What food does the customer prefer?",
34
+ user_id="customer_123",
35
+ agent_id="support_agent",
36
+ )
37
+ print(result["context"])
38
+ ```
39
+
40
+ `agent_id` creates an isolated memory namespace. Omit it for a user profile
41
+ shared by every agent in the project. `run_id` groups the conversation history
42
+ used during ingestion. `metadata` is event metadata and is returned by event
43
+ status and webhook payloads; it is not currently a memory-search filter.
44
+
45
+ ## Async client
46
+
47
+ ```python
48
+ from syntarus import AsyncMemoryClient
49
+
50
+ async with AsyncMemoryClient(api_key="sk_mem_...") as memory:
51
+ accepted = await memory.add(
52
+ user_id="customer_123",
53
+ messages=[{"role": "user", "content": "Call me after 5 PM."}],
54
+ )
55
+ await memory.wait_for_event(accepted["event_id"])
56
+ ```
57
+
58
+ ## Lifecycle operations
59
+
60
+ ```python
61
+ memory.export(user_id="customer_123", agent_id="support_agent")
62
+ memory.delete("memory-uuid")
63
+ memory.delete_user(user_id="customer_123", agent_id="support_agent")
64
+ ```
65
+
66
+ Deleting a user without `agent_id` deletes only the shared user namespace.
67
+ Delete each agent namespace separately when an application uses agent-scoped
68
+ memory.
69
+
70
+ ## Webhooks
71
+
72
+ ```python
73
+ created = memory.create_webhook("https://example.com/syntarus/events")
74
+ signing_secret = created["signing_secret"] # returned once
75
+ memory.list_webhooks()
76
+ memory.webhook_deliveries(webhook_id=created["webhook"]["id"])
77
+ memory.delete_webhook(created["webhook"]["id"])
78
+ ```
79
+
80
+ Deliveries include `X-Syntarus-Timestamp`, `X-Syntarus-Delivery`, and
81
+ `X-Syntarus-Signature-256`. Verify the signature as HMAC-SHA256 over
82
+ `<timestamp>.<raw_request_body>` and reject timestamps outside a five-minute
83
+ window. A non-2xx response is retried durably and eventually becomes a
84
+ dead-letter delivery visible through `webhook_deliveries`.
85
+
86
+ ## Errors
87
+
88
+ The SDK raises typed exceptions derived from `SyntarusError`:
89
+
90
+ - `AuthenticationError`
91
+ - `PermissionDenied`
92
+ - `RateLimitError` (`retry_after` is available when the API sends it)
93
+ - `APIConnectionError`
94
+ - `APITimeoutError`
95
+ - `EventFailedError` (`event` contains the terminal event record)
@@ -0,0 +1,44 @@
1
+ """
2
+ Example 3 — CrewAI agent with MemoryOS memory tool.
3
+ Exposes memory search as a CrewAI Tool so the agent decides when to query.
4
+ """
5
+
6
+ from crewai import Agent, Task, Crew
7
+ from crewai.tools import tool
8
+ from memoryos_client import MemoryOS
9
+
10
+ mem = MemoryOS(api_key="mk_crewai", auto_approve=True)
11
+
12
+
13
+ @tool("Search user memory")
14
+ def search_memory(query: str) -> str:
15
+ """
16
+ Search the user's long-term memory for facts relevant to the query.
17
+ Use this before answering personal questions about the user.
18
+ Returns a context string with relevant facts.
19
+ """
20
+ results = mem.search(query, top_k=5)
21
+ if not results:
22
+ return "No relevant memories found."
23
+ return "\n".join(f"- {r.text}" for r in results)
24
+
25
+
26
+ personal_assistant = Agent(
27
+ role="Personal Assistant",
28
+ goal="Help the user based on their preferences and history",
29
+ backstory="You have access to the user's long-term memory.",
30
+ tools=[search_memory],
31
+ verbose=True,
32
+ )
33
+
34
+ task = Task(
35
+ description="Answer: What kind of projects does the user work on?",
36
+ agent=personal_assistant,
37
+ expected_output="A personalized answer based on memory.",
38
+ )
39
+
40
+ crew = Crew(agents=[personal_assistant], tasks=[task])
41
+
42
+ if __name__ == "__main__":
43
+ result = crew.kickoff()
44
+ print(result)
@@ -0,0 +1,42 @@
1
+ """
2
+ Example 1 — Custom agent with MemoryOS memory layer.
3
+ Works with any LLM — Groq, OpenAI, Anthropic, Ollama.
4
+ """
5
+
6
+ from groq import Groq
7
+ from memoryos_client import MemoryOS
8
+
9
+ # Your MemoryOS API key (set in backend/.env MEMORYOS_API_KEYS)
10
+ mem = MemoryOS(api_key="mk_myagent", auto_approve=False)
11
+ client = Groq() # uses GROQ_API_KEY env var
12
+
13
+
14
+ def chat(user_message: str, conversation_id: str = "default") -> str:
15
+ # 1. Retrieve relevant memories BEFORE calling LLM
16
+ memory_context = mem.get_context(user_message)
17
+
18
+ # 2. Build system prompt with memory injected
19
+ system = "You are a helpful personal assistant."
20
+ if memory_context:
21
+ system += f"\n\nWhat you know about this user:\n{memory_context}"
22
+
23
+ # 3. Call LLM
24
+ response = client.chat.completions.create(
25
+ model="openai/gpt-oss-120b",
26
+ messages=[
27
+ {"role": "system", "content": system},
28
+ {"role": "user", "content": user_message},
29
+ ],
30
+ max_tokens=1024,
31
+ )
32
+ assistant_message = response.choices[0].message.content or ""
33
+
34
+ # 4. Ingest the turn AFTER responding
35
+ mem.ingest(user_message, assistant_message, conversation_id=conversation_id)
36
+
37
+ return assistant_message
38
+
39
+
40
+ if __name__ == "__main__":
41
+ print(chat("My name is Sujal and I prefer dark mode in all tools."))
42
+ print(chat("What do you know about me?"))
@@ -0,0 +1,64 @@
1
+ """Rate-limit-aware enterprise ingestion load test.
2
+
3
+ This intentionally counts HTTP 429 separately from accepted events. A 429 is
4
+ not treated as a data-integrity failure; the request is retried with bounded
5
+ exponential backoff when ``--retry-429`` is enabled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import time
13
+ import uuid
14
+
15
+ import httpx
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--events", type=int, default=100)
21
+ parser.add_argument("--retry-429", action="store_true")
22
+ parser.add_argument("--max-retries", type=int, default=5)
23
+ args = parser.parse_args()
24
+ key = os.environ.get("SYNTARUS_PROJECT_A_KEY")
25
+ if not key:
26
+ raise SystemExit("Set SYNTARUS_PROJECT_A_KEY first.")
27
+ base = os.environ.get("SYNTARUS_BASE_URL", "https://ai.syntarus.com/v1")
28
+ accepted = rate_limited = other = 0
29
+ started = time.perf_counter()
30
+
31
+ with httpx.Client(base_url=base, headers={"Authorization": f"Bearer {key}"}, timeout=30) as client:
32
+ for index in range(args.events):
33
+ payload = {
34
+ "user_id": f"load_{uuid.uuid4().hex[:12]}",
35
+ "messages": [{
36
+ "role": "user",
37
+ "content": f"My durable enterprise load fact {index} is stable; remember this.",
38
+ }],
39
+ }
40
+ for attempt in range(args.max_retries + 1):
41
+ response = client.post("/memories", json=payload)
42
+ if response.status_code != 429 or not args.retry_429:
43
+ break
44
+ retry_after = response.headers.get("Retry-After")
45
+ delay = float(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt, 30)
46
+ time.sleep(delay)
47
+ if response.status_code == 202:
48
+ accepted += 1
49
+ elif response.status_code == 429:
50
+ rate_limited += 1
51
+ else:
52
+ other += 1
53
+
54
+ print({
55
+ "events": args.events,
56
+ "accepted": accepted,
57
+ "rate_limited": rate_limited,
58
+ "other": other,
59
+ "elapsed_seconds": round(time.perf_counter() - started, 2),
60
+ })
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,78 @@
1
+ """Live two-project isolation smoke test for a staging Syntarus deployment.
2
+
3
+ Required environment variables:
4
+ SYNTARUS_PROJECT_A_KEY
5
+ SYNTARUS_PROJECT_B_KEY
6
+
7
+ Optional:
8
+ SYNTARUS_BASE_URL (defaults to https://ai.syntarus.com/v1)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import uuid
15
+
16
+ from syntarus import MemoryClient, SyntarusError
17
+
18
+
19
+ def required(name: str) -> str:
20
+ value = os.environ.get(name)
21
+ if not value:
22
+ raise SystemExit(f"Missing required environment variable: {name}")
23
+ return value
24
+
25
+
26
+ def main() -> None:
27
+ base_url = os.environ.get("SYNTARUS_BASE_URL", "https://ai.syntarus.com/v1")
28
+ key_a = required("SYNTARUS_PROJECT_A_KEY")
29
+ key_b = required("SYNTARUS_PROJECT_B_KEY")
30
+ marker = uuid.uuid4().hex
31
+ user_id = f"smoke_{marker[:12]}"
32
+ fact = (
33
+ f"My permanent enterprise isolation preference is marker {marker}. "
34
+ "Please remember this fact for future conversations."
35
+ )
36
+
37
+ with (
38
+ MemoryClient(key_a, base_url=base_url) as project_a,
39
+ MemoryClient(key_b, base_url=base_url) as project_b,
40
+ ):
41
+ accepted = project_a.add(
42
+ user_id=user_id,
43
+ agent_id="smoke_agent",
44
+ run_id=f"run_{marker[:12]}",
45
+ messages=[{"role": "user", "content": fact}],
46
+ metadata={"test": "enterprise-smoke"},
47
+ idempotency_key=f"smoke-{marker}",
48
+ )
49
+ project_a.wait_for_event(accepted["event_id"], timeout=120)
50
+
51
+ own = project_a.search(
52
+ "What is the enterprise isolation marker?",
53
+ user_id=user_id,
54
+ agent_id="smoke_agent",
55
+ )
56
+ if marker not in own.get("context", ""):
57
+ raise SystemExit("FAIL: Project A could not retrieve its own marker.")
58
+
59
+ other = project_b.search(
60
+ "What is the enterprise isolation marker?",
61
+ user_id=user_id,
62
+ agent_id="smoke_agent",
63
+ )
64
+ if marker in other.get("context", ""):
65
+ raise SystemExit("FAIL: Project B retrieved Project A memory.")
66
+
67
+ try:
68
+ project_b.event(accepted["event_id"])
69
+ except SyntarusError:
70
+ pass
71
+ else:
72
+ raise SystemExit("FAIL: Project B inspected Project A event.")
73
+
74
+ print("PASS: ingestion, polling, retrieval, and cross-project isolation")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
@@ -0,0 +1,38 @@
1
+ """
2
+ Example 2 — LangChain agent with MemoryOS as external memory.
3
+ Replaces LangChain's built-in ConversationBufferMemory.
4
+ """
5
+
6
+ from langchain_groq import ChatGroq
7
+ from langchain.schema import HumanMessage, SystemMessage
8
+ from memoryos_client import MemoryOS
9
+
10
+ mem = MemoryOS(api_key="mk_langchain", auto_approve=True)
11
+ llm = ChatGroq(model="openai/gpt-oss-120b")
12
+
13
+
14
+ def run_agent(user_message: str, conversation_id: str = "lc_session") -> str:
15
+ # Pull memory context
16
+ memory_context = mem.get_context(user_message)
17
+
18
+ system_content = "You are a helpful LangChain agent with long-term memory."
19
+ if memory_context:
20
+ system_content += f"\n\nUser memory:\n{memory_context}"
21
+
22
+ messages = [
23
+ SystemMessage(content=system_content),
24
+ HumanMessage(content=user_message),
25
+ ]
26
+
27
+ response = llm.invoke(messages)
28
+ assistant_text = response.content
29
+
30
+ # Write to memory (auto_approve=True so no UI review needed for bots)
31
+ mem.ingest(user_message, assistant_text, conversation_id=conversation_id)
32
+
33
+ return assistant_text
34
+
35
+
36
+ if __name__ == "__main__":
37
+ print(run_agent("I'm building a SaaS product targeting developers"))
38
+ print(run_agent("What should I focus on for my product?"))
@@ -0,0 +1,23 @@
1
+ """Verify Syntarus webhook signatures from a raw body.
2
+
3
+ Receivers must pass the exact bytes received over the network. This helper is
4
+ deliberately framework-neutral so it can be used by Flask, FastAPI, Django,
5
+ or a serverless handler without forwarding customer payloads elsewhere.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+
13
+
14
+ def verify_signature(*, secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
15
+ expected = "sha256=" + hmac.new(
16
+ secret.encode("utf-8"),
17
+ timestamp.encode("ascii") + b"." + raw_body,
18
+ hashlib.sha256,
19
+ ).hexdigest()
20
+ return hmac.compare_digest(expected, signature)
21
+
22
+
23
+ __all__ = ["verify_signature"]
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "syntarus"
7
+ version = "0.2.0"
8
+ description = "Project-scoped memory client for Syntarus"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Syntarus" }]
13
+ dependencies = ["httpx>=0.27,<1"]
14
+ keywords = ["ai", "agents", "memory", "llm"]
15
+ classifiers = ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"]
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9", "pytest-asyncio>=0.24,<1", "build>=1.2,<2", "twine>=5,<7"]
19
+
20
+ [project.urls]
21
+ Documentation = "https://www.syntarus.com/pages/developers.html"
22
+ Homepage = "https://www.syntarus.com"
23
+ Source = "https://github.com/sujalkherawat25-stack/memoryos/tree/main/sdk"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/syntarus"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = [
30
+ "src/syntarus",
31
+ "tests",
32
+ "examples",
33
+ "README.md",
34
+ "LICENSE",
35
+ "pyproject.toml",
36
+ ]
37
+ exclude = ["mcp/**", "memoryos_client.py", "**/__pycache__/**"]
@@ -0,0 +1,18 @@
1
+ """Official Syntarus project-scoped memory clients."""
2
+
3
+ from .client import AsyncMemoryClient, MemoryClient
4
+ from .exceptions import (
5
+ APIConnectionError,
6
+ APITimeoutError,
7
+ AuthenticationError,
8
+ EventFailedError,
9
+ PermissionDenied,
10
+ RateLimitError,
11
+ SyntarusError,
12
+ )
13
+
14
+ __all__ = [
15
+ "AsyncMemoryClient", "MemoryClient", "SyntarusError", "AuthenticationError",
16
+ "PermissionDenied", "RateLimitError", "APIConnectionError", "APITimeoutError",
17
+ "EventFailedError",
18
+ ]
@@ -0,0 +1,219 @@
1
+ """Synchronous and asynchronous clients for the Syntarus v1 memory API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .exceptions import (
13
+ APIConnectionError,
14
+ APITimeoutError,
15
+ AuthenticationError,
16
+ EventFailedError,
17
+ PermissionDenied,
18
+ RateLimitError,
19
+ SyntarusError,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://ai.syntarus.com/v1"
23
+
24
+
25
+ def _raise_for_response(response: httpx.Response) -> None:
26
+ if response.is_success:
27
+ return
28
+ try:
29
+ detail = response.json().get("detail", "Request failed.")
30
+ except Exception:
31
+ detail = "Request failed."
32
+ if response.status_code == 401:
33
+ raise AuthenticationError("Project API key was rejected.")
34
+ if response.status_code == 403:
35
+ raise PermissionDenied(str(detail))
36
+ if response.status_code == 429:
37
+ raw_retry = response.headers.get("Retry-After")
38
+ try:
39
+ retry_after = float(raw_retry) if raw_retry is not None else None
40
+ except ValueError:
41
+ retry_after = None
42
+ raise RateLimitError("Project API rate limit exceeded.", retry_after=retry_after)
43
+ raise SyntarusError(f"Syntarus API error ({response.status_code}): {detail}")
44
+
45
+
46
+ class _BaseClient:
47
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
48
+ if not api_key.startswith("sk_mem_"):
49
+ raise ValueError("api_key must be a Syntarus project API key")
50
+ self._base_url = base_url.rstrip("/")
51
+ self._headers = {"Authorization": f"Bearer {api_key}", "User-Agent": "syntarus-python/0.2.0"}
52
+ self._timeout = timeout
53
+
54
+ @staticmethod
55
+ def _payload(user_id: str, messages: list[dict[str, str]], agent_id: str | None, run_id: str | None, metadata: dict[str, Any] | None) -> dict[str, Any]:
56
+ return {"user_id": user_id, "messages": messages, "agent_id": agent_id, "run_id": run_id, "metadata": metadata or {}}
57
+
58
+
59
+ class MemoryClient(_BaseClient):
60
+ """Blocking client. Use ``AsyncMemoryClient`` inside async applications."""
61
+
62
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
63
+ super().__init__(api_key, base_url=base_url, timeout=timeout)
64
+ self._client = httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout)
65
+
66
+ def close(self) -> None:
67
+ self._client.close()
68
+
69
+ def __enter__(self) -> "MemoryClient":
70
+ return self
71
+
72
+ def __exit__(self, *_args: object) -> None:
73
+ self.close()
74
+
75
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
76
+ try:
77
+ response = self._client.request(method, path, **kwargs)
78
+ except httpx.TimeoutException as exc:
79
+ raise APITimeoutError("Syntarus API request timed out.") from exc
80
+ except httpx.RequestError as exc:
81
+ raise APIConnectionError("Could not connect to the Syntarus API.") from exc
82
+ _raise_for_response(response)
83
+ return response
84
+
85
+ def add(self, *, user_id: str, messages: list[dict[str, str]], agent_id: str | None = None, run_id: str | None = None, metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
86
+ response = self._request("POST", "/memories", json=self._payload(user_id, messages, agent_id, run_id, metadata), headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex})
87
+ return response.json()
88
+
89
+ def event(self, event_id: str) -> dict[str, Any]:
90
+ response = self._request("GET", f"/events/{event_id}")
91
+ return response.json()
92
+
93
+ def wait_for_event(self, event_id: str, *, timeout: float = 60.0, poll_interval: float = 0.5) -> dict[str, Any]:
94
+ if timeout <= 0 or poll_interval <= 0:
95
+ raise ValueError("timeout and poll_interval must be positive")
96
+ deadline = time.monotonic() + timeout
97
+ while True:
98
+ event = self.event(event_id)
99
+ if event.get("status") == "succeeded":
100
+ return event
101
+ if event.get("status") in {"dead_letter", "cancelled"}:
102
+ raise EventFailedError(event)
103
+ if time.monotonic() >= deadline:
104
+ raise APITimeoutError(f"Timed out waiting for event {event_id}.")
105
+ time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
106
+
107
+ def search(self, query: str, *, user_id: str, top_k: int = 10, agent_id: str | None = None) -> dict[str, Any]:
108
+ response = self._request("POST", "/memories/search", json={"user_id": user_id, "query": query, "top_k": top_k, "agent_id": agent_id})
109
+ return response.json()
110
+
111
+ def export(self, *, user_id: str, agent_id: str | None = None) -> dict[str, Any]:
112
+ params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
113
+ response = self._request("GET", "/memories/export", params=params)
114
+ return response.json()
115
+
116
+ def delete(self, memory_id: str) -> None:
117
+ self._request("DELETE", f"/memories/{memory_id}")
118
+
119
+ def delete_user(self, *, user_id: str, agent_id: str | None = None) -> None:
120
+ params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
121
+ self._request("DELETE", "/memories", params=params)
122
+
123
+ def create_webhook(self, url: str, *, event_types: list[str] | None = None) -> dict[str, Any]:
124
+ response = self._request("POST", "/webhooks", json={"url": url, "event_types": event_types or ["memory.ingest.succeeded"]})
125
+ return response.json()
126
+
127
+ def list_webhooks(self) -> dict[str, Any]:
128
+ return self._request("GET", "/webhooks").json()
129
+
130
+ def webhook_deliveries(self, *, webhook_id: str | None = None, limit: int = 100) -> dict[str, Any]:
131
+ params: dict[str, Any] = {"limit": limit}
132
+ if webhook_id:
133
+ params["webhook_id"] = webhook_id
134
+ return self._request("GET", "/webhooks/deliveries", params=params).json()
135
+
136
+ def delete_webhook(self, webhook_id: str) -> None:
137
+ self._request("DELETE", f"/webhooks/{webhook_id}")
138
+
139
+
140
+ class AsyncMemoryClient(_BaseClient):
141
+ """Async client for FastAPI, LangGraph and other async agent runtimes."""
142
+
143
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
144
+ super().__init__(api_key, base_url=base_url, timeout=timeout)
145
+ self._client = httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout)
146
+
147
+ async def aclose(self) -> None:
148
+ await self._client.aclose()
149
+
150
+ async def __aenter__(self) -> "AsyncMemoryClient":
151
+ return self
152
+
153
+ async def __aexit__(self, *_args: object) -> None:
154
+ await self.aclose()
155
+
156
+ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
157
+ try:
158
+ response = await self._client.request(method, path, **kwargs)
159
+ except httpx.TimeoutException as exc:
160
+ raise APITimeoutError("Syntarus API request timed out.") from exc
161
+ except httpx.RequestError as exc:
162
+ raise APIConnectionError("Could not connect to the Syntarus API.") from exc
163
+ _raise_for_response(response)
164
+ return response
165
+
166
+ async def add(self, *, user_id: str, messages: list[dict[str, str]], agent_id: str | None = None, run_id: str | None = None, metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
167
+ response = await self._request("POST", "/memories", json=self._payload(user_id, messages, agent_id, run_id, metadata), headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex})
168
+ return response.json()
169
+
170
+ async def event(self, event_id: str) -> dict[str, Any]:
171
+ response = await self._request("GET", f"/events/{event_id}")
172
+ return response.json()
173
+
174
+ async def wait_for_event(self, event_id: str, *, timeout: float = 60.0, poll_interval: float = 0.5) -> dict[str, Any]:
175
+ if timeout <= 0 or poll_interval <= 0:
176
+ raise ValueError("timeout and poll_interval must be positive")
177
+ deadline = time.monotonic() + timeout
178
+ while True:
179
+ event = await self.event(event_id)
180
+ if event.get("status") == "succeeded":
181
+ return event
182
+ if event.get("status") in {"dead_letter", "cancelled"}:
183
+ raise EventFailedError(event)
184
+ if time.monotonic() >= deadline:
185
+ raise APITimeoutError(f"Timed out waiting for event {event_id}.")
186
+ await asyncio.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
187
+
188
+ async def search(self, query: str, *, user_id: str, top_k: int = 10, agent_id: str | None = None) -> dict[str, Any]:
189
+ response = await self._request("POST", "/memories/search", json={"user_id": user_id, "query": query, "top_k": top_k, "agent_id": agent_id})
190
+ return response.json()
191
+
192
+ async def export(self, *, user_id: str, agent_id: str | None = None) -> dict[str, Any]:
193
+ params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
194
+ response = await self._request("GET", "/memories/export", params=params)
195
+ return response.json()
196
+
197
+ async def delete(self, memory_id: str) -> None:
198
+ await self._request("DELETE", f"/memories/{memory_id}")
199
+
200
+ async def delete_user(self, *, user_id: str, agent_id: str | None = None) -> None:
201
+ params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
202
+ await self._request("DELETE", "/memories", params=params)
203
+
204
+ async def create_webhook(self, url: str, *, event_types: list[str] | None = None) -> dict[str, Any]:
205
+ response = await self._request("POST", "/webhooks", json={"url": url, "event_types": event_types or ["memory.ingest.succeeded"]})
206
+ return response.json()
207
+
208
+ async def list_webhooks(self) -> dict[str, Any]:
209
+ return (await self._request("GET", "/webhooks")).json()
210
+
211
+ async def webhook_deliveries(self, *, webhook_id: str | None = None, limit: int = 100) -> dict[str, Any]:
212
+ params: dict[str, Any] = {"limit": limit}
213
+ if webhook_id:
214
+ params["webhook_id"] = webhook_id
215
+ response = await self._request("GET", "/webhooks/deliveries", params=params)
216
+ return response.json()
217
+
218
+ async def delete_webhook(self, webhook_id: str) -> None:
219
+ await self._request("DELETE", f"/webhooks/{webhook_id}")
@@ -0,0 +1,38 @@
1
+ """Typed API errors. Exception messages intentionally omit request secrets."""
2
+
3
+
4
+ class SyntarusError(Exception):
5
+ """Base exception raised for an unsuccessful Syntarus API response."""
6
+
7
+
8
+ class AuthenticationError(SyntarusError):
9
+ """The project API key is invalid, expired, or revoked."""
10
+
11
+
12
+ class PermissionDenied(SyntarusError):
13
+ """The project API key does not have the required scope."""
14
+
15
+
16
+ class RateLimitError(SyntarusError):
17
+ """The project API key has exceeded its request budget."""
18
+
19
+ def __init__(self, message: str, *, retry_after: float | None = None):
20
+ super().__init__(message)
21
+ self.retry_after = retry_after
22
+
23
+
24
+ class APIConnectionError(SyntarusError):
25
+ """The Syntarus API could not be reached."""
26
+
27
+
28
+ class APITimeoutError(APIConnectionError):
29
+ """The Syntarus API did not respond before the configured timeout."""
30
+
31
+
32
+ class EventFailedError(SyntarusError):
33
+ """An accepted asynchronous memory event reached a terminal failure."""
34
+
35
+ def __init__(self, event: dict):
36
+ self.event = event
37
+ detail = event.get("last_error") or f"event ended with status {event.get('status')}"
38
+ super().__init__(str(detail))
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from syntarus import AsyncMemoryClient, EventFailedError, MemoryClient, RateLimitError
9
+
10
+
11
+ def _sync_client(handler) -> MemoryClient:
12
+ client = MemoryClient("sk_mem_test", base_url="https://api.test/v1")
13
+ client._client.close()
14
+ client._client = httpx.Client(
15
+ base_url="https://api.test/v1",
16
+ headers=client._headers,
17
+ transport=httpx.MockTransport(handler),
18
+ )
19
+ return client
20
+
21
+
22
+ def _async_client(handler) -> AsyncMemoryClient:
23
+ client = AsyncMemoryClient("sk_mem_test", base_url="https://api.test/v1")
24
+ client._client = httpx.AsyncClient(
25
+ base_url="https://api.test/v1",
26
+ headers=client._headers,
27
+ transport=httpx.MockTransport(handler),
28
+ )
29
+ return client
30
+
31
+
32
+ def test_add_sends_scope_metadata_and_idempotency_key():
33
+ def handler(request: httpx.Request) -> httpx.Response:
34
+ assert request.url.path == "/v1/memories"
35
+ assert request.headers["authorization"] == "Bearer sk_mem_test"
36
+ assert request.headers["idempotency-key"] == "call-1"
37
+ body = json.loads(request.content)
38
+ assert body["agent_id"] == "support"
39
+ assert body["run_id"] == "ticket-1"
40
+ assert body["metadata"] == {"channel": "voice"}
41
+ return httpx.Response(202, json={"event_id": "evt_1", "status": "pending"})
42
+
43
+ with _sync_client(handler) as client:
44
+ response = client.add(
45
+ user_id="customer_1",
46
+ agent_id="support",
47
+ run_id="ticket-1",
48
+ metadata={"channel": "voice"},
49
+ messages=[{"role": "user", "content": "hello"}],
50
+ idempotency_key="call-1",
51
+ )
52
+ assert response["event_id"] == "evt_1"
53
+
54
+
55
+ def test_wait_for_event_returns_success_without_extra_sleep():
56
+ statuses = iter(["processing", "succeeded"])
57
+
58
+ def handler(_request: httpx.Request) -> httpx.Response:
59
+ status = next(statuses)
60
+ return httpx.Response(200, json={"id": "evt_1", "status": status})
61
+
62
+ with _sync_client(handler) as client:
63
+ event = client.wait_for_event("evt_1", timeout=1, poll_interval=0.001)
64
+ assert event["status"] == "succeeded"
65
+
66
+
67
+ def test_wait_for_event_raises_terminal_error():
68
+ def handler(_request: httpx.Request) -> httpx.Response:
69
+ return httpx.Response(200, json={"id": "evt_1", "status": "dead_letter", "last_error": "extractor failed"})
70
+
71
+ with _sync_client(handler) as client, pytest.raises(EventFailedError, match="extractor failed") as exc:
72
+ client.wait_for_event("evt_1", timeout=1, poll_interval=0.001)
73
+ assert exc.value.event["id"] == "evt_1"
74
+
75
+
76
+ def test_rate_limit_exposes_retry_after():
77
+ def handler(_request: httpx.Request) -> httpx.Response:
78
+ return httpx.Response(429, headers={"Retry-After": "7"}, json={"detail": "slow down"})
79
+
80
+ with _sync_client(handler) as client, pytest.raises(RateLimitError) as exc:
81
+ client.search("hello", user_id="customer_1")
82
+ assert exc.value.retry_after == 7
83
+
84
+
85
+ def test_webhook_management_surface():
86
+ requests: list[httpx.Request] = []
87
+
88
+ def handler(request: httpx.Request) -> httpx.Response:
89
+ requests.append(request)
90
+ if request.method == "POST":
91
+ return httpx.Response(201, json={"webhook": {"id": "wh_1"}, "signing_secret": "secret"})
92
+ if request.method == "GET" and request.url.path.endswith("/deliveries"):
93
+ return httpx.Response(200, json={"deliveries": []})
94
+ if request.method == "GET":
95
+ return httpx.Response(200, json={"webhooks": []})
96
+ return httpx.Response(204)
97
+
98
+ with _sync_client(handler) as client:
99
+ created = client.create_webhook("https://example.com/events")
100
+ client.list_webhooks()
101
+ client.webhook_deliveries(webhook_id="wh_1", limit=25)
102
+ client.delete_webhook("wh_1")
103
+
104
+ assert created["signing_secret"] == "secret"
105
+ assert requests[2].url.params["webhook_id"] == "wh_1"
106
+ assert requests[2].url.params["limit"] == "25"
107
+
108
+
109
+ def test_optional_query_parameters_are_omitted_not_sent_empty():
110
+ requests: list[httpx.Request] = []
111
+
112
+ def handler(request: httpx.Request) -> httpx.Response:
113
+ requests.append(request)
114
+ if request.method == "GET" and request.url.path.endswith("/export"):
115
+ return httpx.Response(200, json={"memories": []})
116
+ if request.method == "GET":
117
+ return httpx.Response(200, json={"deliveries": []})
118
+ return httpx.Response(204)
119
+
120
+ with _sync_client(handler) as client:
121
+ client.export(user_id="customer_1")
122
+ client.delete_user(user_id="customer_1")
123
+ client.webhook_deliveries()
124
+
125
+ assert "agent_id" not in requests[0].url.params
126
+ assert "agent_id" not in requests[1].url.params
127
+ assert "webhook_id" not in requests[2].url.params
128
+
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_async_search_and_delete_are_agent_scoped():
132
+ requests: list[httpx.Request] = []
133
+
134
+ def handler(request: httpx.Request) -> httpx.Response:
135
+ requests.append(request)
136
+ if request.method == "POST":
137
+ assert json.loads(request.content)["agent_id"] == "sales"
138
+ return httpx.Response(200, json={"results": [], "context": "", "latency_ms": 1})
139
+ return httpx.Response(204)
140
+
141
+ async with _async_client(handler) as client:
142
+ await client.search("preferences", user_id="customer_1", agent_id="sales")
143
+ await client.delete_user(user_id="customer_1", agent_id="sales")
144
+
145
+ assert requests[1].url.params["agent_id"] == "sales"
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+ from examples.webhook_verify import verify_signature
7
+
8
+
9
+ def test_webhook_verifier_uses_raw_body_and_timestamp():
10
+ secret = "test-secret"
11
+ timestamp = "1700000000"
12
+ body = b'{"z":1,"a":2}'
13
+ signature = "sha256=" + hmac.new(
14
+ secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
15
+ ).hexdigest()
16
+
17
+ assert verify_signature(secret=secret, timestamp=timestamp, raw_body=body, signature=signature)
18
+ assert not verify_signature(secret=secret, timestamp=timestamp, raw_body=b'{"a":2,"z":1}', signature=signature)
19
+ assert not verify_signature(secret=secret, timestamp="1700000001", raw_body=body, signature=signature)