cortexlayer 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.
cortexlayer/client.py ADDED
@@ -0,0 +1,526 @@
1
+ """Sync and async clients for the Cortex HTTP API (``/v1/*``).
2
+
3
+ Both classes share every request builder and response parser (``_Base``); they
4
+ differ only in the transport call and the retry sleep, so the two surfaces
5
+ cannot drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import datetime as _dt
12
+ import os
13
+ import time
14
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+
19
+ from ._validate import need_int, need_str
20
+ from ._version import __version__
21
+ from .errors import (
22
+ AuthenticationError,
23
+ ConflictError,
24
+ ConnectionError,
25
+ CortexConfigError,
26
+ CortexError,
27
+ InvalidRequestError,
28
+ NotFoundError,
29
+ PermissionDeniedError,
30
+ RateLimitError,
31
+ ServerError,
32
+ WritesNotSupportedError,
33
+ )
34
+ from .types import (
35
+ Account,
36
+ AddResult,
37
+ Graph,
38
+ Page,
39
+ PageList,
40
+ SearchResult,
41
+ Usage,
42
+ )
43
+
44
+ DEFAULT_BASE_URL = "https://api.cortexlayer.net"
45
+ API_KEY_ENV = "CORTEX_API_KEY"
46
+ BASE_URL_ENV = "CORTEX_BASE_URL"
47
+
48
+ MAX_SEARCH_LIMIT = 20 # server: top_k 1..20
49
+ MAX_LIST_LIMIT = 500 # server: /v1/pages limit 1..500
50
+ USAGE_GROUPS = ("day", "key", "operation")
51
+ _RETRY_STATUS = (502, 503, 504)
52
+
53
+ DateLike = Union[str, _dt.date, _dt.datetime]
54
+
55
+
56
+ class _Op:
57
+ """One prepared request: what to send and how to read the answer."""
58
+
59
+ __slots__ = ("method", "path", "params", "json", "parse", "retryable", "write")
60
+
61
+ def __init__(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ parse: Callable[[Any], Any],
66
+ *,
67
+ params: Optional[Dict[str, Any]] = None,
68
+ json: Optional[Dict[str, Any]] = None,
69
+ retryable: bool = False,
70
+ write: bool = False,
71
+ ) -> None:
72
+ self.method = method
73
+ self.path = path
74
+ self.parse = parse
75
+ self.params = params
76
+ self.json = json
77
+ # Only safe, side-effect-free calls are retried on transport errors and
78
+ # 502/503/504: a retried write could apply twice.
79
+ self.retryable = retryable
80
+ self.write = write
81
+
82
+
83
+ _need_str = need_str
84
+ _need_int = need_int
85
+
86
+
87
+ def _iso(value: DateLike, name: str) -> str:
88
+ if isinstance(value, (_dt.datetime, _dt.date)):
89
+ return value.isoformat()
90
+ if isinstance(value, str) and value.strip():
91
+ return value.strip()
92
+ raise InvalidRequestError(f"{name} must be an ISO date/datetime string or a date")
93
+
94
+
95
+ def _page_path(page_id: str, suffix: str = "") -> str:
96
+ _need_str(page_id, "id")
97
+ return f"/v1/pages/{quote(page_id, safe='')}{suffix}"
98
+
99
+
100
+ def _ok_none(_: Any) -> None:
101
+ return None
102
+
103
+
104
+ class _Base:
105
+ """Config + request building + response handling, transport-free."""
106
+
107
+ def __init__(
108
+ self,
109
+ api_key: Optional[str] = None,
110
+ base_url: Optional[str] = None,
111
+ *,
112
+ timeout: float = 30.0,
113
+ max_retries: int = 2,
114
+ retry_backoff: float = 0.5,
115
+ ) -> None:
116
+ key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
117
+ url = base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
118
+ if key is not None and (not isinstance(key, str) or not key.strip()):
119
+ raise CortexConfigError("api_key must be a non-empty string")
120
+ if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
121
+ raise CortexConfigError("base_url must start with http:// or https://")
122
+ if key is None and url.rstrip("/") == DEFAULT_BASE_URL:
123
+ raise CortexConfigError(
124
+ f"No API key. Pass api_key=... or set {API_KEY_ENV} "
125
+ "(create one in the Cortex web app under Keys)."
126
+ )
127
+ if max_retries < 0:
128
+ raise CortexConfigError("max_retries must be >= 0")
129
+ self._api_key = key.strip() if key else None
130
+ self._base_url = url.rstrip("/")
131
+ self._timeout = timeout
132
+ self._max_retries = max_retries
133
+ self._backoff = max(0.0, retry_backoff)
134
+
135
+ def __repr__(self) -> str:
136
+ key = "None" if not self._api_key else f"'…{self._api_key[-4:]}'"
137
+ return f"{type(self).__name__}(base_url={self._base_url!r}, api_key={key})"
138
+
139
+ # --- request plumbing ---
140
+
141
+ def _headers(self) -> Dict[str, str]:
142
+ h = {
143
+ "Accept": "application/json",
144
+ "User-Agent": f"cortexlayer-python/{__version__}",
145
+ }
146
+ if self._api_key:
147
+ h["Authorization"] = f"Bearer {self._api_key}"
148
+ return h
149
+
150
+ def _url(self, op: _Op) -> str:
151
+ return self._base_url + op.path
152
+
153
+ def _delay(self, attempt: int) -> float:
154
+ return min(self._backoff * (2 ** attempt), 4.0)
155
+
156
+ def _finish(self, op: _Op, resp: httpx.Response) -> Any:
157
+ if resp.status_code >= 400:
158
+ raise self._error_for(op, resp)
159
+ try:
160
+ data = resp.json()
161
+ except ValueError as e:
162
+ raise ServerError(
163
+ "Unexpected non-JSON response from server", status=resp.status_code
164
+ ) from e
165
+ try:
166
+ return op.parse(data)
167
+ except (KeyError, TypeError, ValueError, AttributeError) as e:
168
+ raise ServerError(
169
+ f"Unexpected response shape from {op.method} {op.path}",
170
+ status=resp.status_code,
171
+ ) from e
172
+
173
+ @staticmethod
174
+ def _error_message(resp: httpx.Response) -> Tuple[str, bool]:
175
+ """(message, came_from_a_json_error_body)."""
176
+ try:
177
+ body = resp.json()
178
+ except ValueError:
179
+ return (resp.text.strip()[:200] or resp.reason_phrase or "error"), False
180
+ if isinstance(body, dict) and isinstance(body.get("error"), str):
181
+ return body["error"], True
182
+ return (str(body)[:200], False)
183
+
184
+ def _error_for(self, op: _Op, resp: httpx.Response) -> CortexError:
185
+ status = resp.status_code
186
+ msg, from_json = self._error_message(resp)
187
+ if op.write and (status == 405 or (status == 404 and not from_json)):
188
+ return WritesNotSupportedError(
189
+ "This Cortex server doesn't support REST writes "
190
+ f"({op.method} {op.path} → {status}). It is running a version "
191
+ "older than server task 0073 — update/redeploy the server.",
192
+ status=status,
193
+ )
194
+ if status in (400, 422):
195
+ return InvalidRequestError(msg, status=status)
196
+ if status == 401:
197
+ return AuthenticationError(
198
+ f"{msg} — check your API key (it may be missing, wrong or revoked).",
199
+ status=401,
200
+ )
201
+ if status == 403:
202
+ return PermissionDeniedError(msg, status=403)
203
+ if status == 404:
204
+ return NotFoundError(msg, status=404)
205
+ if status == 409:
206
+ return ConflictError(msg, status=409)
207
+ if status == 429:
208
+ ra = resp.headers.get("Retry-After")
209
+ try:
210
+ retry_after: Optional[float] = float(ra) if ra is not None else None
211
+ except ValueError:
212
+ retry_after = None
213
+ return RateLimitError(msg, status=429, retry_after=retry_after)
214
+ if status >= 500:
215
+ return ServerError(msg, status=status)
216
+ return CortexError(msg, status=status)
217
+
218
+ # --- operations (transport-free; shared by sync + async) ---
219
+
220
+ @staticmethod
221
+ def _op_add(text: str, timestamp: Optional[str]) -> _Op:
222
+ body: Dict[str, Any] = {"text": _need_str(text, "text")}
223
+ if timestamp is not None:
224
+ body["timestamp"] = _need_str(timestamp, "timestamp")
225
+ return _Op("POST", "/v1/pages", AddResult.from_dict, json=body, write=True)
226
+
227
+ @staticmethod
228
+ def _op_search(query: str, limit: int, expand_links: bool) -> _Op:
229
+ _need_str(query, "query")
230
+ _need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
231
+ if not isinstance(expand_links, bool):
232
+ raise InvalidRequestError("expand_links must be True or False")
233
+ return _Op(
234
+ "POST", "/v1/search",
235
+ lambda d: [SearchResult.from_dict(r) for r in d["results"]],
236
+ json={"query": query, "top_k": limit, "expand_links": expand_links},
237
+ retryable=True,
238
+ )
239
+
240
+ @staticmethod
241
+ def _op_get(page_id: str) -> _Op:
242
+ return _Op("GET", _page_path(page_id), Page.from_dict, retryable=True)
243
+
244
+ @staticmethod
245
+ def _op_get_all(query: Optional[str], limit: int, offset: int) -> _Op:
246
+ _need_int(limit, "limit", 1, MAX_LIST_LIMIT)
247
+ _need_int(offset, "offset", 0)
248
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
249
+ if query is not None:
250
+ params["q"] = _need_str(query, "query")
251
+ return _Op("GET", "/v1/pages", PageList.from_dict, params=params, retryable=True)
252
+
253
+ @staticmethod
254
+ def _op_update(page_id: str, text: str) -> _Op:
255
+ return _Op(
256
+ "PATCH", _page_path(page_id), _ok_none,
257
+ json={"text": _need_str(text, "text")}, write=True,
258
+ )
259
+
260
+ @staticmethod
261
+ def _op_delete(page_id: str) -> _Op:
262
+ return _Op("DELETE", _page_path(page_id), _ok_none, write=True)
263
+
264
+ @staticmethod
265
+ def _op_relink() -> _Op:
266
+ return _Op("POST", "/v1/relink", dict, write=True)
267
+
268
+ @staticmethod
269
+ def _op_me() -> _Op:
270
+ return _Op("GET", "/v1/me", Account.from_dict, retryable=True)
271
+
272
+ @staticmethod
273
+ def _op_usage(
274
+ group_by: str, since: Optional[DateLike], until: Optional[DateLike]
275
+ ) -> _Op:
276
+ if group_by not in USAGE_GROUPS:
277
+ raise InvalidRequestError(f"group_by must be one of {', '.join(USAGE_GROUPS)}")
278
+ params: Dict[str, Any] = {"group_by": group_by}
279
+ if since is not None:
280
+ params["since"] = _iso(since, "since")
281
+ if until is not None:
282
+ params["until"] = _iso(until, "until")
283
+ return _Op("GET", "/v1/usage", Usage.from_dict, params=params, retryable=True)
284
+
285
+ @staticmethod
286
+ def _op_graph(limit: Optional[int]) -> _Op:
287
+ params = {} if limit is None else {"limit": _need_int(limit, "limit", 1, 5000)}
288
+ return _Op("GET", "/v1/graph", Graph.from_dict, params=params, retryable=True)
289
+
290
+ @staticmethod
291
+ def _op_neighbors(page_id: str, depth: int, limit: Optional[int]) -> _Op:
292
+ _need_int(depth, "depth", 1, 3)
293
+ params: Dict[str, Any] = {"depth": depth}
294
+ if limit is not None:
295
+ params["limit"] = _need_int(limit, "limit", 1)
296
+ return _Op(
297
+ "GET", _page_path(page_id, "/neighbors"), Graph.from_dict,
298
+ params=params, retryable=True,
299
+ )
300
+
301
+
302
+ class CortexClient(_Base):
303
+ """Synchronous client.
304
+
305
+ >>> c = CortexClient(api_key="ctx_...") # doctest: +SKIP
306
+ >>> c.add("I moved to Lisbon in March") # doctest: +SKIP
307
+ >>> [r.title for r in c.search("where do I live?")] # doctest: +SKIP
308
+
309
+ Use as a context manager (or call :meth:`close`) to release connections.
310
+ Pass ``http_client`` to supply your own ``httpx.Client`` (proxies, custom
311
+ transports, tests); it is never closed for you.
312
+ """
313
+
314
+ def __init__(
315
+ self,
316
+ api_key: Optional[str] = None,
317
+ base_url: Optional[str] = None,
318
+ *,
319
+ timeout: float = 30.0,
320
+ max_retries: int = 2,
321
+ retry_backoff: float = 0.5,
322
+ http_client: Optional[httpx.Client] = None,
323
+ ) -> None:
324
+ super().__init__(
325
+ api_key, base_url, timeout=timeout,
326
+ max_retries=max_retries, retry_backoff=retry_backoff,
327
+ )
328
+ self._owns_http = http_client is None
329
+ self._http = http_client or httpx.Client(timeout=timeout)
330
+
331
+ def close(self) -> None:
332
+ if self._owns_http:
333
+ self._http.close()
334
+
335
+ def __enter__(self) -> "CortexClient":
336
+ return self
337
+
338
+ def __exit__(self, *exc: Any) -> None:
339
+ self.close()
340
+
341
+ def _call(self, op: _Op) -> Any:
342
+ attempt = 0
343
+ while True:
344
+ try:
345
+ resp = self._http.request(
346
+ op.method, self._url(op), headers=self._headers(),
347
+ params=op.params, json=op.json, timeout=self._timeout,
348
+ )
349
+ except httpx.HTTPError as e:
350
+ if op.retryable and attempt < self._max_retries:
351
+ time.sleep(self._delay(attempt))
352
+ attempt += 1
353
+ continue
354
+ raise ConnectionError(
355
+ f"Could not reach {self._base_url}: {e.__class__.__name__}: {e}"
356
+ ) from e
357
+ if resp.status_code in _RETRY_STATUS and op.retryable and attempt < self._max_retries:
358
+ time.sleep(self._delay(attempt))
359
+ attempt += 1
360
+ continue
361
+ return self._finish(op, resp)
362
+
363
+ # --- memory ---
364
+
365
+ def add(self, text: str, *, timestamp: Optional[str] = None) -> AddResult:
366
+ """Save ``text`` as memory (long text is chunked into several pages).
367
+ ``timestamp`` (e.g. ``"8 May, 2023"``) is stored as the date.
368
+
369
+ Needs a server with REST writes (Cortex server task 0073); raises
370
+ :class:`WritesNotSupportedError` on older servers."""
371
+ return self._call(self._op_add(text, timestamp))
372
+
373
+ def search(
374
+ self, query: str, *, limit: int = 4, expand_links: bool = True
375
+ ) -> List[SearchResult]:
376
+ """Semantic search. With ``expand_links`` (default) related pages are
377
+ pulled in via links — those results have ``via == "link"``."""
378
+ return self._call(self._op_search(query, limit, expand_links))
379
+
380
+ def get(self, id: str) -> Page:
381
+ """One page by id. Unknown ids raise :class:`NotFoundError`."""
382
+ return self._call(self._op_get(id))
383
+
384
+ def get_all(
385
+ self, *, query: Optional[str] = None, limit: int = 50, offset: int = 0
386
+ ) -> PageList:
387
+ """Browse pages; ``query`` is a case-insensitive substring filter
388
+ (use :meth:`search` for semantic search)."""
389
+ return self._call(self._op_get_all(query, limit, offset))
390
+
391
+ def update(self, id: str, text: str) -> None:
392
+ """Replace a page's text. Needs a server with REST writes (task 0073)."""
393
+ self._call(self._op_update(id, text))
394
+
395
+ def delete(self, id: str) -> None:
396
+ """Delete a page. Needs a server with REST writes (task 0073)."""
397
+ self._call(self._op_delete(id))
398
+
399
+ def relink(self) -> Dict[str, Any]:
400
+ """Re-run the batch linking pass (do it after adding several
401
+ memories). Needs a server with REST writes (task 0073)."""
402
+ return self._call(self._op_relink())
403
+
404
+ # --- account ---
405
+
406
+ def me(self) -> Account:
407
+ """The account this key belongs to (a cheap way to validate a key)."""
408
+ return self._call(self._op_me())
409
+
410
+ def usage(
411
+ self,
412
+ *,
413
+ group_by: str = "day",
414
+ since: Optional[DateLike] = None,
415
+ until: Optional[DateLike] = None,
416
+ ) -> Usage:
417
+ """Your own API usage, aggregated by ``day``, ``key`` or ``operation``."""
418
+ return self._call(self._op_usage(group_by, since, until))
419
+
420
+ # --- graph ---
421
+
422
+ def graph(self, *, limit: Optional[int] = None) -> Graph:
423
+ """The link graph over your pages (capped; see ``truncated``)."""
424
+ return self._call(self._op_graph(limit))
425
+
426
+ def neighbors(self, id: str, *, depth: int = 1, limit: Optional[int] = None) -> Graph:
427
+ """N-hop neighborhood (``depth`` 1–3) of one page."""
428
+ return self._call(self._op_neighbors(id, depth, limit))
429
+
430
+
431
+ class AsyncCortexClient(_Base):
432
+ """Asynchronous twin of :class:`CortexClient` (same methods, ``await`` them)."""
433
+
434
+ def __init__(
435
+ self,
436
+ api_key: Optional[str] = None,
437
+ base_url: Optional[str] = None,
438
+ *,
439
+ timeout: float = 30.0,
440
+ max_retries: int = 2,
441
+ retry_backoff: float = 0.5,
442
+ http_client: Optional[httpx.AsyncClient] = None,
443
+ ) -> None:
444
+ super().__init__(
445
+ api_key, base_url, timeout=timeout,
446
+ max_retries=max_retries, retry_backoff=retry_backoff,
447
+ )
448
+ self._owns_http = http_client is None
449
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
450
+
451
+ async def aclose(self) -> None:
452
+ if self._owns_http:
453
+ await self._http.aclose()
454
+
455
+ async def __aenter__(self) -> "AsyncCortexClient":
456
+ return self
457
+
458
+ async def __aexit__(self, *exc: Any) -> None:
459
+ await self.aclose()
460
+
461
+ async def _call(self, op: _Op) -> Any:
462
+ attempt = 0
463
+ while True:
464
+ try:
465
+ resp = await self._http.request(
466
+ op.method, self._url(op), headers=self._headers(),
467
+ params=op.params, json=op.json, timeout=self._timeout,
468
+ )
469
+ except httpx.HTTPError as e:
470
+ if op.retryable and attempt < self._max_retries:
471
+ await asyncio.sleep(self._delay(attempt))
472
+ attempt += 1
473
+ continue
474
+ raise ConnectionError(
475
+ f"Could not reach {self._base_url}: {e.__class__.__name__}: {e}"
476
+ ) from e
477
+ if resp.status_code in _RETRY_STATUS and op.retryable and attempt < self._max_retries:
478
+ await asyncio.sleep(self._delay(attempt))
479
+ attempt += 1
480
+ continue
481
+ return self._finish(op, resp)
482
+
483
+ async def add(self, text: str, *, timestamp: Optional[str] = None) -> AddResult:
484
+ return await self._call(self._op_add(text, timestamp))
485
+
486
+ async def search(
487
+ self, query: str, *, limit: int = 4, expand_links: bool = True
488
+ ) -> List[SearchResult]:
489
+ return await self._call(self._op_search(query, limit, expand_links))
490
+
491
+ async def get(self, id: str) -> Page:
492
+ return await self._call(self._op_get(id))
493
+
494
+ async def get_all(
495
+ self, *, query: Optional[str] = None, limit: int = 50, offset: int = 0
496
+ ) -> PageList:
497
+ return await self._call(self._op_get_all(query, limit, offset))
498
+
499
+ async def update(self, id: str, text: str) -> None:
500
+ await self._call(self._op_update(id, text))
501
+
502
+ async def delete(self, id: str) -> None:
503
+ await self._call(self._op_delete(id))
504
+
505
+ async def relink(self) -> Dict[str, Any]:
506
+ return await self._call(self._op_relink())
507
+
508
+ async def me(self) -> Account:
509
+ return await self._call(self._op_me())
510
+
511
+ async def usage(
512
+ self,
513
+ *,
514
+ group_by: str = "day",
515
+ since: Optional[DateLike] = None,
516
+ until: Optional[DateLike] = None,
517
+ ) -> Usage:
518
+ return await self._call(self._op_usage(group_by, since, until))
519
+
520
+ async def graph(self, *, limit: Optional[int] = None) -> Graph:
521
+ return await self._call(self._op_graph(limit))
522
+
523
+ async def neighbors(
524
+ self, id: str, *, depth: int = 1, limit: Optional[int] = None
525
+ ) -> Graph:
526
+ return await self._call(self._op_neighbors(id, depth, limit))
cortexlayer/errors.py ADDED
@@ -0,0 +1,108 @@
1
+ """Typed errors for the Cortex client.
2
+
3
+ The server answers errors as ``{"error": "<message or code>"}`` with an HTTP
4
+ status; each status maps to one exception class, all subclasses of
5
+ :class:`CortexError`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+
13
+ class CortexError(Exception):
14
+ """Base class for every error this package raises."""
15
+
16
+ def __init__(self, message: str, *, status: Optional[int] = None) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.status = status
20
+
21
+ def __str__(self) -> str:
22
+ return self.message
23
+
24
+
25
+ class CortexConfigError(CortexError, ValueError):
26
+ """The client was constructed with missing/invalid configuration."""
27
+
28
+
29
+ class InvalidRequestError(CortexError, ValueError):
30
+ """Bad arguments — caught client-side, or a server 400."""
31
+
32
+
33
+ class AuthenticationError(CortexError):
34
+ """401: the API key is missing, invalid, or revoked."""
35
+
36
+
37
+ class PermissionDeniedError(CortexError):
38
+ """403. ``code`` is the server's machine-readable reason when it sent one
39
+ (e.g. ``session_required``, ``not_invited``, ``email_not_verified``)."""
40
+
41
+ def __init__(self, message: str, *, status: Optional[int] = 403) -> None:
42
+ super().__init__(message, status=status)
43
+ self.code = message
44
+
45
+
46
+ class NotFoundError(CortexError):
47
+ """404: no such page (unknown ids and other users' ids look identical)."""
48
+
49
+
50
+ class ConflictError(CortexError):
51
+ """409: the request conflicts with current state (e.g. ``key_limit``)."""
52
+
53
+ def __init__(self, message: str, *, status: Optional[int] = 409) -> None:
54
+ super().__init__(message, status=status)
55
+ self.code = message
56
+
57
+
58
+ class RateLimitError(CortexError):
59
+ """429. ``retry_after`` is the ``Retry-After`` header in seconds, if sent."""
60
+
61
+ def __init__(
62
+ self,
63
+ message: str,
64
+ *,
65
+ status: Optional[int] = 429,
66
+ retry_after: Optional[float] = None,
67
+ ) -> None:
68
+ super().__init__(message, status=status)
69
+ self.retry_after = retry_after
70
+
71
+
72
+ class ServerError(CortexError):
73
+ """5xx (or an unparseable server reply)."""
74
+
75
+
76
+ class ConnectionError(CortexError): # noqa: A001 — deliberate: mirrors httpx naming
77
+ """The request never got a response (DNS, refused, timeout, TLS...)."""
78
+
79
+
80
+ class WritesNotSupportedError(CortexError):
81
+ """The server is too old to have REST write endpoints (added in Cortex
82
+ server task 0073).
83
+
84
+ Raised when ``add`` / ``update`` / ``delete`` / ``relink`` get a 404/405
85
+ on the write route itself (as opposed to a 404 for an unknown page id).
86
+ """
87
+
88
+
89
+ class LocalDependencyError(CortexError, ImportError):
90
+ """The embedded engine needs packages that are not installed.
91
+
92
+ Install them with ``pip install "cortexlayer[local]"`` (Chroma + spaCy).
93
+ The hosted client (:class:`CortexClient`) never needs them.
94
+ """
95
+
96
+
97
+ class MissingModelError(LocalDependencyError):
98
+ """spaCy is installed but its language model is not.
99
+
100
+ Fix: ``python -m spacy download en_core_web_sm`` — or construct
101
+ ``Memory(entity_extractor="regex")`` to run without a model.
102
+ """
103
+
104
+
105
+ class LLMError(CortexError):
106
+ """The language model needed for fact extraction / answering failed
107
+ (unreachable, timed out, or returned an error). Distinct from "the model
108
+ found nothing worth remembering", which is a normal empty result."""