cortexdbai 0.2.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.
cortexdb/client.py ADDED
@@ -0,0 +1,962 @@
1
+ """Synchronous and asynchronous HTTP clients for the CortexDB API.
2
+
3
+ Usage (sync)::
4
+
5
+ with Cortex() as client:
6
+ resp = client.remember("The deploy succeeded at 14:32 UTC.")
7
+ results = client.recall("What happened with the deploy?")
8
+
9
+ Usage (async)::
10
+
11
+ async with AsyncCortex() as client:
12
+ resp = await client.remember("The deploy succeeded at 14:32 UTC.")
13
+ results = await client.recall("What happened with the deploy?")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from tenacity import (
23
+ retry,
24
+ retry_if_exception,
25
+ stop_after_attempt,
26
+ wait_exponential,
27
+ )
28
+
29
+ from cortexdb.exceptions import (
30
+ CortexAPIError,
31
+ CortexAuthError,
32
+ CortexConnectionError,
33
+ CortexRateLimitError,
34
+ CortexTimeoutError,
35
+ )
36
+ from cortexdb.models import (
37
+ EntityResponse,
38
+ ForgetResponse,
39
+ HealthResponse,
40
+ IngestEpisodeRequest,
41
+ IngestEpisodeResponse,
42
+ LinkResponse,
43
+ ListEpisodesResponse,
44
+ MetricsResponse,
45
+ RecallResponse,
46
+ RememberResponse,
47
+ SearchResponse,
48
+ )
49
+
50
+ _DEFAULT_BASE_URL = "https://api.cortexdb.ai"
51
+ _RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
52
+
53
+
54
+ def _is_retryable(exc: BaseException) -> bool:
55
+ """Return True if the exception warrants a retry."""
56
+ if isinstance(exc, CortexRateLimitError):
57
+ return True
58
+ if isinstance(exc, CortexAPIError) and exc.status_code in _RETRYABLE_STATUS_CODES:
59
+ return True
60
+ if isinstance(exc, CortexConnectionError):
61
+ return True
62
+ return False
63
+
64
+
65
+ def _raise_for_status(response: httpx.Response) -> None:
66
+ """Translate an HTTP error response into the appropriate CortexDB exception."""
67
+ if response.is_success:
68
+ return
69
+
70
+ status = response.status_code
71
+ try:
72
+ body = response.json()
73
+ except Exception:
74
+ body = {}
75
+
76
+ message = body.get("message") or body.get("error") or response.reason_phrase or "Unknown error"
77
+ error_code = body.get("error_code") or body.get("code")
78
+
79
+ if status == 429:
80
+ retry_after_raw = response.headers.get("Retry-After")
81
+ retry_after: float | None = None
82
+ if retry_after_raw is not None:
83
+ try:
84
+ retry_after = float(retry_after_raw)
85
+ except (ValueError, TypeError):
86
+ pass
87
+ raise CortexRateLimitError(
88
+ message=message,
89
+ status_code=status,
90
+ error_code=error_code,
91
+ retry_after=retry_after,
92
+ )
93
+
94
+ if status in (401, 403):
95
+ raise CortexAuthError(message=message, status_code=status, error_code=error_code)
96
+
97
+ if status >= 400:
98
+ raise CortexAPIError(message=message, status_code=status, error_code=error_code)
99
+
100
+
101
+ def _build_headers(api_key: str | None) -> dict[str, str]:
102
+ headers: dict[str, str] = {
103
+ "User-Agent": "cortexdb-python/0.2.0",
104
+ "Accept": "application/json",
105
+ }
106
+ if api_key:
107
+ headers["Authorization"] = f"Bearer {api_key}"
108
+ return headers
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Synchronous client
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ class Cortex:
117
+ """Synchronous client for the CortexDB HTTP API.
118
+
119
+ Args:
120
+ base_url: Root URL of the CortexDB server (default ``https://api.cortexdb.ai``).
121
+ api_key: Bearer token for authentication. Falls back to the
122
+ ``CORTEXDB_API_KEY`` environment variable when *None*.
123
+ timeout: Request timeout in seconds.
124
+ max_retries: Maximum number of retry attempts for transient failures.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ base_url: str = _DEFAULT_BASE_URL,
130
+ api_key: str | None = None,
131
+ timeout: float = 30.0,
132
+ max_retries: int = 3,
133
+ ) -> None:
134
+ self._base_url = base_url.rstrip("/")
135
+ self._api_key = api_key or os.environ.get("CORTEXDB_API_KEY")
136
+ self._max_retries = max_retries
137
+ self._client = httpx.Client(
138
+ base_url=self._base_url,
139
+ headers=_build_headers(self._api_key),
140
+ timeout=timeout,
141
+ )
142
+
143
+ # -- context manager -----------------------------------------------------
144
+
145
+ def __enter__(self) -> Cortex:
146
+ return self
147
+
148
+ def __exit__(self, *exc: object) -> None:
149
+ self.close()
150
+
151
+ def close(self) -> None:
152
+ """Close the underlying HTTP connection pool."""
153
+ self._client.close()
154
+
155
+ # -- public API ----------------------------------------------------------
156
+
157
+ def remember(
158
+ self,
159
+ content: str,
160
+ tenant_id: str = "default",
161
+ scope: str | None = None,
162
+ metadata: dict[str, Any] | None = None,
163
+ ) -> RememberResponse:
164
+ """Store a piece of information in CortexDB.
165
+
166
+ Args:
167
+ content: The text content to remember.
168
+ tenant_id: Tenant namespace.
169
+ scope: Optional scope qualifier.
170
+ metadata: Arbitrary key-value metadata to attach.
171
+
172
+ Returns:
173
+ A ``RememberResponse`` with the ID assigned by the server.
174
+ """
175
+ payload: dict[str, Any] = {"content": content, "tenant_id": tenant_id}
176
+ if scope is not None:
177
+ payload["scope"] = scope
178
+ if metadata is not None:
179
+ payload["metadata"] = metadata
180
+ data = self._request("POST", "/v1/remember", json=payload)
181
+ return RememberResponse.model_validate(data)
182
+
183
+ def recall(
184
+ self,
185
+ query: str,
186
+ tenant_id: str = "default",
187
+ max_tokens: int = 4096,
188
+ min_confidence: float = 0.0,
189
+ ) -> RecallResponse:
190
+ """Retrieve relevant memories matching a query.
191
+
192
+ Args:
193
+ query: Natural-language query.
194
+ tenant_id: Tenant namespace.
195
+ max_tokens: Maximum token budget for the response.
196
+ min_confidence: Minimum confidence threshold for matches.
197
+
198
+ Returns:
199
+ A ``RecallResponse`` containing ranked matches.
200
+ """
201
+ payload: dict[str, Any] = {
202
+ "query": query,
203
+ "tenant_id": tenant_id,
204
+ }
205
+ data = self._request("POST", "/v1/recall", json=payload)
206
+ return RecallResponse.model_validate(data)
207
+
208
+ def forget(
209
+ self,
210
+ query: str | None = None,
211
+ reason: str = "",
212
+ tenant_id: str = "default",
213
+ ) -> ForgetResponse:
214
+ """Delete memories matching a query.
215
+
216
+ Args:
217
+ query: Pattern or identifier of memories to delete. When *None*,
218
+ the server determines the scope.
219
+ reason: Human-readable justification for the deletion.
220
+ tenant_id: Tenant namespace.
221
+
222
+ Returns:
223
+ A ``ForgetResponse`` indicating how many records were deleted.
224
+ """
225
+ payload: dict[str, Any] = {"reason": reason, "tenant_id": tenant_id}
226
+ if query is not None:
227
+ payload["query"] = query
228
+ data = self._request("POST", "/v1/forget", json=payload)
229
+ return ForgetResponse.model_validate(data)
230
+
231
+ def ingest_episode(self, episode: IngestEpisodeRequest) -> IngestEpisodeResponse:
232
+ """Ingest a structured episode into CortexDB.
233
+
234
+ Args:
235
+ episode: The episode to ingest.
236
+
237
+ Returns:
238
+ An ``IngestEpisodeResponse`` with the episode ID and status.
239
+ """
240
+ payload = episode.model_dump(
241
+ by_alias=False, exclude_none=True, exclude={"tenant_id"}
242
+ )
243
+ data = self._request("POST", "/v1/episodes", json=payload)
244
+ return IngestEpisodeResponse.model_validate(data)
245
+
246
+ def list_episodes(
247
+ self,
248
+ tenant_id: str = "default",
249
+ offset: int = 0,
250
+ limit: int = 50,
251
+ **kwargs: Any,
252
+ ) -> ListEpisodesResponse:
253
+ """List episodes with optional filtering.
254
+
255
+ Args:
256
+ tenant_id: Tenant namespace.
257
+ offset: Pagination offset.
258
+ limit: Maximum number of episodes to return.
259
+ **kwargs: Additional query parameters forwarded to the API
260
+ (e.g. ``type``, ``actor_id``).
261
+
262
+ Returns:
263
+ A ``ListEpisodesResponse`` with the episode list and pagination info.
264
+ """
265
+ params: dict[str, Any] = {
266
+ "tenant_id": tenant_id,
267
+ "offset": offset,
268
+ "limit": limit,
269
+ **kwargs,
270
+ }
271
+ data = self._request("GET", "/v1/episodes", params=params)
272
+ return ListEpisodesResponse.model_validate(data)
273
+
274
+ def health(self) -> HealthResponse:
275
+ """Check server health.
276
+
277
+ Returns:
278
+ A ``HealthResponse`` with the server's status and version.
279
+ """
280
+ data = self._request("GET", "/v1/admin/health")
281
+ return HealthResponse.model_validate(data)
282
+
283
+ def metrics(self) -> MetricsResponse:
284
+ """Retrieve server metrics.
285
+
286
+ Returns:
287
+ A ``MetricsResponse`` with episode counts and storage usage.
288
+ """
289
+ data = self._request("GET", "/v1/admin/metrics")
290
+ return MetricsResponse.model_validate(data)
291
+
292
+ def entity(self, entity_id: str, tenant_id: str = "default") -> EntityResponse:
293
+ """Get detailed information about a specific entity.
294
+
295
+ Returns entity metadata, relationships, and recent episodes.
296
+
297
+ Args:
298
+ entity_id: The entity ID to look up.
299
+ tenant_id: Tenant namespace.
300
+
301
+ Returns:
302
+ An ``EntityResponse`` with entity details, relationships, and episodes.
303
+ """
304
+ data = self._request(
305
+ "GET",
306
+ f"/v1/entities/{entity_id}",
307
+ headers={"x-tenant-id": tenant_id},
308
+ )
309
+ return EntityResponse.model_validate(data)
310
+
311
+ def link(
312
+ self,
313
+ source_entity_id: str,
314
+ target_entity_id: str,
315
+ relationship: str,
316
+ fact: str = "",
317
+ confidence: float = 0.9,
318
+ tenant_id: str = "default",
319
+ ) -> LinkResponse:
320
+ """Create an explicit relationship between two entities.
321
+
322
+ Args:
323
+ source_entity_id: ID of the source entity.
324
+ target_entity_id: ID of the target entity.
325
+ relationship: Relationship type (e.g., ``"DEPENDS_ON"``, ``"OWNS"``).
326
+ fact: Human-readable description of the relationship.
327
+ confidence: Confidence score (0.0 to 1.0).
328
+ tenant_id: Tenant namespace.
329
+
330
+ Returns:
331
+ A ``LinkResponse`` with the created edge ID.
332
+ """
333
+ payload = {
334
+ "source_entity_id": source_entity_id,
335
+ "target_entity_id": target_entity_id,
336
+ "relationship": relationship,
337
+ "fact": fact,
338
+ "confidence": confidence,
339
+ }
340
+ data = self._request(
341
+ "POST",
342
+ "/v1/link",
343
+ json=payload,
344
+ headers={"x-tenant-id": tenant_id},
345
+ )
346
+ return LinkResponse.model_validate(data)
347
+
348
+ def search(
349
+ self,
350
+ query: str,
351
+ source: str | None = None,
352
+ episode_type: str | None = None,
353
+ time_range: str | None = None,
354
+ namespace: str | None = None,
355
+ limit: int = 20,
356
+ offset: int = 0,
357
+ tenant_id: str = "default",
358
+ ) -> SearchResponse:
359
+ """Search across memories and episodes with filters.
360
+
361
+ Unlike :meth:`recall`, this returns both context and matching episodes.
362
+
363
+ Args:
364
+ query: Natural-language search query.
365
+ source: Filter by source connector (e.g., ``"slack"``, ``"github"``).
366
+ episode_type: Filter by episode type (e.g., ``"message"``, ``"incident"``).
367
+ time_range: Time range filter (e.g., ``"1h"``, ``"24h"``, ``"7d"``, ``"30d"``).
368
+ namespace: Filter by namespace.
369
+ limit: Maximum results to return.
370
+ offset: Pagination offset.
371
+ tenant_id: Tenant namespace.
372
+
373
+ Returns:
374
+ A ``SearchResponse`` with context, episodes, and metadata.
375
+ """
376
+ payload: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
377
+ filters: dict[str, str] = {}
378
+ if source:
379
+ filters["source"] = source
380
+ if episode_type:
381
+ filters["episode_type"] = episode_type
382
+ if time_range:
383
+ filters["time_range"] = time_range
384
+ if namespace:
385
+ filters["namespace"] = namespace
386
+ if filters:
387
+ payload["filters"] = filters
388
+ data = self._request(
389
+ "POST",
390
+ "/v1/search",
391
+ json=payload,
392
+ headers={"x-tenant-id": tenant_id},
393
+ )
394
+ return SearchResponse.model_validate(data)
395
+
396
+ # -- GTM convenience methods ---------------------------------------------
397
+
398
+ def memory(
399
+ self,
400
+ query: str,
401
+ tenant_id: str = "default",
402
+ **kwargs: Any,
403
+ ) -> RecallResponse:
404
+ """Recall memories with a single natural-language query.
405
+
406
+ This is the GTM one-liner::
407
+
408
+ cortex.memory("why did checkout fail yesterday")
409
+
410
+ It is an alias for :meth:`recall` with the same parameter semantics.
411
+
412
+ Args:
413
+ query: Natural-language query.
414
+ tenant_id: Tenant namespace.
415
+ **kwargs: Additional keyword arguments forwarded to :meth:`recall`
416
+ (e.g. ``max_tokens``, ``min_confidence``).
417
+
418
+ Returns:
419
+ A ``RecallResponse`` containing ranked matches.
420
+ """
421
+ return self.recall(query=query, tenant_id=tenant_id, **kwargs)
422
+
423
+ def store(
424
+ self,
425
+ data: str | dict[str, Any],
426
+ tenant_id: str = "default",
427
+ **kwargs: Any,
428
+ ) -> RememberResponse | IngestEpisodeResponse:
429
+ """Smart store that accepts a string or a dict.
430
+
431
+ Usage::
432
+
433
+ cortex.store("The deploy succeeded at 14:32 UTC.")
434
+ cortex.store({"content": "deploy ok", "type": "event", "source": "ci"})
435
+
436
+ When *data* is a **string**, it delegates to :meth:`remember`.
437
+ When *data* is a **dict**, it converts it via
438
+ :meth:`IngestEpisodeRequest.from_dict` and delegates to
439
+ :meth:`ingest_episode`.
440
+
441
+ Args:
442
+ data: Either a plain string (stored via ``remember``) or a dict
443
+ describing a structured episode (stored via ``ingest_episode``).
444
+ tenant_id: Tenant namespace (used for both code paths).
445
+ **kwargs: Extra keyword arguments forwarded to ``remember`` when
446
+ *data* is a string.
447
+
448
+ Returns:
449
+ A ``RememberResponse`` (string path) or
450
+ ``IngestEpisodeResponse`` (dict path).
451
+
452
+ Raises:
453
+ TypeError: If *data* is neither a string nor a dict.
454
+ """
455
+ if isinstance(data, str):
456
+ return self.remember(content=data, tenant_id=tenant_id, **kwargs)
457
+
458
+ if isinstance(data, dict):
459
+ data.setdefault("tenant_id", tenant_id)
460
+ episode = IngestEpisodeRequest.from_dict(data)
461
+ return self.ingest_episode(episode)
462
+
463
+ raise TypeError(
464
+ f"store() expects a str or dict, got {type(data).__name__}"
465
+ )
466
+
467
+ def context(
468
+ self,
469
+ query: str,
470
+ agent_id: str = "default",
471
+ tenant_id: str = "default",
472
+ max_tokens: int = 8192,
473
+ ) -> RecallResponse:
474
+ """Retrieve enriched context for an LLM agent.
475
+
476
+ Designed for feeding context windows to autonomous agents. Uses a
477
+ higher default token budget than :meth:`recall`.
478
+
479
+ Usage::
480
+
481
+ cortex.context(agent_id="incident-bot", query="current status of payments")
482
+
483
+ Args:
484
+ query: Natural-language query describing what context is needed.
485
+ agent_id: Identifier of the requesting agent (reserved for
486
+ future per-agent retrieval policies).
487
+ tenant_id: Tenant namespace.
488
+ max_tokens: Maximum token budget for the response (default 8192).
489
+
490
+ Returns:
491
+ A ``RecallResponse`` containing ranked matches.
492
+ """
493
+ return self.recall(
494
+ query=query,
495
+ tenant_id=tenant_id,
496
+ max_tokens=max_tokens,
497
+ )
498
+
499
+ # -- internal ------------------------------------------------------------
500
+
501
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
502
+ """Execute an HTTP request with retry logic.
503
+
504
+ Retries on 429, 502, 503, and 504 responses using exponential
505
+ backoff. On 429 responses the ``Retry-After`` header is respected.
506
+
507
+ Returns:
508
+ Decoded JSON response body.
509
+
510
+ Raises:
511
+ CortexAPIError: On non-retryable HTTP errors.
512
+ CortexConnectionError: When the server is unreachable.
513
+ CortexTimeoutError: When the request exceeds the configured timeout.
514
+ """
515
+
516
+ @retry(
517
+ retry=retry_if_exception(_is_retryable),
518
+ stop=stop_after_attempt(self._max_retries),
519
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=10),
520
+ reraise=True,
521
+ )
522
+ def _do() -> Any:
523
+ try:
524
+ response = self._client.request(method, path, **kwargs)
525
+ except httpx.TimeoutException as exc:
526
+ raise CortexTimeoutError(str(exc)) from exc
527
+ except httpx.ConnectError as exc:
528
+ raise CortexConnectionError(str(exc)) from exc
529
+ except httpx.HTTPError as exc:
530
+ raise CortexConnectionError(str(exc)) from exc
531
+
532
+ _raise_for_status(response)
533
+
534
+ if response.status_code == 204:
535
+ return {}
536
+ return response.json()
537
+
538
+ return _do()
539
+
540
+
541
+ # ---------------------------------------------------------------------------
542
+ # Asynchronous client
543
+ # ---------------------------------------------------------------------------
544
+
545
+
546
+ class AsyncCortex:
547
+ """Asynchronous client for the CortexDB HTTP API.
548
+
549
+ Provides the same interface as :class:`Cortex` but all network methods are
550
+ coroutines suitable for use with ``asyncio``.
551
+
552
+ Args:
553
+ base_url: Root URL of the CortexDB server (default ``https://api.cortexdb.ai``).
554
+ api_key: Bearer token for authentication. Falls back to the
555
+ ``CORTEXDB_API_KEY`` environment variable when *None*.
556
+ timeout: Request timeout in seconds.
557
+ max_retries: Maximum number of retry attempts for transient failures.
558
+ """
559
+
560
+ def __init__(
561
+ self,
562
+ base_url: str = _DEFAULT_BASE_URL,
563
+ api_key: str | None = None,
564
+ timeout: float = 30.0,
565
+ max_retries: int = 3,
566
+ ) -> None:
567
+ self._base_url = base_url.rstrip("/")
568
+ self._api_key = api_key or os.environ.get("CORTEXDB_API_KEY")
569
+ self._max_retries = max_retries
570
+ self._client = httpx.AsyncClient(
571
+ base_url=self._base_url,
572
+ headers=_build_headers(self._api_key),
573
+ timeout=timeout,
574
+ )
575
+
576
+ # -- context manager -----------------------------------------------------
577
+
578
+ async def __aenter__(self) -> AsyncCortex:
579
+ return self
580
+
581
+ async def __aexit__(self, *exc: object) -> None:
582
+ await self.close()
583
+
584
+ async def close(self) -> None:
585
+ """Close the underlying HTTP connection pool."""
586
+ await self._client.aclose()
587
+
588
+ # -- public API ----------------------------------------------------------
589
+
590
+ async def remember(
591
+ self,
592
+ content: str,
593
+ tenant_id: str = "default",
594
+ scope: str | None = None,
595
+ metadata: dict[str, Any] | None = None,
596
+ ) -> RememberResponse:
597
+ """Store a piece of information in CortexDB.
598
+
599
+ Args:
600
+ content: The text content to remember.
601
+ tenant_id: Tenant namespace.
602
+ scope: Optional scope qualifier.
603
+ metadata: Arbitrary key-value metadata to attach.
604
+
605
+ Returns:
606
+ A ``RememberResponse`` with the ID assigned by the server.
607
+ """
608
+ payload: dict[str, Any] = {"content": content, "tenant_id": tenant_id}
609
+ if scope is not None:
610
+ payload["scope"] = scope
611
+ if metadata is not None:
612
+ payload["metadata"] = metadata
613
+ data = await self._request("POST", "/v1/remember", json=payload)
614
+ return RememberResponse.model_validate(data)
615
+
616
+ async def recall(
617
+ self,
618
+ query: str,
619
+ tenant_id: str = "default",
620
+ ) -> RecallResponse:
621
+ """Retrieve relevant memories matching a query.
622
+
623
+ Args:
624
+ query: Natural-language query.
625
+ tenant_id: Tenant namespace.
626
+
627
+ Returns:
628
+ A ``RecallResponse`` with context and confidence.
629
+ """
630
+ payload: dict[str, Any] = {
631
+ "query": query,
632
+ "tenant_id": tenant_id,
633
+ }
634
+ data = await self._request("POST", "/v1/recall", json=payload)
635
+ return RecallResponse.model_validate(data)
636
+
637
+ async def forget(
638
+ self,
639
+ query: str | None = None,
640
+ reason: str = "",
641
+ tenant_id: str = "default",
642
+ ) -> ForgetResponse:
643
+ """Delete memories matching a query.
644
+
645
+ Args:
646
+ query: Pattern or identifier of memories to delete.
647
+ reason: Human-readable justification for the deletion.
648
+ tenant_id: Tenant namespace.
649
+
650
+ Returns:
651
+ A ``ForgetResponse`` indicating how many records were forgotten.
652
+ """
653
+ payload: dict[str, Any] = {"reason": reason, "tenant_id": tenant_id}
654
+ if query is not None:
655
+ payload["query"] = query
656
+ data = await self._request("POST", "/v1/forget", json=payload)
657
+ return ForgetResponse.model_validate(data)
658
+
659
+ async def ingest_episode(self, episode: IngestEpisodeRequest) -> IngestEpisodeResponse:
660
+ """Ingest a structured episode into CortexDB.
661
+
662
+ Args:
663
+ episode: The episode to ingest.
664
+
665
+ Returns:
666
+ An ``IngestEpisodeResponse`` with the episode ID and status.
667
+ """
668
+ payload = episode.model_dump(
669
+ by_alias=False, exclude_none=True, exclude={"tenant_id"}
670
+ )
671
+ data = await self._request("POST", "/v1/episodes", json=payload)
672
+ return IngestEpisodeResponse.model_validate(data)
673
+
674
+ async def list_episodes(
675
+ self,
676
+ tenant_id: str = "default",
677
+ offset: int = 0,
678
+ limit: int = 50,
679
+ **kwargs: Any,
680
+ ) -> ListEpisodesResponse:
681
+ """List episodes with optional filtering.
682
+
683
+ Args:
684
+ tenant_id: Tenant namespace.
685
+ offset: Pagination offset.
686
+ limit: Maximum number of episodes to return.
687
+ **kwargs: Additional query parameters forwarded to the API.
688
+
689
+ Returns:
690
+ A ``ListEpisodesResponse`` with the episode list and pagination info.
691
+ """
692
+ params: dict[str, Any] = {
693
+ "tenant_id": tenant_id,
694
+ "offset": offset,
695
+ "limit": limit,
696
+ **kwargs,
697
+ }
698
+ data = await self._request("GET", "/v1/episodes", params=params)
699
+ return ListEpisodesResponse.model_validate(data)
700
+
701
+ async def health(self) -> HealthResponse:
702
+ """Check server health.
703
+
704
+ Returns:
705
+ A ``HealthResponse`` with the server's status and version.
706
+ """
707
+ data = await self._request("GET", "/v1/admin/health")
708
+ return HealthResponse.model_validate(data)
709
+
710
+ async def metrics(self) -> MetricsResponse:
711
+ """Retrieve server metrics.
712
+
713
+ Returns:
714
+ A ``MetricsResponse`` with episode counts and storage usage.
715
+ """
716
+ data = await self._request("GET", "/v1/admin/metrics")
717
+ return MetricsResponse.model_validate(data)
718
+
719
+ async def entity(self, entity_id: str, tenant_id: str = "default") -> EntityResponse:
720
+ """Get detailed information about a specific entity.
721
+
722
+ Returns entity metadata, relationships, and recent episodes.
723
+
724
+ Args:
725
+ entity_id: The entity ID to look up.
726
+ tenant_id: Tenant namespace.
727
+
728
+ Returns:
729
+ An ``EntityResponse`` with entity details, relationships, and episodes.
730
+ """
731
+ data = await self._request(
732
+ "GET",
733
+ f"/v1/entities/{entity_id}",
734
+ headers={"x-tenant-id": tenant_id},
735
+ )
736
+ return EntityResponse.model_validate(data)
737
+
738
+ async def link(
739
+ self,
740
+ source_entity_id: str,
741
+ target_entity_id: str,
742
+ relationship: str,
743
+ fact: str = "",
744
+ confidence: float = 0.9,
745
+ tenant_id: str = "default",
746
+ ) -> LinkResponse:
747
+ """Create an explicit relationship between two entities.
748
+
749
+ Args:
750
+ source_entity_id: ID of the source entity.
751
+ target_entity_id: ID of the target entity.
752
+ relationship: Relationship type (e.g., ``"DEPENDS_ON"``, ``"OWNS"``).
753
+ fact: Human-readable description of the relationship.
754
+ confidence: Confidence score (0.0 to 1.0).
755
+ tenant_id: Tenant namespace.
756
+
757
+ Returns:
758
+ A ``LinkResponse`` with the created edge ID.
759
+ """
760
+ payload = {
761
+ "source_entity_id": source_entity_id,
762
+ "target_entity_id": target_entity_id,
763
+ "relationship": relationship,
764
+ "fact": fact,
765
+ "confidence": confidence,
766
+ }
767
+ data = await self._request(
768
+ "POST",
769
+ "/v1/link",
770
+ json=payload,
771
+ headers={"x-tenant-id": tenant_id},
772
+ )
773
+ return LinkResponse.model_validate(data)
774
+
775
+ async def search(
776
+ self,
777
+ query: str,
778
+ source: str | None = None,
779
+ episode_type: str | None = None,
780
+ time_range: str | None = None,
781
+ namespace: str | None = None,
782
+ limit: int = 20,
783
+ offset: int = 0,
784
+ tenant_id: str = "default",
785
+ ) -> SearchResponse:
786
+ """Search across memories and episodes with filters.
787
+
788
+ Unlike :meth:`recall`, this returns both context and matching episodes.
789
+
790
+ Args:
791
+ query: Natural-language search query.
792
+ source: Filter by source connector (e.g., ``"slack"``, ``"github"``).
793
+ episode_type: Filter by episode type (e.g., ``"message"``, ``"incident"``).
794
+ time_range: Time range filter (e.g., ``"1h"``, ``"24h"``, ``"7d"``, ``"30d"``).
795
+ namespace: Filter by namespace.
796
+ limit: Maximum results to return.
797
+ offset: Pagination offset.
798
+ tenant_id: Tenant namespace.
799
+
800
+ Returns:
801
+ A ``SearchResponse`` with context, episodes, and metadata.
802
+ """
803
+ payload: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
804
+ filters: dict[str, str] = {}
805
+ if source:
806
+ filters["source"] = source
807
+ if episode_type:
808
+ filters["episode_type"] = episode_type
809
+ if time_range:
810
+ filters["time_range"] = time_range
811
+ if namespace:
812
+ filters["namespace"] = namespace
813
+ if filters:
814
+ payload["filters"] = filters
815
+ data = await self._request(
816
+ "POST",
817
+ "/v1/search",
818
+ json=payload,
819
+ headers={"x-tenant-id": tenant_id},
820
+ )
821
+ return SearchResponse.model_validate(data)
822
+
823
+ # -- GTM convenience methods ---------------------------------------------
824
+
825
+ async def memory(
826
+ self,
827
+ query: str,
828
+ tenant_id: str = "default",
829
+ **kwargs: Any,
830
+ ) -> RecallResponse:
831
+ """Recall memories with a single natural-language query.
832
+
833
+ This is the GTM one-liner::
834
+
835
+ await cortex.memory("why did checkout fail yesterday")
836
+
837
+ It is an alias for :meth:`recall` with the same parameter semantics.
838
+
839
+ Args:
840
+ query: Natural-language query.
841
+ tenant_id: Tenant namespace.
842
+ **kwargs: Additional keyword arguments forwarded to :meth:`recall`
843
+ (e.g. ``max_tokens``, ``min_confidence``).
844
+
845
+ Returns:
846
+ A ``RecallResponse`` containing ranked matches.
847
+ """
848
+ return await self.recall(query=query, tenant_id=tenant_id, **kwargs)
849
+
850
+ async def store(
851
+ self,
852
+ data: str | dict[str, Any],
853
+ tenant_id: str = "default",
854
+ **kwargs: Any,
855
+ ) -> RememberResponse | IngestEpisodeResponse:
856
+ """Smart store that accepts a string or a dict.
857
+
858
+ Usage::
859
+
860
+ await cortex.store("The deploy succeeded at 14:32 UTC.")
861
+ await cortex.store({"content": "deploy ok", "type": "event", "source": "ci"})
862
+
863
+ When *data* is a **string**, it delegates to :meth:`remember`.
864
+ When *data* is a **dict**, it converts it via
865
+ :meth:`IngestEpisodeRequest.from_dict` and delegates to
866
+ :meth:`ingest_episode`.
867
+
868
+ Args:
869
+ data: Either a plain string (stored via ``remember``) or a dict
870
+ describing a structured episode (stored via ``ingest_episode``).
871
+ tenant_id: Tenant namespace (used for both code paths).
872
+ **kwargs: Extra keyword arguments forwarded to ``remember`` when
873
+ *data* is a string.
874
+
875
+ Returns:
876
+ A ``RememberResponse`` (string path) or
877
+ ``IngestEpisodeResponse`` (dict path).
878
+
879
+ Raises:
880
+ TypeError: If *data* is neither a string nor a dict.
881
+ """
882
+ if isinstance(data, str):
883
+ return await self.remember(content=data, tenant_id=tenant_id, **kwargs)
884
+
885
+ if isinstance(data, dict):
886
+ data.setdefault("tenant_id", tenant_id)
887
+ episode = IngestEpisodeRequest.from_dict(data)
888
+ return await self.ingest_episode(episode)
889
+
890
+ raise TypeError(
891
+ f"store() expects a str or dict, got {type(data).__name__}"
892
+ )
893
+
894
+ async def context(
895
+ self,
896
+ query: str,
897
+ agent_id: str = "default",
898
+ tenant_id: str = "default",
899
+ max_tokens: int = 8192,
900
+ ) -> RecallResponse:
901
+ """Retrieve enriched context for an LLM agent.
902
+
903
+ Designed for feeding context windows to autonomous agents. Uses a
904
+ higher default token budget than :meth:`recall`.
905
+
906
+ Usage::
907
+
908
+ await cortex.context(agent_id="incident-bot", query="current status of payments")
909
+
910
+ Args:
911
+ query: Natural-language query describing what context is needed.
912
+ agent_id: Identifier of the requesting agent (reserved for
913
+ future per-agent retrieval policies).
914
+ tenant_id: Tenant namespace.
915
+ max_tokens: Maximum token budget for the response (default 8192).
916
+
917
+ Returns:
918
+ A ``RecallResponse`` containing ranked matches.
919
+ """
920
+ return await self.recall(
921
+ query=query,
922
+ tenant_id=tenant_id,
923
+ max_tokens=max_tokens,
924
+ )
925
+
926
+ # -- internal ------------------------------------------------------------
927
+
928
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
929
+ """Execute an async HTTP request with retry logic.
930
+
931
+ Returns:
932
+ Decoded JSON response body.
933
+
934
+ Raises:
935
+ CortexAPIError: On non-retryable HTTP errors.
936
+ CortexConnectionError: When the server is unreachable.
937
+ CortexTimeoutError: When the request exceeds the configured timeout.
938
+ """
939
+
940
+ @retry(
941
+ retry=retry_if_exception(_is_retryable),
942
+ stop=stop_after_attempt(self._max_retries),
943
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=10),
944
+ reraise=True,
945
+ )
946
+ async def _do() -> Any:
947
+ try:
948
+ response = await self._client.request(method, path, **kwargs)
949
+ except httpx.TimeoutException as exc:
950
+ raise CortexTimeoutError(str(exc)) from exc
951
+ except httpx.ConnectError as exc:
952
+ raise CortexConnectionError(str(exc)) from exc
953
+ except httpx.HTTPError as exc:
954
+ raise CortexConnectionError(str(exc)) from exc
955
+
956
+ _raise_for_status(response)
957
+
958
+ if response.status_code == 204:
959
+ return {}
960
+ return response.json()
961
+
962
+ return await _do()