parcle 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.
parcle/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Parcle — long-term memory for AI agents.
2
+
3
+ Ingest conversations and files into a per-user memory, then ask questions in
4
+ natural language and get cited answers back.
5
+
6
+ from parcle import Parcle
7
+
8
+ client = Parcle(api_key="pk_live_...")
9
+ client.ingest_dialog(user_id="ada", messages=[{"role": "user", "content": "..."}])
10
+ result = client.search(user_id="ada", query="What food should I avoid?")
11
+ print(result.answer, result.confidence, result.citations)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .client import Parcle
17
+ from .exceptions import (
18
+ AuthenticationError,
19
+ FileTooLargeError,
20
+ InternalServerError,
21
+ InvalidRequestError,
22
+ NotFoundError,
23
+ ParcleAPIError,
24
+ ParcleConfigError,
25
+ ParcleConnectionError,
26
+ ParcleError,
27
+ ParcleTimeoutError,
28
+ RateLimitError,
29
+ ServiceUnavailableError,
30
+ UnsupportedFileTypeError,
31
+ ValidationError,
32
+ )
33
+ from .models import (
34
+ Citation,
35
+ DeleteResult,
36
+ Event,
37
+ IngestDialogResult,
38
+ IngestFileResult,
39
+ Message,
40
+ SearchResult,
41
+ Session,
42
+ Source,
43
+ SourcesPage,
44
+ User,
45
+ )
46
+
47
+ __version__ = "0.1.0"
48
+
49
+ __all__ = [
50
+ "Parcle",
51
+ "__version__",
52
+ # models
53
+ "Citation",
54
+ "DeleteResult",
55
+ "Event",
56
+ "IngestDialogResult",
57
+ "IngestFileResult",
58
+ "Message",
59
+ "SearchResult",
60
+ "Session",
61
+ "Source",
62
+ "SourcesPage",
63
+ "User",
64
+ # exceptions
65
+ "ParcleError",
66
+ "ParcleConfigError",
67
+ "ParcleConnectionError",
68
+ "ParcleTimeoutError",
69
+ "ParcleAPIError",
70
+ "InvalidRequestError",
71
+ "AuthenticationError",
72
+ "NotFoundError",
73
+ "FileTooLargeError",
74
+ "UnsupportedFileTypeError",
75
+ "ValidationError",
76
+ "RateLimitError",
77
+ "InternalServerError",
78
+ "ServiceUnavailableError",
79
+ ]
parcle/client.py ADDED
@@ -0,0 +1,527 @@
1
+ """Synchronous client for the Parcle Memory API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any, Dict, IO, List, Mapping, Optional, Sequence, Tuple, Union
11
+
12
+ import httpx
13
+
14
+ from .exceptions import (
15
+ ParcleAPIError,
16
+ ParcleConfigError,
17
+ ParcleConnectionError,
18
+ ParcleTimeoutError,
19
+ error_from_response,
20
+ )
21
+ from .models import (
22
+ DeleteResult,
23
+ Event,
24
+ IngestDialogResult,
25
+ IngestFileResult,
26
+ Message,
27
+ SearchResult,
28
+ Session,
29
+ Source,
30
+ SourcesPage,
31
+ User,
32
+ )
33
+
34
+ __all__ = ["Parcle"]
35
+
36
+ DEFAULT_BASE_URL = "https://api.parcle.ai"
37
+ DEFAULT_TIMEOUT = 30.0
38
+ DEFAULT_MAX_RETRIES = 2
39
+ # Statuses worth retrying with backoff: rate limit, server error, unavailable.
40
+ _RETRY_STATUS = frozenset({429, 500, 503})
41
+
42
+ # Content types for the supported upload extensions, filling gaps left by the
43
+ # stdlib's ``mimetypes`` (which does not know markdown, for example).
44
+ _EXTRA_CONTENT_TYPES = {
45
+ ".md": "text/markdown",
46
+ ".markdown": "text/markdown",
47
+ ".msg": "application/vnd.ms-outlook",
48
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
49
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
50
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
51
+ ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
52
+ ".tsv": "text/tab-separated-values",
53
+ }
54
+
55
+ # What ``ingest_file`` accepts for ``file``: a path, a binary stream, raw bytes,
56
+ # or a ``(filename, content[, content_type])`` tuple.
57
+ FileInput = Union[
58
+ str,
59
+ os.PathLike,
60
+ IO[bytes],
61
+ bytes,
62
+ Tuple[str, Union[bytes, IO[bytes]]],
63
+ Tuple[str, Union[bytes, IO[bytes]], Optional[str]],
64
+ ]
65
+
66
+ # A message passed to ``ingest_dialog``: a plain dict or a ``Message``.
67
+ MessageInput = Union[Mapping[str, Any], Message]
68
+
69
+ # A tag / tag_filter mapping.
70
+ TagFilter = Mapping[str, Any]
71
+
72
+
73
+ class Parcle:
74
+ """Client for the Parcle Memory API.
75
+
76
+ Parameters
77
+ ----------
78
+ api_key:
79
+ Your Parcle API key. If omitted, the ``PARCLE_API_KEY`` environment
80
+ variable is used.
81
+ base_url:
82
+ Override the API base URL (e.g. for a staging environment).
83
+ timeout:
84
+ Per-request timeout in seconds.
85
+ max_retries:
86
+ How many times to retry a request that fails with a retryable status
87
+ (429/500/503) or a connection error, using exponential backoff.
88
+ http_client:
89
+ Bring your own configured :class:`httpx.Client` (proxies, custom
90
+ transport, …). Its lifetime is then yours to manage.
91
+
92
+ The client may be used as a context manager to ensure the underlying
93
+ connection pool is closed::
94
+
95
+ with Parcle() as client:
96
+ client.search(user_id="ada", query="...")
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ api_key: Optional[str] = None,
102
+ *,
103
+ base_url: str = DEFAULT_BASE_URL,
104
+ timeout: float = DEFAULT_TIMEOUT,
105
+ max_retries: int = DEFAULT_MAX_RETRIES,
106
+ http_client: Optional[httpx.Client] = None,
107
+ ) -> None:
108
+ key = api_key or os.environ.get("PARCLE_API_KEY")
109
+ if not key:
110
+ raise ParcleConfigError(
111
+ "No API key provided. Pass api_key=... or set the "
112
+ "PARCLE_API_KEY environment variable."
113
+ )
114
+ self.api_key = key
115
+ self.base_url = base_url.rstrip("/")
116
+ self.max_retries = max(0, int(max_retries))
117
+
118
+ self._owns_client = http_client is None
119
+ self._client = http_client or httpx.Client(timeout=timeout)
120
+
121
+ # -- lifecycle -------------------------------------------------------------
122
+
123
+ def close(self) -> None:
124
+ """Close the underlying HTTP client, if this instance owns it."""
125
+ if self._owns_client:
126
+ self._client.close()
127
+
128
+ def __enter__(self) -> "Parcle":
129
+ return self
130
+
131
+ def __exit__(self, *exc: Any) -> None:
132
+ self.close()
133
+
134
+ # -- users -----------------------------------------------------------------
135
+
136
+ def create_user(
137
+ self,
138
+ user_id: Optional[str] = None,
139
+ *,
140
+ name: Optional[str] = None,
141
+ timezone: Optional[str] = None,
142
+ ) -> User:
143
+ """Create or update a user.
144
+
145
+ Omit ``user_id`` to let Parcle generate one. ``timezone`` is an IANA
146
+ zone used only to interpret relative time in searches.
147
+ """
148
+ payload = _drop_none(
149
+ {"user_id": user_id, "name": name, "timezone": timezone}
150
+ )
151
+ data = self._request("POST", "/v1/users", json_body=payload)
152
+ return User.from_dict(data)
153
+
154
+ # -- ingestion -------------------------------------------------------------
155
+
156
+ def ingest_dialog(
157
+ self,
158
+ user_id: str,
159
+ messages: Sequence[MessageInput],
160
+ *,
161
+ session_id: Optional[str] = None,
162
+ tag: Optional[TagFilter] = None,
163
+ ) -> IngestDialogResult:
164
+ """Append dialog messages to a user's memory.
165
+
166
+ Omit ``session_id`` to start a new session; pass one to append. ``tag``
167
+ applies only when a new session is created.
168
+ """
169
+ payload: Dict[str, Any] = {
170
+ "user_id": user_id,
171
+ "session_id": session_id,
172
+ "messages": [_message_to_dict(m) for m in messages],
173
+ }
174
+ if tag is not None:
175
+ payload["tag"] = dict(tag)
176
+ data = self._request(
177
+ "POST", "/v1/memories/ingest_dialog", json_body=payload
178
+ )
179
+ return IngestDialogResult.from_dict(data)
180
+
181
+ def ingest_file(
182
+ self,
183
+ user_id: str,
184
+ file: FileInput,
185
+ *,
186
+ updated_at: Optional[str] = None,
187
+ tag: Optional[TagFilter] = None,
188
+ ) -> IngestFileResult:
189
+ """Upload a file into a user's memory.
190
+
191
+ ``file`` may be a path, an open binary stream, raw ``bytes``, or a
192
+ ``(filename, content[, content_type])`` tuple. For streams and bytes a
193
+ filename is required either via the stream's ``.name`` or the tuple form.
194
+ """
195
+ filename, content, content_type = _prepare_file(file)
196
+ files = {"file": (filename, content, content_type)}
197
+ form: Dict[str, Any] = {"user_id": user_id}
198
+ if updated_at is not None:
199
+ form["updated_at"] = updated_at
200
+ if tag is not None:
201
+ form["tag"] = json.dumps(tag)
202
+ data = self._request(
203
+ "POST", "/v1/memories/ingest_files", files=files, data=form
204
+ )
205
+ return IngestFileResult.from_dict(data)
206
+
207
+ # -- events ----------------------------------------------------------------
208
+
209
+ def get_event(self, user_id: str, event_id: str) -> Event:
210
+ """Fetch the ingestion status for a single write."""
211
+ data = self._request(
212
+ "POST",
213
+ "/v1/memories/events",
214
+ json_body={"user_id": user_id, "event_id": event_id},
215
+ )
216
+ return Event.from_dict(data)
217
+
218
+ def wait_until_ready(
219
+ self,
220
+ user_id: str,
221
+ event_id: str,
222
+ *,
223
+ poll_interval: float = 2.0,
224
+ timeout: Optional[float] = 120.0,
225
+ raise_on_failed: bool = True,
226
+ ) -> Event:
227
+ """Poll :meth:`get_event` until the event is ready or failed.
228
+
229
+ Returns the terminal :class:`~parcle.models.Event`. Raises
230
+ :class:`~parcle.exceptions.ParcleTimeoutError` if ``timeout`` seconds
231
+ pass first, and (by default) :class:`~parcle.exceptions.ParcleAPIError`
232
+ if ingestion failed.
233
+ """
234
+ deadline = None if timeout is None else time.monotonic() + timeout
235
+ while True:
236
+ event = self.get_event(user_id, event_id)
237
+ if event.is_terminal:
238
+ if event.is_failed and raise_on_failed:
239
+ raise ParcleAPIError(
240
+ event.error or "Ingestion failed.",
241
+ code="ingestion_failed",
242
+ )
243
+ return event
244
+ if deadline is not None and time.monotonic() >= deadline:
245
+ raise ParcleTimeoutError(
246
+ f"Event {event_id!r} not ready after {timeout}s "
247
+ f"(last status: {event.status})."
248
+ )
249
+ time.sleep(poll_interval)
250
+
251
+ # -- search ----------------------------------------------------------------
252
+
253
+ def search(
254
+ self,
255
+ user_id: str,
256
+ query: str,
257
+ *,
258
+ tag_filter: Optional[TagFilter] = None,
259
+ timezone: Optional[str] = None,
260
+ ) -> SearchResult:
261
+ """Ask a natural-language question over a user's memory.
262
+
263
+ Returns an ``answer`` grounded in source ``citations``, with a
264
+ ``confidence`` in ``[0, 1]``.
265
+ """
266
+ payload = _drop_none(
267
+ {
268
+ "user_id": user_id,
269
+ "query": query,
270
+ "tag_filter": dict(tag_filter) if tag_filter is not None else None,
271
+ "timezone": timezone,
272
+ }
273
+ )
274
+ data = self._request("POST", "/v1/memories/search", json_body=payload)
275
+ return SearchResult.from_dict(data)
276
+
277
+ # -- sources & sessions ----------------------------------------------------
278
+
279
+ def list_sources(
280
+ self,
281
+ user_id: str,
282
+ *,
283
+ type: Optional[str] = None,
284
+ tag_filter: Optional[TagFilter] = None,
285
+ page: int = 1,
286
+ limit: int = 50,
287
+ order: str = "desc",
288
+ ) -> SourcesPage:
289
+ """List a user's sources (dialog sessions and files), page by page.
290
+
291
+ ``type`` is ``"session"`` or ``"file"``; omit for both.
292
+ """
293
+ payload = _drop_none(
294
+ {
295
+ "user_id": user_id,
296
+ "type": type,
297
+ "tag_filter": dict(tag_filter) if tag_filter is not None else None,
298
+ "page": page,
299
+ "limit": limit,
300
+ "order": order,
301
+ }
302
+ )
303
+ data = self._request("POST", "/v1/memories/sources", json_body=payload)
304
+ return SourcesPage.from_dict(data)
305
+
306
+ def iter_sources(
307
+ self,
308
+ user_id: str,
309
+ *,
310
+ type: Optional[str] = None,
311
+ tag_filter: Optional[TagFilter] = None,
312
+ limit: int = 50,
313
+ order: str = "desc",
314
+ ):
315
+ """Yield every source across all pages, fetching lazily."""
316
+ page = 1
317
+ while True:
318
+ result = self.list_sources(
319
+ user_id,
320
+ type=type,
321
+ tag_filter=tag_filter,
322
+ page=page,
323
+ limit=limit,
324
+ order=order,
325
+ )
326
+ for source in result.sources:
327
+ yield source
328
+ if page >= result.total_pages or not result.sources:
329
+ return
330
+ page += 1
331
+
332
+ def get_session(self, user_id: str, session_id: str) -> Session:
333
+ """Read a dialog session's original messages in chronological order."""
334
+ data = self._request(
335
+ "POST",
336
+ "/v1/memories/sessions",
337
+ json_body={"user_id": user_id, "session_id": session_id},
338
+ )
339
+ return Session.from_dict(data)
340
+
341
+ # -- deletion --------------------------------------------------------------
342
+
343
+ def delete_by_session(self, user_id: str, session_id: str) -> DeleteResult:
344
+ """Delete all memory derived from a dialog session."""
345
+ return self._delete(
346
+ "/v1/memories/by_session",
347
+ {"user_id": user_id, "session_id": session_id},
348
+ )
349
+
350
+ def delete_by_file(self, user_id: str, file_id: str) -> DeleteResult:
351
+ """Delete all memory derived from a file."""
352
+ return self._delete(
353
+ "/v1/memories/by_file",
354
+ {"user_id": user_id, "file_id": file_id},
355
+ )
356
+
357
+ def delete_by_tag(self, user_id: str, tag_filter: TagFilter) -> DeleteResult:
358
+ """Delete all memory whose source tags match ``tag_filter``.
359
+
360
+ ``tag_filter`` must be non-empty.
361
+ """
362
+ if not tag_filter:
363
+ raise ParcleConfigError("delete_by_tag requires a non-empty tag_filter.")
364
+ return self._delete(
365
+ "/v1/memories/by_tag",
366
+ {"user_id": user_id, "tag_filter": dict(tag_filter)},
367
+ )
368
+
369
+ def _delete(self, path: str, body: Dict[str, Any]) -> DeleteResult:
370
+ data = self._request("DELETE", path, json_body=body)
371
+ return DeleteResult.from_dict(data)
372
+
373
+ # -- transport -------------------------------------------------------------
374
+
375
+ def _request(
376
+ self,
377
+ method: str,
378
+ path: str,
379
+ *,
380
+ json_body: Optional[Dict[str, Any]] = None,
381
+ data: Optional[Dict[str, Any]] = None,
382
+ files: Optional[Dict[str, Any]] = None,
383
+ ) -> Dict[str, Any]:
384
+ url = f"{self.base_url}{path}"
385
+ headers = {
386
+ "Authorization": f"Bearer {self.api_key}",
387
+ "Accept": "application/json",
388
+ }
389
+
390
+ last_exc: Optional[Exception] = None
391
+ for attempt in range(self.max_retries + 1):
392
+ try:
393
+ response = self._client.request(
394
+ method,
395
+ url,
396
+ headers=headers,
397
+ json=json_body,
398
+ data=data,
399
+ files=files,
400
+ )
401
+ except httpx.TimeoutException as exc:
402
+ last_exc = ParcleConnectionError(f"Request to {url} timed out.")
403
+ last_exc.__cause__ = exc
404
+ except httpx.HTTPError as exc:
405
+ last_exc = ParcleConnectionError(f"Request to {url} failed: {exc}")
406
+ last_exc.__cause__ = exc
407
+ else:
408
+ if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
409
+ self._sleep_for_retry(response, attempt)
410
+ continue
411
+ return self._parse_response(response)
412
+
413
+ # Reached only on a connection-level failure; back off and retry.
414
+ if attempt < self.max_retries:
415
+ time.sleep(_backoff_seconds(attempt))
416
+ continue
417
+ assert last_exc is not None
418
+ raise last_exc
419
+
420
+ # Unreachable, but keeps type-checkers happy.
421
+ assert last_exc is not None
422
+ raise last_exc
423
+
424
+ def _parse_response(self, response: httpx.Response) -> Dict[str, Any]:
425
+ body: Any
426
+ try:
427
+ body = response.json() if response.content else None
428
+ except (json.JSONDecodeError, ValueError):
429
+ body = None
430
+
431
+ if response.is_success:
432
+ return body if isinstance(body, dict) else {}
433
+
434
+ raise error_from_response(
435
+ response.status_code,
436
+ body,
437
+ fallback_message=(
438
+ response.text or f"Request failed with status {response.status_code}."
439
+ ),
440
+ )
441
+
442
+ @staticmethod
443
+ def _sleep_for_retry(response: httpx.Response, attempt: int) -> None:
444
+ retry_after = response.headers.get("Retry-After")
445
+ if retry_after:
446
+ try:
447
+ time.sleep(float(retry_after))
448
+ return
449
+ except ValueError:
450
+ pass
451
+ time.sleep(_backoff_seconds(attempt))
452
+
453
+
454
+ # -- module-level helpers ------------------------------------------------------
455
+
456
+
457
+ def _drop_none(d: Dict[str, Any]) -> Dict[str, Any]:
458
+ """Drop keys whose value is None so they are omitted from the request."""
459
+ return {k: v for k, v in d.items() if v is not None}
460
+
461
+
462
+ def _backoff_seconds(attempt: int) -> float:
463
+ """Exponential backoff: 0.5s, 1s, 2s, … capped at 8s."""
464
+ return min(0.5 * (2 ** attempt), 8.0)
465
+
466
+
467
+ def _message_to_dict(message: MessageInput) -> Dict[str, Any]:
468
+ if isinstance(message, Message):
469
+ return message.to_dict()
470
+ if isinstance(message, Mapping):
471
+ if "role" not in message or "content" not in message:
472
+ raise ParcleConfigError(
473
+ "Each message requires 'role' and 'content' keys."
474
+ )
475
+ return _drop_none(dict(message))
476
+ raise TypeError(
477
+ f"Message must be a dict or Message, got {type(message).__name__}."
478
+ )
479
+
480
+
481
+ def _guess_content_type(filename: str) -> str:
482
+ suffix = Path(filename).suffix.lower()
483
+ if suffix in _EXTRA_CONTENT_TYPES:
484
+ return _EXTRA_CONTENT_TYPES[suffix]
485
+ guessed, _ = mimetypes.guess_type(filename)
486
+ return guessed or "application/octet-stream"
487
+
488
+
489
+ def _prepare_file(
490
+ file: FileInput,
491
+ ) -> Tuple[str, Union[bytes, IO[bytes]], str]:
492
+ """Normalise the ``file`` argument into ``(filename, content, content_type)``."""
493
+ # (filename, content) or (filename, content, content_type)
494
+ if isinstance(file, tuple):
495
+ if len(file) == 2:
496
+ filename, content = file
497
+ content_type = None
498
+ elif len(file) == 3:
499
+ filename, content, content_type = file
500
+ else:
501
+ raise ParcleConfigError(
502
+ "file tuple must be (filename, content[, content_type])."
503
+ )
504
+ return filename, content, content_type or _guess_content_type(filename)
505
+
506
+ # Path-like → open and read.
507
+ if isinstance(file, (str, os.PathLike)):
508
+ path = Path(file)
509
+ if not path.is_file():
510
+ raise ParcleConfigError(f"File not found: {path}")
511
+ return path.name, path.read_bytes(), _guess_content_type(path.name)
512
+
513
+ # Raw bytes with no filename — we can't infer one.
514
+ if isinstance(file, (bytes, bytearray)):
515
+ raise ParcleConfigError(
516
+ "Raw bytes need a filename; pass a (filename, bytes) tuple instead."
517
+ )
518
+
519
+ # Assume a binary stream; derive the filename from .name if present.
520
+ name = getattr(file, "name", None)
521
+ if not name:
522
+ raise ParcleConfigError(
523
+ "Could not determine a filename for the file stream; pass a "
524
+ "(filename, stream) tuple instead."
525
+ )
526
+ filename = os.path.basename(name)
527
+ return filename, file, _guess_content_type(filename)
parcle/exceptions.py ADDED
@@ -0,0 +1,151 @@
1
+ """Exception hierarchy for the Parcle client.
2
+
3
+ Every non-2xx API response is translated into a :class:`ParcleAPIError`
4
+ subclass chosen by HTTP status. The server's stable ``error.code`` string and
5
+ human-readable ``error.message`` are preserved on the exception, along with the
6
+ ``request_id`` for support correlation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ class ParcleError(Exception):
15
+ """Base class for every error raised by this library."""
16
+
17
+
18
+ class ParcleConfigError(ParcleError):
19
+ """Raised for misconfiguration, e.g. a missing API key."""
20
+
21
+
22
+ class ParcleConnectionError(ParcleError):
23
+ """Raised when the request never produced an HTTP response.
24
+
25
+ Covers connection failures, DNS errors, and timeouts.
26
+ """
27
+
28
+
29
+ class ParcleTimeoutError(ParcleError):
30
+ """Raised when a polling helper exceeds its deadline."""
31
+
32
+
33
+ class ParcleAPIError(ParcleError):
34
+ """Raised when the API returns a non-2xx response.
35
+
36
+ Attributes
37
+ ----------
38
+ status_code:
39
+ The HTTP status code of the response.
40
+ code:
41
+ The stable ``error.code`` string for programmatic handling.
42
+ message:
43
+ The human-readable ``error.message``.
44
+ request_id:
45
+ The server-assigned request id, useful for support.
46
+ body:
47
+ The raw decoded response body, when available.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ status_code: Optional[int] = None,
55
+ code: Optional[str] = None,
56
+ request_id: Optional[str] = None,
57
+ body: Optional[Any] = None,
58
+ ) -> None:
59
+ super().__init__(message)
60
+ self.status_code = status_code
61
+ self.code = code
62
+ self.message = message
63
+ self.request_id = request_id
64
+ self.body = body
65
+
66
+ def __str__(self) -> str:
67
+ parts = []
68
+ if self.status_code is not None:
69
+ parts.append(f"HTTP {self.status_code}")
70
+ if self.code:
71
+ parts.append(self.code)
72
+ prefix = f"[{' '.join(parts)}] " if parts else ""
73
+ suffix = f" (request_id={self.request_id})" if self.request_id else ""
74
+ return f"{prefix}{self.message}{suffix}"
75
+
76
+
77
+ # -- Status-specific subclasses ------------------------------------------------
78
+
79
+
80
+ class InvalidRequestError(ParcleAPIError):
81
+ """HTTP 400 — malformed JSON, wrong field type, or missing required field."""
82
+
83
+
84
+ class AuthenticationError(ParcleAPIError):
85
+ """HTTP 401 — missing or invalid bearer key."""
86
+
87
+
88
+ class NotFoundError(ParcleAPIError):
89
+ """HTTP 404 — unknown user, session, file, or event."""
90
+
91
+
92
+ class FileTooLargeError(ParcleAPIError):
93
+ """HTTP 413 — uploaded file exceeds the size limit."""
94
+
95
+
96
+ class UnsupportedFileTypeError(ParcleAPIError):
97
+ """HTTP 415 — unsupported extension or database-like file."""
98
+
99
+
100
+ class ValidationError(ParcleAPIError):
101
+ """HTTP 422 — well-formed request that breaks a rule."""
102
+
103
+
104
+ class RateLimitError(ParcleAPIError):
105
+ """HTTP 429 — too many requests."""
106
+
107
+
108
+ class InternalServerError(ParcleAPIError):
109
+ """HTTP 500 — unexpected server failure."""
110
+
111
+
112
+ class ServiceUnavailableError(ParcleAPIError):
113
+ """HTTP 503 — temporarily overloaded or down."""
114
+
115
+
116
+ _STATUS_TO_EXCEPTION: Dict[int, type] = {
117
+ 400: InvalidRequestError,
118
+ 401: AuthenticationError,
119
+ 404: NotFoundError,
120
+ 413: FileTooLargeError,
121
+ 415: UnsupportedFileTypeError,
122
+ 422: ValidationError,
123
+ 429: RateLimitError,
124
+ 500: InternalServerError,
125
+ 503: ServiceUnavailableError,
126
+ }
127
+
128
+
129
+ def error_from_response(
130
+ status_code: int, body: Optional[Any], *, fallback_message: str
131
+ ) -> ParcleAPIError:
132
+ """Build the appropriate :class:`ParcleAPIError` for a failed response."""
133
+ code: Optional[str] = None
134
+ message = fallback_message
135
+ request_id: Optional[str] = None
136
+
137
+ if isinstance(body, dict):
138
+ err = body.get("error")
139
+ if isinstance(err, dict):
140
+ code = err.get("code")
141
+ message = err.get("message") or message
142
+ request_id = err.get("request_id")
143
+
144
+ exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ParcleAPIError)
145
+ return exc_cls(
146
+ message,
147
+ status_code=status_code,
148
+ code=code,
149
+ request_id=request_id,
150
+ body=body,
151
+ )
parcle/models.py ADDED
@@ -0,0 +1,222 @@
1
+ """Typed response models returned by the Parcle client.
2
+
3
+ These are lightweight dataclasses parsed from the API's JSON responses. Unknown
4
+ fields are ignored, so the models stay forward-compatible as the API grows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ # A free-form tag attached to a source: a flat mapping of string keys to scalar
13
+ # values used for grouping and filtering memory within one user.
14
+ Tag = Dict[str, Any]
15
+
16
+
17
+ @dataclass
18
+ class User:
19
+ """A memory namespace returned by :meth:`Parcle.create_user`."""
20
+
21
+ user_id: str
22
+ name: Optional[str] = None
23
+ timezone: str = "UTC"
24
+ is_new: bool = False
25
+
26
+ @classmethod
27
+ def from_dict(cls, data: Dict[str, Any]) -> "User":
28
+ return cls(
29
+ user_id=data["user_id"],
30
+ name=data.get("name"),
31
+ timezone=data.get("timezone", "UTC"),
32
+ is_new=bool(data.get("is_new", False)),
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class Message:
38
+ """A single dialog message, in or out."""
39
+
40
+ role: str
41
+ content: str
42
+ speaker: Optional[str] = None
43
+ updated_at: Optional[str] = None
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: Dict[str, Any]) -> "Message":
47
+ return cls(
48
+ role=data["role"],
49
+ content=data["content"],
50
+ speaker=data.get("speaker"),
51
+ updated_at=data.get("updated_at"),
52
+ )
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ out: Dict[str, Any] = {"role": self.role, "content": self.content}
56
+ if self.speaker is not None:
57
+ out["speaker"] = self.speaker
58
+ if self.updated_at is not None:
59
+ out["updated_at"] = self.updated_at
60
+ return out
61
+
62
+
63
+ @dataclass
64
+ class IngestDialogResult:
65
+ """Result of :meth:`Parcle.ingest_dialog`."""
66
+
67
+ session_id: str
68
+ event_id: str
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: Dict[str, Any]) -> "IngestDialogResult":
72
+ return cls(session_id=data["session_id"], event_id=data["event_id"])
73
+
74
+
75
+ @dataclass
76
+ class IngestFileResult:
77
+ """Result of :meth:`Parcle.ingest_file`."""
78
+
79
+ file_id: str
80
+ event_id: str
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: Dict[str, Any]) -> "IngestFileResult":
84
+ return cls(file_id=data["file_id"], event_id=data["event_id"])
85
+
86
+
87
+ @dataclass
88
+ class Event:
89
+ """Ingestion status for a single write, from :meth:`Parcle.get_event`."""
90
+
91
+ event_id: str
92
+ status: str
93
+ error: Optional[str] = None
94
+
95
+ @property
96
+ def is_ready(self) -> bool:
97
+ """True once the ingested content is searchable."""
98
+ return self.status == "ready"
99
+
100
+ @property
101
+ def is_failed(self) -> bool:
102
+ return self.status == "failed"
103
+
104
+ @property
105
+ def is_terminal(self) -> bool:
106
+ """True once the event will not change state again."""
107
+ return self.status in ("ready", "failed")
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: Dict[str, Any]) -> "Event":
111
+ return cls(
112
+ event_id=data["event_id"],
113
+ status=data["status"],
114
+ error=data.get("error"),
115
+ )
116
+
117
+
118
+ @dataclass
119
+ class Citation:
120
+ """A source the search answer draws on."""
121
+
122
+ type: str # "session" | "file"
123
+ id: str
124
+
125
+ @classmethod
126
+ def from_dict(cls, data: Dict[str, Any]) -> "Citation":
127
+ return cls(type=data["type"], id=data["id"])
128
+
129
+
130
+ @dataclass
131
+ class SearchResult:
132
+ """Result of :meth:`Parcle.search` — an answer grounded in citations."""
133
+
134
+ answer: str
135
+ confidence: float
136
+ citations: List[Citation] = field(default_factory=list)
137
+
138
+ @classmethod
139
+ def from_dict(cls, data: Dict[str, Any]) -> "SearchResult":
140
+ return cls(
141
+ answer=data["answer"],
142
+ confidence=float(data["confidence"]),
143
+ citations=[Citation.from_dict(c) for c in data.get("citations", [])],
144
+ )
145
+
146
+
147
+ @dataclass
148
+ class Source:
149
+ """A dialog session or file in a user's memory."""
150
+
151
+ id: str
152
+ type: str # "session" | "file"
153
+ updated_at: Optional[str] = None
154
+ tag: Optional[Tag] = None
155
+ name: Optional[str] = None # filename, files only
156
+
157
+ @classmethod
158
+ def from_dict(cls, data: Dict[str, Any]) -> "Source":
159
+ return cls(
160
+ id=data["id"],
161
+ type=data["type"],
162
+ updated_at=data.get("updated_at"),
163
+ tag=data.get("tag"),
164
+ name=data.get("name"),
165
+ )
166
+
167
+
168
+ @dataclass
169
+ class SourcesPage:
170
+ """One page of :meth:`Parcle.list_sources`."""
171
+
172
+ sources: List[Source]
173
+ page: int
174
+ total_pages: int
175
+ total: int
176
+
177
+ def __iter__(self):
178
+ return iter(self.sources)
179
+
180
+ def __len__(self) -> int:
181
+ return len(self.sources)
182
+
183
+ @classmethod
184
+ def from_dict(cls, data: Dict[str, Any]) -> "SourcesPage":
185
+ return cls(
186
+ sources=[Source.from_dict(s) for s in data.get("sources", [])],
187
+ page=int(data.get("page", 1)),
188
+ total_pages=int(data.get("total_pages", 1)),
189
+ total=int(data.get("total", 0)),
190
+ )
191
+
192
+
193
+ @dataclass
194
+ class Session:
195
+ """A dialog session's original messages, from :meth:`Parcle.get_session`."""
196
+
197
+ session_id: str
198
+ messages: List[Message]
199
+ tag: Optional[Tag] = None
200
+
201
+ @classmethod
202
+ def from_dict(cls, data: Dict[str, Any]) -> "Session":
203
+ return cls(
204
+ session_id=data["session_id"],
205
+ messages=[Message.from_dict(m) for m in data.get("messages", [])],
206
+ tag=data.get("tag"),
207
+ )
208
+
209
+
210
+ @dataclass
211
+ class DeleteResult:
212
+ """Result of any ``delete_by_*`` call."""
213
+
214
+ deleted: bool
215
+ deleted_count: int
216
+
217
+ @classmethod
218
+ def from_dict(cls, data: Dict[str, Any]) -> "DeleteResult":
219
+ return cls(
220
+ deleted=bool(data["deleted"]),
221
+ deleted_count=int(data["deleted_count"]),
222
+ )
parcle/py.typed ADDED
File without changes
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: parcle
3
+ Version: 0.1.0
4
+ Summary: Long-term memory for AI agents — a Python client for the Parcle Memory API.
5
+ Project-URL: Homepage, https://parcle.ai
6
+ Project-URL: Documentation, https://api.parcle.ai
7
+ Author: Parcle
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,llm,memory,parcle,rag
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.24
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == 'dev'
25
+ Requires-Dist: respx>=0.20; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ <div align="center">
29
+
30
+ # Parcle
31
+
32
+ **Long-term memory for AI agents**
33
+
34
+ Ingest conversations and files, then ask questions in natural language and get
35
+ cited answers back. Give every user a private, persistent agent memory.
36
+
37
+ </div>
38
+
39
+ ---
40
+
41
+ ## Why Parcle?
42
+
43
+ LLMs forget everything between calls. Parcle gives every user a private memory you
44
+ can write to and search:
45
+
46
+ - 🧠 **Per-user memory** — scope everything to a `user_id`.
47
+ - 💬 **Ingest anything** — chat transcripts and files (PDF, Markdown, text, …) go in the same place.
48
+ - 🔎 **Ask, don't query** — search returns a synthesized **answer** with **citations**, not just raw chunks.
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install parcle
54
+ ```
55
+
56
+ ## Quickstart
57
+
58
+ ```python
59
+ from parcle import Parcle
60
+
61
+ # Reads PARCLE_API_KEY from the environment if api_key is omitted.
62
+ client = Parcle(api_key="pk_live_...")
63
+
64
+ # 1. Write a conversation into a user's memory.
65
+ # Ingestion is incremental: omit session_id to start a new session, then
66
+ # pass the returned session_id back to append more turns to the same one.
67
+ result = client.ingest_dialog(
68
+ user_id="ada",
69
+ messages=[
70
+ {"role": "user", "content": "I'm allergic to peanuts."},
71
+ {"role": "assistant", "content": "Got it — I'll avoid peanuts in suggestions."},
72
+ ],
73
+ )
74
+ client.ingest_dialog(
75
+ user_id="ada",
76
+ session_id=result.session_id, # append to the same session
77
+ messages=[
78
+ {"role": "user", "content": "Also, I don't eat shellfish."},
79
+ ],
80
+ )
81
+
82
+ # 2. ...or ingest a file (PDF, Markdown, text, …).
83
+ client.ingest_file(user_id="ada", file="diet-notes.pdf")
84
+
85
+ # 3. Ask a question. You get an answer with confidence and citations.
86
+ result = client.search(user_id="ada", query="What food should I avoid?")
87
+
88
+ print(result.answer) # "You're allergic to peanuts, so avoid them."
89
+ print(result.confidence) # 0.92
90
+ print(result.citations) # [Citation(type="session", id="...")]
91
+ ```
@@ -0,0 +1,9 @@
1
+ parcle/__init__.py,sha256=KHPp1LaiZJYHKEyS4rrxKmafIPIzgzZ3TrSBBQNorrQ,1741
2
+ parcle/client.py,sha256=Bd5BmUN8RDEg6BJXCrggq-DEshWKMUCKYBt_9LuuEAM,17870
3
+ parcle/exceptions.py,sha256=PPyhyna7VHhR4JtgDP8ZrvsIqOQxpisBJvSN0fvUIBY,4226
4
+ parcle/models.py,sha256=Csq3tsCYONdUYDPscYZ14qgsPIiTtgWUniS2QSlew_s,5748
5
+ parcle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ parcle-0.1.0.dist-info/METADATA,sha256=BUOzK3xp6-6tqz8it2AR-vBwA4JktHpGEZbTAOTR9fU,2893
7
+ parcle-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ parcle-0.1.0.dist-info/licenses/LICENSE,sha256=cEfzkpOGKfxoc-4jVvIUb9uIBejC__tKqou_kcOmYAs,1063
9
+ parcle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parcle
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.