korely-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ """korely-memory — the Python SDK for Korely Agents.
2
+
3
+ A typed, dependency-free client over the Korely REST API. Every method maps
4
+ 1:1 onto an endpoint; the moat (typed bi-temporal facts, contradiction
5
+ checking) runs server-side.
6
+
7
+ from korely_memory import Korely
8
+
9
+ korely = Korely(api_key="kor_live_...", region="eu")
10
+ korely.add("User prefers TypeScript", agent_id="coding-assistant")
11
+ ctx = korely.get_context(query="what does the user like?", user_id="dana")
12
+ """
13
+ from .client import Korely, __version__
14
+ from .exceptions import (
15
+ APIError,
16
+ AuthenticationError,
17
+ KorelyError,
18
+ NamespaceForbiddenError,
19
+ NotFoundError,
20
+ QuotaExceededError,
21
+ StaleWriteError,
22
+ )
23
+ from .models import (
24
+ BatchJob,
25
+ BulkReceipt,
26
+ Context,
27
+ DeleteReceipt,
28
+ Fact,
29
+ HistoryEvent,
30
+ Memory,
31
+ MemoryHistory,
32
+ MemoryPage,
33
+ Profile,
34
+ SearchHit,
35
+ UserScope,
36
+ UsersPage,
37
+ )
38
+
39
+ __all__ = [
40
+ "Korely",
41
+ "__version__",
42
+ # exceptions
43
+ "KorelyError",
44
+ "AuthenticationError",
45
+ "NamespaceForbiddenError",
46
+ "NotFoundError",
47
+ "StaleWriteError",
48
+ "QuotaExceededError",
49
+ "APIError",
50
+ # models
51
+ "Memory",
52
+ "Fact",
53
+ "SearchHit",
54
+ "MemoryPage",
55
+ "DeleteReceipt",
56
+ "BulkReceipt",
57
+ "Context",
58
+ "BatchJob",
59
+ "Profile",
60
+ "HistoryEvent",
61
+ "MemoryHistory",
62
+ "UserScope",
63
+ "UsersPage",
64
+ ]
@@ -0,0 +1,308 @@
1
+ """The Korely client. A thin, dependency-free HTTP wrapper: every method maps
2
+ 1:1 onto a REST endpoint (see /agents/docs/surfaces/sdk). All the intelligence
3
+ — embeddings, entity + typed-fact extraction, contradiction checking — runs
4
+ server-side, so this stays a small client over stdlib urllib."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from typing import Any, List, Optional
10
+ from urllib import error as _urlerror
11
+ from urllib import parse as _urlparse
12
+ from urllib import request as _urlrequest
13
+
14
+ from .exceptions import (
15
+ APIError,
16
+ AuthenticationError,
17
+ KorelyError,
18
+ NamespaceForbiddenError,
19
+ NotFoundError,
20
+ QuotaExceededError,
21
+ StaleWriteError,
22
+ )
23
+ from .models import (
24
+ BatchJob,
25
+ BulkReceipt,
26
+ Context,
27
+ DeleteReceipt,
28
+ Fact,
29
+ Memory,
30
+ MemoryHistory,
31
+ MemoryPage,
32
+ Profile,
33
+ SearchHit,
34
+ UserScope,
35
+ UsersPage,
36
+ )
37
+
38
+ __version__ = "0.1.0"
39
+
40
+ # All keys are the EU region; data is stored and processed in the EU.
41
+ _REGIONS = {"eu": "https://api.korely.ai"}
42
+
43
+
44
+ def _clean(d: dict) -> dict:
45
+ """Drop None values so we never send null params/body fields."""
46
+ return {k: v for k, v in d.items() if v is not None}
47
+
48
+
49
+ def _coerce_content(content: Any) -> str:
50
+ """add() accepts a string OR a list of chat messages
51
+ [{"role": ..., "content": ...}] (Mem0/Supermemory shape). A message list is
52
+ joined into one text block (``role: content`` per line) before sending —
53
+ the server stores and mines the resulting text. Empty or role-only messages
54
+ are dropped (no dangling ``role:`` lines)."""
55
+ if isinstance(content, str):
56
+ return content
57
+ if isinstance(content, (list, tuple)):
58
+ parts: List[str] = []
59
+ for m in content:
60
+ if isinstance(m, dict):
61
+ role = (m.get("role") or "").strip()
62
+ body = m.get("content")
63
+ body = "" if body is None else str(body).strip()
64
+ if role and body:
65
+ parts.append(f"{role}: {body}")
66
+ elif body:
67
+ parts.append(body)
68
+ # role-only / empty message → dropped
69
+ else:
70
+ s = str(m).strip()
71
+ if s:
72
+ parts.append(s)
73
+ return "\n".join(parts)
74
+ return str(content)
75
+
76
+
77
+ class Korely:
78
+ """Typed client over the Korely REST API.
79
+
80
+ >>> korely = Korely(api_key="kor_live_...", region="eu")
81
+ >>> korely.add("User prefers TypeScript", agent_id="coding-assistant")
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ api_key: Optional[str] = None,
87
+ region: str = "eu",
88
+ base_url: Optional[str] = None,
89
+ timeout: float = 30.0,
90
+ ):
91
+ self.api_key = api_key or os.environ.get("KORELY_API_KEY")
92
+ if not self.api_key:
93
+ raise KorelyError(
94
+ "No API key. Pass api_key='kor_live_...' or set KORELY_API_KEY."
95
+ )
96
+ self.base_url = (base_url or _REGIONS.get(region) or _REGIONS["eu"]).rstrip("/")
97
+ self.timeout = timeout
98
+
99
+ # ── low-level transport (the one seam tests override) ──────────────────
100
+ def _send(self, method: str, path: str, *, params: Optional[dict] = None,
101
+ json_body: Optional[Any] = None) -> "tuple[int, dict]":
102
+ url = self.base_url + path
103
+ if params:
104
+ qs = _urlparse.urlencode(_clean(params), doseq=True)
105
+ if qs:
106
+ url += "?" + qs
107
+ data = json.dumps(json_body).encode("utf-8") if json_body is not None else None
108
+ headers = {
109
+ "Authorization": "Bearer " + self.api_key,
110
+ "Accept": "application/json",
111
+ "User-Agent": "korely-memory-python/" + __version__,
112
+ }
113
+ if data is not None:
114
+ headers["Content-Type"] = "application/json"
115
+ req = _urlrequest.Request(url, data=data, method=method, headers=headers)
116
+ try:
117
+ with _urlrequest.urlopen(req, timeout=self.timeout) as resp:
118
+ raw = resp.read()
119
+ status = getattr(resp, "status", resp.getcode())
120
+ return status, (json.loads(raw) if raw else {})
121
+ except _urlerror.HTTPError as e:
122
+ raw = e.read()
123
+ try:
124
+ parsed = json.loads(raw) if raw else {}
125
+ except ValueError:
126
+ parsed = {"message": raw.decode("utf-8", "replace")}
127
+ if not isinstance(parsed, dict):
128
+ parsed = {"message": str(parsed)}
129
+ retry_after = e.headers.get("Retry-After") if e.headers else None
130
+ parsed.setdefault("_retry_after", retry_after)
131
+ return e.code, parsed
132
+ except _urlerror.URLError as e:
133
+ raise KorelyError("Connection error: " + str(e.reason))
134
+
135
+ def _call(self, method: str, path: str, *, params: Optional[dict] = None,
136
+ json_body: Optional[Any] = None) -> dict:
137
+ status, body = self._send(method, path, params=params, json_body=json_body)
138
+ if status >= 400:
139
+ self._raise(status, body if isinstance(body, dict) else {})
140
+ return body if isinstance(body, dict) else {}
141
+
142
+ @staticmethod
143
+ def _raise(status: int, body: dict) -> None:
144
+ code = body.get("code")
145
+ msg = body.get("message") or code or ("HTTP " + str(status))
146
+ if status == 401:
147
+ raise AuthenticationError(msg, status=status, code=code)
148
+ if status == 403:
149
+ raise NamespaceForbiddenError(msg, status=status, code=code)
150
+ if status == 404:
151
+ raise NotFoundError(msg, status=status, code=code)
152
+ if status == 409:
153
+ raise StaleWriteError(msg, status=status, code=code)
154
+ if status == 429:
155
+ ra = body.get("_retry_after") or body.get("retry_after")
156
+ try:
157
+ ra = int(ra) if ra is not None else None
158
+ except (ValueError, TypeError):
159
+ ra = None
160
+ raise QuotaExceededError(msg, status=status, code=code, retry_after=ra)
161
+ raise APIError(msg, status=status, code=code)
162
+
163
+ # ── memories ───────────────────────────────────────────────────────────
164
+ def add(self, content: "str | list", *, agent_id: Optional[str] = None,
165
+ user_id: Optional[str] = None, run_id: Optional[str] = None,
166
+ metadata: Optional[dict] = None) -> Memory:
167
+ """POST /v1/memories — store a memory; returns it with extracted facts.
168
+
169
+ ``content`` is a string, or a list of chat messages
170
+ ``[{"role": ..., "content": ...}]`` (Mem0/Supermemory shape), which is
171
+ joined into one text block before sending."""
172
+ text = _coerce_content(content)
173
+ if not text.strip():
174
+ raise KorelyError("content is empty — pass a non-blank string or messages with content.")
175
+ body = self._call("POST", "/v1/memories", json_body=_clean({
176
+ "content": text, "agent_id": agent_id, "user_id": user_id,
177
+ "run_id": run_id, "metadata": metadata,
178
+ }))
179
+ return Memory.from_dict(body)
180
+
181
+ def search(self, query: str, *, user_id: Optional[str] = None,
182
+ agent_id: Optional[str] = None, limit: int = 10) -> List[SearchHit]:
183
+ """POST /v1/memories/search — hybrid retrieval, ranked by score."""
184
+ body = self._call("POST", "/v1/memories/search", json_body=_clean({
185
+ "query": query, "user_id": user_id, "agent_id": agent_id, "limit": limit,
186
+ }))
187
+ return [SearchHit.from_dict(h) for h in body.get("results", [])]
188
+
189
+ def get_all(self, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
190
+ limit: int = 50, offset: int = 0) -> MemoryPage:
191
+ """GET /v1/memories — list a scope, newest first."""
192
+ body = self._call("GET", "/v1/memories", params=_clean({
193
+ "user_id": user_id, "agent_id": agent_id, "limit": limit, "offset": offset,
194
+ }))
195
+ return MemoryPage.from_dict(body)
196
+
197
+ def get(self, memory_id: str) -> Memory:
198
+ """GET /v1/memories/:id — full content, metadata, extracted facts."""
199
+ return Memory.from_dict(self._call("GET", "/v1/memories/" + memory_id))
200
+
201
+ def update(self, memory_id: str, *, content: str,
202
+ expected_updated_at: Optional[str] = None) -> Memory:
203
+ """PATCH /v1/memories/:id — re-runs extraction. Pass
204
+ ``expected_updated_at`` for optimistic concurrency (raises
205
+ StaleWriteError instead of clobbering)."""
206
+ body = self._call("PATCH", "/v1/memories/" + memory_id, json_body=_clean({
207
+ "content": content, "expected_updated_at": expected_updated_at,
208
+ }))
209
+ return Memory.from_dict(body)
210
+
211
+ def delete(self, memory_id: str) -> DeleteReceipt:
212
+ """DELETE /v1/memories/:id — forget one memory (audited invalidation)."""
213
+ return DeleteReceipt.from_dict(self._call("DELETE", "/v1/memories/" + memory_id))
214
+
215
+ def delete_all(self, *, user_id: str) -> BulkReceipt:
216
+ """DELETE /v1/users/:user_id/memories — forget every memory + fact for
217
+ one end user in a single call."""
218
+ return BulkReceipt.from_dict(
219
+ self._call("DELETE", "/v1/users/" + user_id + "/memories")
220
+ )
221
+
222
+ def history(self, memory_id: str) -> MemoryHistory:
223
+ """GET /v1/memories/:id/history — the lifecycle timeline of a memory:
224
+ created / updated / deleted, plus every typed fact it produced (and the
225
+ moment each was learned or superseded)."""
226
+ return MemoryHistory.from_dict(
227
+ self._call("GET", "/v1/memories/" + memory_id + "/history")
228
+ )
229
+
230
+ def users(self, *, agent_id: Optional[str] = None, limit: int = 50,
231
+ offset: int = 0) -> UsersPage:
232
+ """GET /v1/users — the end users you've stored data for (the distinct
233
+ ``user_id`` namespaces), each with active memory + fact counts and
234
+ last-active time. The default (null) namespace is omitted. Returns a
235
+ UsersPage: iterable like a list, with ``.total`` for pagination."""
236
+ body = self._call("GET", "/v1/users", params=_clean({
237
+ "agent_id": agent_id, "limit": limit, "offset": offset,
238
+ }))
239
+ return UsersPage.from_dict(body)
240
+
241
+ # ── facts ────────────────────────────────────────────────────────────────
242
+ def get_facts(self, *, subject: Optional[str] = None, entity: Optional[str] = None,
243
+ predicate: Optional[str] = None, predicate_family: Optional[str] = None,
244
+ include_invalidated: bool = False, as_of: Optional[str] = None,
245
+ user_id: Optional[str] = None, agent_id: Optional[str] = None,
246
+ limit: int = 50, offset: int = 0) -> List[Fact]:
247
+ """GET /v1/facts — typed (subject, predicate, object) triples with
248
+ bi-temporal validity. Pass ``as_of`` (ISO date) for a point-in-time
249
+ query: what was true on that date."""
250
+ params = _clean({
251
+ "subject": subject, "entity": entity, "predicate": predicate,
252
+ "predicate_family": predicate_family, "as_of": as_of,
253
+ "user_id": user_id, "agent_id": agent_id, "limit": limit, "offset": offset,
254
+ })
255
+ if include_invalidated:
256
+ params["include_invalidated"] = "true"
257
+ body = self._call("GET", "/v1/facts", params=params)
258
+ return [Fact.from_dict(f) for f in body.get("facts", [])]
259
+
260
+ def add_fact_triple(self, subject: str, predicate: str, object: str, *,
261
+ user_id: Optional[str] = None, agent_id: Optional[str] = None,
262
+ run_id: Optional[str] = None, subject_type: str = "unknown",
263
+ object_is_literal: bool = False, confidence: float = 0.9,
264
+ valid_from: Optional[str] = None) -> Fact:
265
+ """POST /v1/facts — write a typed (subject, predicate, object) triple
266
+ directly, skipping extraction. The server runs the contradiction check
267
+ and the fact is bi-temporal: pass ``valid_from`` (ISO date) for a
268
+ historical fact. Returns the written Fact, with ``invalidated`` listing
269
+ any fact ids it superseded."""
270
+ body = self._call("POST", "/v1/facts", json_body=_clean({
271
+ "subject": subject, "predicate": predicate, "object": object,
272
+ "user_id": user_id, "agent_id": agent_id, "run_id": run_id,
273
+ "subject_type": subject_type, "object_is_literal": object_is_literal,
274
+ "confidence": confidence, "valid_from": valid_from,
275
+ }))
276
+ return Fact.from_dict(body)
277
+
278
+ def get_profile(self, *, user_id: str, agent_id: Optional[str] = None,
279
+ as_of: Optional[str] = None) -> Profile:
280
+ """GET /v1/profile — the assembled profile of one end user: the active
281
+ typed facts known about them, the end user's own facts first, grouped by
282
+ predicate family. ``user_id`` is required. Pass ``as_of`` (ISO date) for
283
+ the point-in-time profile ("what we knew on 2026-03-01")."""
284
+ body = self._call("GET", "/v1/profile", params=_clean({
285
+ "user_id": user_id, "agent_id": agent_id, "as_of": as_of,
286
+ }))
287
+ return Profile.from_dict(body)
288
+
289
+ # ── context ──────────────────────────────────────────────────────────────
290
+ def get_context(self, *, query: str, user_id: Optional[str] = None,
291
+ agent_id: Optional[str] = None, token_budget: int = 800) -> Context:
292
+ """GET /v1/context — one call that assembles a prompt-ready context
293
+ block (profile + relevant facts + memories) within a token budget."""
294
+ body = self._call("GET", "/v1/context", params=_clean({
295
+ "query": query, "user_id": user_id, "agent_id": agent_id,
296
+ "token_budget": token_budget,
297
+ }))
298
+ return Context.from_dict(body)
299
+
300
+ # ── batch ────────────────────────────────────────────────────────────────
301
+ def batch(self, memories: List[dict]) -> BatchJob:
302
+ """POST /v1/batch — bulk import (up to 500 memory objects), async."""
303
+ body = self._call("POST", "/v1/batch", json_body={"memories": list(memories)})
304
+ return BatchJob.from_dict(body)
305
+
306
+ def batch_status(self, job_id: str) -> BatchJob:
307
+ """GET /v1/batch/:id — poll an import job."""
308
+ return BatchJob.from_dict(self._call("GET", "/v1/batch/" + job_id))
@@ -0,0 +1,45 @@
1
+ """Typed exceptions mapping 1:1 onto the REST error codes. All subclass
2
+ ``KorelyError`` so a caller can catch everything with one except."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class KorelyError(Exception):
9
+ """Base for every error the SDK raises."""
10
+
11
+ def __init__(self, message: str = "", *, status: Optional[int] = None, code: Optional[str] = None):
12
+ super().__init__(message or code or "Korely error")
13
+ self.message = message
14
+ self.status = status
15
+ self.code = code
16
+
17
+
18
+ class AuthenticationError(KorelyError):
19
+ """401 — missing, malformed, or revoked API key."""
20
+
21
+
22
+ class NamespaceForbiddenError(KorelyError):
23
+ """403 — the key lacks the scope required for this call."""
24
+
25
+
26
+ class NotFoundError(KorelyError):
27
+ """404 — memory or fact id does not exist, or was forgotten."""
28
+
29
+
30
+ class StaleWriteError(KorelyError):
31
+ """409 — update() with an expected_updated_at older than the record."""
32
+
33
+
34
+ class QuotaExceededError(KorelyError):
35
+ """429 — past the soft cap. Carries ``retry_after`` in seconds when the
36
+ server sent a Retry-After header."""
37
+
38
+ def __init__(self, message: str = "", *, status: Optional[int] = None,
39
+ code: Optional[str] = None, retry_after: Optional[int] = None):
40
+ super().__init__(message, status=status, code=code)
41
+ self.retry_after = retry_after
42
+
43
+
44
+ class APIError(KorelyError):
45
+ """Any other non-2xx response (validation 422, server 5xx, …)."""
@@ -0,0 +1,239 @@
1
+ """Response models. The JSON shapes from the REST API reference are the
2
+ attribute shapes here — each ``from_dict`` keeps documented fields and ignores
3
+ anything new, so a server that returns a superset never breaks an old SDK."""
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field, fields
7
+ from typing import Any, List, Optional
8
+
9
+
10
+ def _take(cls, d: Optional[dict]) -> dict:
11
+ """Keep only the keys that are declared fields of ``cls``."""
12
+ names = {f.name for f in fields(cls)}
13
+ return {k: v for k, v in (d or {}).items() if k in names}
14
+
15
+
16
+ @dataclass
17
+ class Fact:
18
+ id: Optional[str] = None
19
+ subject: Optional[str] = None
20
+ predicate: Optional[str] = None
21
+ object: Optional[str] = None
22
+ predicate_family: Optional[str] = None
23
+ subject_type: Optional[str] = None
24
+ predicate_raw: Optional[str] = None
25
+ object_is_literal: Optional[bool] = None
26
+ confidence: Optional[float] = None
27
+ user_id: Optional[str] = None
28
+ agent_id: Optional[str] = None
29
+ valid_from: Optional[str] = None
30
+ invalid_at: Optional[str] = None
31
+ invalidated_by: Optional[str] = None
32
+ # write-shape only: ids this fact superseded (from add()/update())
33
+ invalidated: List[str] = field(default_factory=list)
34
+ source_memory_id: Optional[str] = None
35
+ created_at: Optional[str] = None
36
+
37
+ @classmethod
38
+ def from_dict(cls, d: dict) -> "Fact":
39
+ return cls(**_take(cls, d))
40
+
41
+
42
+ @dataclass
43
+ class Memory:
44
+ id: Optional[str] = None
45
+ content: Optional[str] = None
46
+ user_id: Optional[str] = None
47
+ agent_id: Optional[str] = None
48
+ run_id: Optional[str] = None
49
+ metadata: dict = field(default_factory=dict)
50
+ created_at: Optional[str] = None
51
+ updated_at: Optional[str] = None
52
+ facts: List[Fact] = field(default_factory=list)
53
+
54
+ @classmethod
55
+ def from_dict(cls, d: dict) -> "Memory":
56
+ d = dict(d or {})
57
+ facts = [Fact.from_dict(f) for f in (d.get("facts") or [])]
58
+ obj = cls(**_take(cls, d))
59
+ obj.facts = facts
60
+ return obj
61
+
62
+
63
+ @dataclass
64
+ class SearchHit:
65
+ id: Optional[str] = None
66
+ score: Optional[float] = None
67
+ snippet: Optional[str] = None
68
+ user_id: Optional[str] = None
69
+ agent_id: Optional[str] = None
70
+ metadata: dict = field(default_factory=dict)
71
+
72
+ @classmethod
73
+ def from_dict(cls, d: dict) -> "SearchHit":
74
+ return cls(**_take(cls, d))
75
+
76
+
77
+ @dataclass
78
+ class MemoryPage:
79
+ """Iterable page of memories with a ``total`` count."""
80
+ memories: List[Memory] = field(default_factory=list)
81
+ total: int = 0
82
+
83
+ def __iter__(self):
84
+ return iter(self.memories)
85
+
86
+ def __len__(self) -> int:
87
+ return len(self.memories)
88
+
89
+ def __getitem__(self, i):
90
+ return self.memories[i]
91
+
92
+ @classmethod
93
+ def from_dict(cls, d: dict) -> "MemoryPage":
94
+ d = d or {}
95
+ return cls(
96
+ memories=[Memory.from_dict(m) for m in (d.get("memories") or [])],
97
+ total=int(d.get("total", 0)),
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class DeleteReceipt:
103
+ id: Optional[str] = None
104
+ status: Optional[str] = None
105
+ facts_invalidated: Optional[int] = None
106
+ audit_id: Optional[str] = None
107
+
108
+ @classmethod
109
+ def from_dict(cls, d: dict) -> "DeleteReceipt":
110
+ return cls(**_take(cls, d))
111
+
112
+
113
+ @dataclass
114
+ class BulkReceipt:
115
+ user_id: Optional[str] = None
116
+ memories_forgotten: Optional[int] = None
117
+ facts_invalidated: Optional[int] = None
118
+ audit_id: Optional[str] = None
119
+
120
+ @classmethod
121
+ def from_dict(cls, d: dict) -> "BulkReceipt":
122
+ return cls(**_take(cls, d))
123
+
124
+
125
+ @dataclass
126
+ class Context:
127
+ context: str = ""
128
+ tokens: int = 0
129
+ sources: List[str] = field(default_factory=list)
130
+
131
+ @classmethod
132
+ def from_dict(cls, d: dict) -> "Context":
133
+ return cls(**_take(cls, d))
134
+
135
+
136
+ @dataclass
137
+ class BatchJob:
138
+ id: Optional[str] = None
139
+ status: Optional[str] = None
140
+ received: Optional[int] = None
141
+ imported: Optional[int] = None
142
+ failed: Optional[int] = None
143
+ errors: List[Any] = field(default_factory=list)
144
+
145
+ @classmethod
146
+ def from_dict(cls, d: dict) -> "BatchJob":
147
+ return cls(**_take(cls, d))
148
+
149
+
150
+ @dataclass
151
+ class Profile:
152
+ """The assembled profile of one end user: active typed facts known about
153
+ them, the end user's own facts first, plus a ``by_family`` grouping.
154
+ ``as_of`` echoes a point-in-time request. ``truncated`` is True when
155
+ ``total`` exceeds the number of facts returned (the profile is capped at 200)."""
156
+ user_id: Optional[str] = None
157
+ as_of: Optional[str] = None
158
+ facts: List[Fact] = field(default_factory=list)
159
+ by_family: dict = field(default_factory=dict)
160
+ total: int = 0
161
+ truncated: bool = False
162
+
163
+ @classmethod
164
+ def from_dict(cls, d: dict) -> "Profile":
165
+ d = dict(d or {})
166
+ facts = [Fact.from_dict(f) for f in (d.get("facts") or [])]
167
+ by_family = {
168
+ k: [Fact.from_dict(f) for f in (v or [])]
169
+ for k, v in (d.get("by_family") or {}).items()
170
+ }
171
+ obj = cls(**_take(cls, d))
172
+ obj.facts = facts
173
+ obj.by_family = by_family
174
+ return obj
175
+
176
+
177
+ @dataclass
178
+ class HistoryEvent:
179
+ """One point on a memory's timeline: created | updated | fact_extracted |
180
+ fact_invalidated | deleted. ``fact`` / ``fact_id`` are set on fact events."""
181
+ event: Optional[str] = None
182
+ at: Optional[str] = None
183
+ fact: Optional[str] = None
184
+ fact_id: Optional[str] = None
185
+
186
+ @classmethod
187
+ def from_dict(cls, d: dict) -> "HistoryEvent":
188
+ return cls(**_take(cls, d))
189
+
190
+
191
+ @dataclass
192
+ class MemoryHistory:
193
+ id: Optional[str] = None
194
+ events: List[HistoryEvent] = field(default_factory=list)
195
+
196
+ @classmethod
197
+ def from_dict(cls, d: dict) -> "MemoryHistory":
198
+ d = dict(d or {})
199
+ events = [HistoryEvent.from_dict(e) for e in (d.get("events") or [])]
200
+ obj = cls(**_take(cls, d))
201
+ obj.events = events
202
+ return obj
203
+
204
+
205
+ @dataclass
206
+ class UserScope:
207
+ """One end user the developer has stored data for, with counts."""
208
+ user_id: Optional[str] = None
209
+ memories: int = 0
210
+ facts: int = 0
211
+ last_active: Optional[str] = None
212
+
213
+ @classmethod
214
+ def from_dict(cls, d: dict) -> "UserScope":
215
+ return cls(**_take(cls, d))
216
+
217
+
218
+ @dataclass
219
+ class UsersPage:
220
+ """Iterable page of end users with a ``total`` count (for pagination)."""
221
+ users: List[UserScope] = field(default_factory=list)
222
+ total: int = 0
223
+
224
+ def __iter__(self):
225
+ return iter(self.users)
226
+
227
+ def __len__(self) -> int:
228
+ return len(self.users)
229
+
230
+ def __getitem__(self, i):
231
+ return self.users[i]
232
+
233
+ @classmethod
234
+ def from_dict(cls, d: dict) -> "UsersPage":
235
+ d = d or {}
236
+ return cls(
237
+ users=[UserScope.from_dict(u) for u in (d.get("users") or [])],
238
+ total=int(d.get("total", 0)),
239
+ )
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: korely-memory
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Korely Agents — memory for AI agents with bi-temporal typed facts.
5
+ Project-URL: Homepage, https://korely.ai/agents
6
+ Project-URL: Documentation, https://korely.ai/agents/docs/surfaces/sdk
7
+ Project-URL: API Reference, https://korely.ai/agents/docs/api-reference
8
+ Author: Korely
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,knowledge-graph,llm,memory,rag
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # korely-memory
25
+
26
+ The Python SDK for [Korely Agents](https://korely.ai/agents) — memory for AI
27
+ agents, with bi-temporal typed facts and contradiction checking built in.
28
+
29
+ A typed, **zero-dependency** client over the Korely REST API. Every method maps
30
+ 1:1 onto an endpoint, so anything you can do with curl you can do here, and the
31
+ JSON shapes in the [API reference](https://korely.ai/agents/docs/api-reference)
32
+ are the attribute shapes you get back. All the intelligence — embeddings, entity
33
+ and typed-fact extraction, contradiction checking, bi-temporal validity — runs
34
+ server-side, so your install stays small and your process stays light.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install korely-memory
40
+ ```
41
+
42
+ Python 3.9 or later.
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ from korely_memory import Korely
48
+
49
+ korely = Korely(api_key="kor_live_...", region="eu")
50
+ # or read the key from the environment (KORELY_API_KEY)
51
+ korely = Korely(region="eu")
52
+
53
+ # Remember — the write path extracts facts and resolves contradictions
54
+ korely.add("User prefers TypeScript with strict mode", agent_id="coding-assistant")
55
+ korely.add("Actually I switched to Rust", agent_id="coding-assistant")
56
+
57
+ # Recall — superseded facts drop out automatically
58
+ for hit in korely.search("user's preferred language", limit=5):
59
+ print(hit.id, hit.score, hit.snippet)
60
+
61
+ # One-call, prompt-ready context for your LLM
62
+ ctx = korely.get_context(query="plan the project", user_id="dana", token_budget=800)
63
+ messages = [{"role": "system", "content": f"You are helpful.\n\n{ctx.context}"}]
64
+ ```
65
+
66
+ ## Methods
67
+
68
+ Every method wraps exactly one REST endpoint.
69
+
70
+ | Method | Endpoint |
71
+ |---|---|
72
+ | `add(content, *, agent_id=, user_id=, run_id=, metadata=)` | `POST /v1/memories` |
73
+ | `search(query, *, user_id=, agent_id=, limit=)` | `POST /v1/memories/search` |
74
+ | `get_all(*, user_id=, agent_id=, limit=, offset=)` | `GET /v1/memories` |
75
+ | `get(memory_id)` | `GET /v1/memories/:id` |
76
+ | `update(memory_id, *, content, expected_updated_at=)` | `PATCH /v1/memories/:id` |
77
+ | `delete(memory_id)` | `DELETE /v1/memories/:id` |
78
+ | `delete_all(*, user_id)` | `DELETE /v1/users/:user_id/memories` |
79
+ | `get_facts(*, subject=, entity=, predicate=, predicate_family=, include_invalidated=, as_of=, …)` | `GET /v1/facts` |
80
+ | `get_context(*, query, user_id=, agent_id=, token_budget=)` | `GET /v1/context` |
81
+ | `batch(memories)` | `POST /v1/batch` |
82
+ | `batch_status(job_id)` | `GET /v1/batch/:id` |
83
+
84
+ ## Bi-temporal facts
85
+
86
+ The differentiator: typed `(subject, predicate, object)` facts with validity over
87
+ time. Ask what was true on any date.
88
+
89
+ ```python
90
+ # Current state
91
+ facts = korely.get_facts(entity="Northwind Hosting")
92
+ print(facts[0].object) # 50 euro per month
93
+ print(facts[0].invalid_at) # None — active
94
+
95
+ # Point-in-time: what did we believe on June 1?
96
+ facts = korely.get_facts(entity="Northwind Hosting", as_of="2026-06-01")
97
+ print(facts[0].object) # 40 euro per month
98
+ ```
99
+
100
+ ## Scoping
101
+
102
+ Three identifiers, three levels of scope — the same everywhere (SDK, REST, MCP):
103
+
104
+ - `agent_id` — your application or agent (one namespace per product surface)
105
+ - `user_id` — your end user (free-form string; **unlimited on every tier**)
106
+ - `run_id` — one session or run (sub-scope inside a user)
107
+
108
+ ```python
109
+ korely.add("Asked to be contacted on Slack", agent_id="support-bot", user_id="customer-4812")
110
+ results = korely.search("contact preference", user_id="customer-4812")
111
+ ```
112
+
113
+ > Always pass `user_id` on reads in multi-tenant products. Filters are additive
114
+ > (AND); a search without `user_id` spans every end user in the namespace.
115
+
116
+ ## Error handling
117
+
118
+ The SDK raises typed exceptions that map onto the REST error codes; all subclass
119
+ `KorelyError`.
120
+
121
+ ```python
122
+ import time
123
+ from korely_memory import Korely, AuthenticationError, NotFoundError, QuotaExceededError
124
+
125
+ korely = Korely(api_key="kor_live_...")
126
+ try:
127
+ memory = korely.get("mem_8f2c1a")
128
+ except AuthenticationError:
129
+ raise # 401 — check or rotate the key
130
+ except NotFoundError:
131
+ memory = None # 404 — forgotten or never existed
132
+ except QuotaExceededError as err:
133
+ time.sleep(err.retry_after) # 429 — back off and retry
134
+ memory = korely.get("mem_8f2c1a")
135
+ ```
136
+
137
+ | Exception | Status |
138
+ |---|---|
139
+ | `AuthenticationError` | 401 |
140
+ | `NamespaceForbiddenError` | 403 |
141
+ | `NotFoundError` | 404 |
142
+ | `StaleWriteError` | 409 |
143
+ | `QuotaExceededError` (`.retry_after`) | 429 |
144
+ | `APIError` | other |
145
+
146
+ ## Links
147
+
148
+ - [SDK docs](https://korely.ai/agents/docs/surfaces/sdk)
149
+ - [API reference](https://korely.ai/agents/docs/api-reference)
150
+ - [Cookbook: a chatbot that remembers](https://korely.ai/agents/docs/cookbooks/chatbot-that-remembers)
151
+
152
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ korely_memory/__init__.py,sha256=9Z-JDrMxbArveYT7I8jAiRZP90-7c2pZ0ECMGe46YdM,1381
2
+ korely_memory/client.py,sha256=9KLZdqQ8JRPkxDzg0Q2zaVOM00CkvkBh8VD0kOtKXHw,14820
3
+ korely_memory/exceptions.py,sha256=ZGQ6JlHHg_GK4dGIoQ_XgdcG-op1OwCZsor5xaA94kc,1487
4
+ korely_memory/models.py,sha256=2i18KOwxIv_Oa8_JX2jjI7M0CdBGdCQvP-CSZD45bwY,6797
5
+ korely_memory-0.1.0.dist-info/METADATA,sha256=mhTImbj9Ncl0WNP47XZTgROZrhpfWepKTQZj2i6EX2o,5622
6
+ korely_memory-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ korely_memory-0.1.0.dist-info/licenses/LICENSE,sha256=Vnh2NMMPMN9gYAuxdKa2IMnhZAHWfOhDN-Fw2xbr5K0,1063
8
+ korely_memory-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Korely
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.