crewai-pastdotdev 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,23 @@
1
+ """past dev for CrewAI: Ingest and Recall as crew memory and as agent tools."""
2
+
3
+ from crewai_pastdotdev._version import __version__
4
+ from crewai_pastdotdev.client import DEFAULT_API_HOST, PastDevClient, PastDevError
5
+ from crewai_pastdotdev.memory import PastDevMemory
6
+ from crewai_pastdotdev.tools import (
7
+ PastDevIngestInput,
8
+ PastDevIngestTool,
9
+ PastDevRecallInput,
10
+ PastDevRecallTool,
11
+ )
12
+
13
+ __all__ = [
14
+ "DEFAULT_API_HOST",
15
+ "PastDevClient",
16
+ "PastDevError",
17
+ "PastDevIngestInput",
18
+ "PastDevIngestTool",
19
+ "PastDevMemory",
20
+ "PastDevRecallInput",
21
+ "PastDevRecallTool",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,85 @@
1
+ """Helpers shared by the memory and the tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from pydantic import SecretStr
11
+
12
+ from crewai_pastdotdev.client import PastDevClient
13
+
14
+ MAX_IDENTITY_LENGTH = 200
15
+ RECALL_LIMIT_MAX = 200
16
+
17
+ _FRACTION = re.compile(r"(\.\d{6})\d+")
18
+
19
+
20
+ def check_identity(identity: str) -> str:
21
+ """Return the identity, or raise ``ValueError`` if past dev would refuse it."""
22
+ if not identity or not identity.strip():
23
+ raise ValueError("past dev needs an identity: the id of the person the memory is about.")
24
+ if len(identity) > MAX_IDENTITY_LENGTH:
25
+ raise ValueError(f"A past dev identity is at most {MAX_IDENTITY_LENGTH} characters.")
26
+ return identity
27
+
28
+
29
+ def make_client(
30
+ client: PastDevClient | None, api_key: SecretStr | None, api_host: str | None
31
+ ) -> PastDevClient:
32
+ if client is not None:
33
+ return client
34
+ return PastDevClient(
35
+ api_key=api_key.get_secret_value() if api_key is not None else None,
36
+ api_host=api_host,
37
+ )
38
+
39
+
40
+ def source_id_for(identity: str, content: str) -> str:
41
+ """A stable source id for one piece of content from one identity.
42
+
43
+ The same content from the same identity gets the same id, so a repeated
44
+ ingest is recognized as unchanged and costs nothing. Two identities that
45
+ send the same words keep two separate data points.
46
+ """
47
+ digest = hashlib.sha256(f"{identity}\x00{content}".encode()).hexdigest()
48
+ return f"crewai-{digest}"
49
+
50
+
51
+ def clamp_limit(limit: int) -> int:
52
+ return max(1, min(int(limit), RECALL_LIMIT_MAX))
53
+
54
+
55
+ def parse_datetime(value: str | None) -> datetime | None:
56
+ """Parse an API date-time (ISO 8601, ``Z`` suffix, up to 7 decimals)."""
57
+ if not value:
58
+ return None
59
+ text = _FRACTION.sub(r"\1", value.replace("Z", "+00:00"))
60
+ try:
61
+ parsed = datetime.fromisoformat(text)
62
+ except ValueError:
63
+ return None
64
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
65
+
66
+
67
+ def format_recall(response: dict[str, Any]) -> str:
68
+ """Render a recall response as text for an agent to read."""
69
+ results = response.get("results") or []
70
+ if not results:
71
+ return "past dev found no relevant memories."
72
+ lines = [f"past dev recalled {len(results)} memories, most relevant first:"]
73
+ for result in results:
74
+ date = (result.get("occurredAt") or "")[:10]
75
+ artifact = result.get("artifact") or {}
76
+ superseded = artifact.get("supersededAt")
77
+ head = f"{result.get('rank', '?')}. [{date}] {result.get('content', '').strip()}"
78
+ if superseded:
79
+ head += f" (superseded on {superseded[:10]})"
80
+ lines.append(head)
81
+ for source in result.get("sources") or []:
82
+ name = source.get("label") or source.get("sourceId")
83
+ for excerpt in source.get("excerpts") or []:
84
+ lines.append(f' - "{excerpt}" ({name}, {(source.get("occurredAt") or "")[:10]})')
85
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,289 @@
1
+ """A small HTTP client for the past dev Memory API.
2
+
3
+ Only the operations this package uses: Ingest, Recall, Delete data point,
4
+ and the two list reads (audiences, identities). See
5
+ https://past.dev/docs/memory-api/api-reference for the full API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from collections.abc import Iterator
13
+ from datetime import datetime
14
+ from typing import Any, Literal
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+
19
+ from crewai_pastdotdev._version import __version__
20
+
21
+ DEFAULT_API_HOST = "https://api.past.dev"
22
+
23
+ RecallLevel = Literal["low", "medium", "high", "extra-high"]
24
+ RecallSort = Literal["relevance", "chronological"]
25
+
26
+ # A 429 with one of these codes is a monthly or billing-period cap.
27
+ # Waiting a few seconds does not lift it, so it is not retried.
28
+ _CAP_CODES = frozenset({"project-cap-reached", "spend-cap-reached"})
29
+
30
+
31
+ class PastDevError(Exception):
32
+ """An error response from the past dev API, or a failed request.
33
+
34
+ ``code`` is the API's stable kebab-case code (for example
35
+ ``out-of-credits``), ``status`` the HTTP status (0 when no response came
36
+ back) and ``request_id`` the ``X-Request-Id`` header, which past dev
37
+ support can trace.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ code: str,
43
+ status: int,
44
+ message: str | None = None,
45
+ request_id: str | None = None,
46
+ data: Any = None,
47
+ ) -> None:
48
+ self.code = code
49
+ self.status = status
50
+ self.message = message
51
+ self.request_id = request_id
52
+ self.data = data
53
+ text = f"past dev error {code} (HTTP {status})"
54
+ if message:
55
+ text += f": {message}"
56
+ if request_id:
57
+ text += f" [request id {request_id}]"
58
+ super().__init__(text)
59
+
60
+
61
+ def _iso(value: datetime | str | None) -> str | None:
62
+ if value is None or isinstance(value, str):
63
+ return value
64
+ return value.isoformat()
65
+
66
+
67
+ def _compact(body: dict[str, Any]) -> dict[str, Any]:
68
+ return {k: v for k, v in body.items() if v is not None}
69
+
70
+
71
+ class PastDevClient:
72
+ """Synchronous client for the past dev Memory API.
73
+
74
+ Args:
75
+ api_key: A project API key (``past_sk_...``). Read from the
76
+ ``PAST_API_KEY`` environment variable when omitted.
77
+ api_host: The API host. Read from ``PAST_API_HOST`` when omitted,
78
+ else ``https://api.past.dev``. Set it when past dev runs in your
79
+ own cloud.
80
+ timeout: Seconds before a request times out.
81
+ max_retries: How many times a rate-limited request (HTTP 429) is
82
+ sent again, after the wait the server asks for. Other errors are
83
+ never retried.
84
+ transport: An ``httpx`` transport, for tests.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ api_key: str | None = None,
90
+ api_host: str | None = None,
91
+ *,
92
+ timeout: float = 30.0,
93
+ max_retries: int = 2,
94
+ transport: httpx.BaseTransport | None = None,
95
+ ) -> None:
96
+ key = api_key or os.environ.get("PAST_API_KEY")
97
+ if not key:
98
+ raise ValueError(
99
+ "past dev needs a project API key: pass api_key or set PAST_API_KEY."
100
+ )
101
+ host = (api_host or os.environ.get("PAST_API_HOST") or DEFAULT_API_HOST).rstrip("/")
102
+ self.api_host = host
103
+ self.max_retries = max_retries
104
+ self._http = httpx.Client(
105
+ base_url=host,
106
+ timeout=timeout,
107
+ transport=transport,
108
+ headers={
109
+ "Authorization": f"Bearer {key}",
110
+ "User-Agent": f"crewai-pastdotdev/{__version__}",
111
+ "Accept": "application/json",
112
+ },
113
+ )
114
+
115
+ def __repr__(self) -> str:
116
+ return f"PastDevClient(api_host={self.api_host!r})"
117
+
118
+ def __deepcopy__(self, memo: dict[int, Any]) -> PastDevClient:
119
+ # CrewAI deep-copies agents and memory. The HTTP pool is thread-safe,
120
+ # so copies share it instead of opening a second one.
121
+ return self
122
+
123
+ def close(self) -> None:
124
+ self._http.close()
125
+
126
+ def __enter__(self) -> PastDevClient:
127
+ return self
128
+
129
+ def __exit__(self, *exc: object) -> None:
130
+ self.close()
131
+
132
+ # -- operations ---------------------------------------------------------
133
+
134
+ def ingest(
135
+ self,
136
+ content: str,
137
+ *,
138
+ identity: str | None = None,
139
+ id: str | None = None,
140
+ timestamp: datetime | str | None = None,
141
+ audience: str | None = None,
142
+ label: str | None = None,
143
+ metadata: Any = None,
144
+ idempotency_key: str | None = None,
145
+ ) -> dict[str, Any]:
146
+ """Ingest one data point. ``POST /api/v1/ingest``.
147
+
148
+ Returns the receipt: ``projectId``, ``ingestionId``, ``sourceId``,
149
+ ``status`` (``queued`` or ``completed``) and ``unchanged``.
150
+ """
151
+ body = _compact(
152
+ {
153
+ "content": content,
154
+ "identity": identity,
155
+ "id": id,
156
+ "timestamp": _iso(timestamp),
157
+ "audience": audience,
158
+ "label": label,
159
+ "metadata": metadata,
160
+ "idempotencyKey": idempotency_key,
161
+ }
162
+ )
163
+ return self._request("POST", "/api/v1/ingest", json=body)
164
+
165
+ def recall(
166
+ self,
167
+ query: str,
168
+ *,
169
+ identity: str,
170
+ limit: int | None = None,
171
+ level: RecallLevel | None = None,
172
+ max_tokens: int | None = None,
173
+ query_timestamp: datetime | str | None = None,
174
+ occurred_from: datetime | str | None = None,
175
+ occurred_to: datetime | str | None = None,
176
+ sort: RecallSort | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Recall ranked memory documents. ``POST /api/v1/recall``.
179
+
180
+ Returns ``asOf``, ``usedEvidenceTokens`` and ``results``; each result
181
+ carries ``id``, ``rank``, ``occurredAt``, ``content``, ``artifact``
182
+ and ``sources``.
183
+ """
184
+ if level is not None and max_tokens is not None:
185
+ raise ValueError("Send level or max_tokens, never both.")
186
+ body = _compact(
187
+ {
188
+ "query": query,
189
+ "identity": identity,
190
+ "limit": limit,
191
+ "level": level,
192
+ "maxTokens": max_tokens,
193
+ "queryTimestamp": _iso(query_timestamp),
194
+ "occurredFrom": _iso(occurred_from),
195
+ "occurredTo": _iso(occurred_to),
196
+ "sort": sort,
197
+ }
198
+ )
199
+ return self._request("POST", "/api/v1/recall", json=body)
200
+
201
+ def delete_data_point(self, source_id: str) -> dict[str, Any]:
202
+ """Delete one data point by its source id.
203
+ ``DELETE /api/v1/data-points/{sourceId}``.
204
+
205
+ Returns the deletion receipt (``operationId``, ``ids``). The purge
206
+ completes in the background.
207
+ """
208
+ return self._request("DELETE", f"/api/v1/data-points/{quote(source_id, safe='')}")
209
+
210
+ def list_audiences(self) -> list[dict[str, Any]]:
211
+ """List the project's audiences. ``GET /api/v1/audiences``.
212
+
213
+ Costs no credits, so it also serves as a connection test.
214
+ """
215
+ return list(self._request("GET", "/api/v1/audiences").get("audiences", []))
216
+
217
+ def list_identities(self, *, page_size: int = 100) -> Iterator[dict[str, Any]]:
218
+ """Iterate over every identity of the project, page by page.
219
+ ``GET /api/v1/identities``.
220
+ """
221
+ after: str | None = None
222
+ while True:
223
+ params: dict[str, Any] = {"limit": page_size}
224
+ if after:
225
+ params["after"] = after
226
+ page = self._request("GET", "/api/v1/identities", params=params)
227
+ yield from page.get("identities", [])
228
+ after = page.get("endCursor")
229
+ if not page.get("hasMore") or not after:
230
+ return
231
+
232
+ def check_connection(self) -> None:
233
+ """Raise :class:`PastDevError` unless the key and host work.
234
+
235
+ Calls ``GET /api/v1/audiences``, which costs no credits.
236
+ """
237
+ self.list_audiences()
238
+
239
+ # -- transport ----------------------------------------------------------
240
+
241
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
242
+ attempt = 0
243
+ while True:
244
+ try:
245
+ response = self._http.request(method, path, **kwargs)
246
+ except httpx.HTTPError as exc:
247
+ raise PastDevError("network-error", 0, str(exc)) from exc
248
+ if response.status_code < 400:
249
+ if not response.content:
250
+ return {}
251
+ return dict(response.json())
252
+ error = _error_from(response)
253
+ if (
254
+ response.status_code == 429
255
+ and error.code not in _CAP_CODES
256
+ and attempt < self.max_retries
257
+ ):
258
+ attempt += 1
259
+ time.sleep(_retry_after(response, attempt))
260
+ continue
261
+ raise error
262
+
263
+
264
+ def _error_from(response: httpx.Response) -> PastDevError:
265
+ request_id = response.headers.get("X-Request-Id")
266
+ try:
267
+ body = response.json()
268
+ except ValueError:
269
+ body = None
270
+ if isinstance(body, dict) and "code" in body:
271
+ return PastDevError(
272
+ str(body["code"]),
273
+ int(body.get("status") or response.status_code),
274
+ body.get("debugMessage"),
275
+ request_id,
276
+ body.get("data"),
277
+ )
278
+ code = "rate-limited" if response.status_code == 429 else f"http-{response.status_code}"
279
+ return PastDevError(code, response.status_code, response.text or None, request_id)
280
+
281
+
282
+ def _retry_after(response: httpx.Response, attempt: int) -> float:
283
+ header = response.headers.get("Retry-After")
284
+ if header:
285
+ try:
286
+ return min(max(float(header), 0.0), 60.0)
287
+ except ValueError:
288
+ pass
289
+ return float(min(2 ** (attempt - 1), 8))
@@ -0,0 +1,366 @@
1
+ """past dev as CrewAI memory.
2
+
3
+ CrewAI 1.x has one ``Memory`` class. Its storage backends are vector stores
4
+ that receive embeddings, while past dev takes text and does its own
5
+ extraction, embedding and ranking. So this integration subclasses ``Memory``
6
+ and replaces the operations a crew calls: a save is a past dev Ingest and a
7
+ search is a past dev Recall. A crew accepts it wherever it accepts a
8
+ ``Memory``::
9
+
10
+ crew = Crew(agents=..., tasks=..., memory=PastDevMemory(identity="user-42"))
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from datetime import datetime
17
+ from typing import Annotated, Any, Literal
18
+
19
+ from crewai.events.event_bus import crewai_event_bus
20
+ from crewai.events.types.memory_events import (
21
+ MemoryQueryCompletedEvent,
22
+ MemoryQueryFailedEvent,
23
+ MemoryQueryStartedEvent,
24
+ )
25
+ from crewai.memory.types import MemoryMatch, MemoryRecord
26
+ from crewai.memory.unified_memory import Memory
27
+ from crewai.memory.utils import join_scope_paths
28
+ from pydantic import Field, PlainValidator, PrivateAttr, SecretStr, field_validator, model_validator
29
+
30
+ from crewai_pastdotdev._common import (
31
+ check_identity,
32
+ clamp_limit,
33
+ make_client,
34
+ parse_datetime,
35
+ source_id_for,
36
+ )
37
+ from crewai_pastdotdev.client import PastDevClient, PastDevError, RecallLevel
38
+
39
+ _SOURCE_TYPE = "past_dev"
40
+
41
+
42
+ def _passthrough(value: Any) -> Any:
43
+ return value
44
+
45
+
46
+ class _NoLocalStore:
47
+ """Stands in for CrewAI's local vector store, which past dev replaces.
48
+
49
+ past dev keeps memory on its servers, so the local browse and edit
50
+ operations (``tree``, ``info``, ``list_records``, ``update`` ...) have
51
+ nothing to read. They raise with a pointer to what to use instead.
52
+ """
53
+
54
+ _MESSAGE = (
55
+ "past dev keeps memory on its servers, not in a local store. "
56
+ "Use recall() to read, remember() to ingest, forget(record_ids=[...]) "
57
+ "to delete data points, and the past dev console to browse."
58
+ )
59
+
60
+ def _unsupported(self, *args: Any, **kwargs: Any) -> Any:
61
+ raise NotImplementedError(self._MESSAGE)
62
+
63
+ save = search = delete = update = get_record = list_records = _unsupported
64
+ get_scope_info = list_scopes = list_categories = count = reset = _unsupported
65
+ asave = asearch = adelete = _unsupported
66
+
67
+ def __deepcopy__(self, memo: dict[int, Any]) -> _NoLocalStore:
68
+ return self
69
+
70
+
71
+ class PastDevMemory(Memory):
72
+ """CrewAI memory backed by past dev.
73
+
74
+ Saves are past dev Ingests and searches are past dev Recalls, for one
75
+ fixed identity. The identity is the id of the person (or account) the
76
+ memory belongs to; your code sets it, never the model.
77
+
78
+ Args:
79
+ identity: The past dev identity every Ingest and Recall uses.
80
+ api_key: A project API key (``past_sk_...``). Defaults to the
81
+ ``PAST_API_KEY`` environment variable.
82
+ api_host: Defaults to ``PAST_API_HOST``, else ``https://api.past.dev``.
83
+ audience: An audience slug for ingested memories. Omit it for
84
+ project-visible memory.
85
+ label: An optional short title stored with each ingested data point.
86
+ recall_level: The recall evidence budget: ``low`` (default),
87
+ ``medium``, ``high`` or ``extra-high``.
88
+ recall_max_tokens: An explicit evidence budget in tokens, instead of
89
+ ``recall_level``.
90
+ client: A ready :class:`PastDevClient`, instead of ``api_key`` and
91
+ ``api_host``.
92
+
93
+ past dev extracts memories on its side, so ``extract_memories`` does not
94
+ call an LLM: a crew's task result is ingested as one data point, as
95
+ written. CrewAI scopes and categories are kept in the data point's
96
+ metadata; recall does not filter on them.
97
+ """
98
+
99
+ identity: str = Field(description="The past dev identity for every Ingest and Recall.")
100
+ api_key: SecretStr | None = Field(default=None, exclude=True, repr=False)
101
+ api_host: str | None = Field(default=None)
102
+ audience: str | None = Field(default=None)
103
+ label: str | None = Field(default=None)
104
+ recall_level: RecallLevel | None = Field(default=None)
105
+ recall_max_tokens: int | None = Field(default=None, ge=1)
106
+ client: Annotated[PastDevClient | None, PlainValidator(_passthrough)] = Field(
107
+ default=None, exclude=True, repr=False
108
+ )
109
+ storage: Annotated[Any, PlainValidator(_passthrough)] = Field(
110
+ default_factory=_NoLocalStore, exclude=True, repr=False
111
+ )
112
+
113
+ _client: PastDevClient = PrivateAttr()
114
+ _owns_client: bool = PrivateAttr(default=False)
115
+
116
+ @field_validator("identity")
117
+ @classmethod
118
+ def _valid_identity(cls, value: str) -> str:
119
+ return check_identity(value)
120
+
121
+ @model_validator(mode="after")
122
+ def _one_budget(self) -> PastDevMemory:
123
+ if self.recall_level is not None and self.recall_max_tokens is not None:
124
+ raise ValueError("Set recall_level or recall_max_tokens, never both.")
125
+ return self
126
+
127
+ def model_post_init(self, context: Any, /) -> None:
128
+ super().model_post_init(context)
129
+ self._owns_client = self.client is None
130
+ self._client = make_client(self.client, self.api_key, self.api_host)
131
+
132
+ def __deepcopy__(self, memo: dict[int, Any] | None = None) -> PastDevMemory:
133
+ # Memory.__deepcopy__ fails on Python 3.12 and earlier. A copy gets its
134
+ # own save thread and shares the HTTP client, which is thread-safe.
135
+ values = {name: getattr(self, name) for name in type(self).model_fields}
136
+ values["client"] = self._client
137
+ clone = type(self)(**values)
138
+ clone._owns_client = False
139
+ return clone
140
+
141
+ # -- save: Ingest -------------------------------------------------------
142
+
143
+ def extract_memories(self, content: str) -> list[str]:
144
+ """Return the content as one item; past dev extracts memories itself."""
145
+ text = content.strip() if content else ""
146
+ return [text] if text else []
147
+
148
+ def remember_many(
149
+ self,
150
+ contents: list[str],
151
+ scope: str | None = None,
152
+ categories: list[str] | None = None,
153
+ metadata: dict[str, Any] | None = None,
154
+ importance: float | None = None,
155
+ source: str | None = None,
156
+ private: bool = False,
157
+ agent_role: str | None = None,
158
+ root_scope: str | None = None,
159
+ ) -> list[MemoryRecord]:
160
+ # A crew passes the role of the agent that produced the result; keep it.
161
+ if agent_role:
162
+ metadata = {"crewai_agent_role": agent_role, **(metadata or {})}
163
+ return super().remember_many(
164
+ contents,
165
+ scope=scope,
166
+ categories=categories,
167
+ metadata=metadata,
168
+ importance=importance,
169
+ source=source,
170
+ private=private,
171
+ agent_role=agent_role,
172
+ root_scope=root_scope,
173
+ )
174
+
175
+ def _encode_batch(
176
+ self,
177
+ contents: list[str],
178
+ scope: str | None = None,
179
+ categories: list[str] | None = None,
180
+ metadata: dict[str, Any] | None = None,
181
+ importance: float | None = None,
182
+ source: str | None = None,
183
+ private: bool = False,
184
+ root_scope: str | None = None,
185
+ ) -> list[MemoryRecord]:
186
+ # Called by Memory.remember() and remember_many() on the save thread,
187
+ # so events, the write barrier and drain_writes() work unchanged.
188
+ scope_path: str | None = scope or root_scope
189
+ if root_scope and scope:
190
+ scope_path = join_scope_paths(root_scope, scope)
191
+ sent_metadata = dict(metadata or {})
192
+ if scope_path:
193
+ sent_metadata.setdefault("crewai_scope", scope_path)
194
+ if categories:
195
+ sent_metadata.setdefault("crewai_categories", list(categories))
196
+
197
+ records: list[MemoryRecord] = []
198
+ for content in contents:
199
+ if not content or not content.strip():
200
+ continue
201
+ receipt = self._client.ingest(
202
+ content,
203
+ identity=self.identity,
204
+ id=source_id_for(self.identity, content),
205
+ audience=self.audience,
206
+ label=self.label,
207
+ metadata=sent_metadata or None,
208
+ )
209
+ records.append(
210
+ MemoryRecord(
211
+ id=str(receipt.get("sourceId")),
212
+ content=content,
213
+ scope=scope_path or "/",
214
+ categories=list(categories or []),
215
+ metadata={
216
+ **sent_metadata,
217
+ "ingestion_id": receipt.get("ingestionId"),
218
+ "status": receipt.get("status"),
219
+ },
220
+ importance=importance if importance is not None else self.default_importance,
221
+ source=source,
222
+ private=private,
223
+ )
224
+ )
225
+ return records
226
+
227
+ # -- search: Recall -----------------------------------------------------
228
+
229
+ def recall(
230
+ self,
231
+ query: str,
232
+ scope: str | None = None,
233
+ categories: list[str] | None = None,
234
+ limit: int = 10,
235
+ depth: Literal["shallow", "deep"] = "deep",
236
+ source: str | None = None,
237
+ include_private: bool = False,
238
+ ) -> list[MemoryMatch]:
239
+ """Recall memories for ``query`` through past dev.
240
+
241
+ ``limit`` is the number of documents (1 to 200). ``scope``,
242
+ ``categories``, ``depth``, ``source`` and ``include_private`` are
243
+ CrewAI options for its local store; past dev ranks on its own and
244
+ applies the identity's audiences instead.
245
+ """
246
+ self.drain_writes()
247
+ if not query or not query.strip():
248
+ return []
249
+ crewai_event_bus.emit(
250
+ self,
251
+ MemoryQueryStartedEvent(
252
+ query=query, limit=limit, score_threshold=None, source_type=_SOURCE_TYPE
253
+ ),
254
+ )
255
+ start = time.perf_counter()
256
+ try:
257
+ response = self._client.recall(
258
+ query,
259
+ identity=self.identity,
260
+ limit=clamp_limit(limit),
261
+ level=self.recall_level,
262
+ max_tokens=self.recall_max_tokens,
263
+ )
264
+ matches = [_to_match(result) for result in response.get("results") or []]
265
+ except Exception as exc:
266
+ crewai_event_bus.emit(
267
+ self,
268
+ MemoryQueryFailedEvent(
269
+ query=query,
270
+ limit=limit,
271
+ score_threshold=None,
272
+ error=str(exc),
273
+ source_type=_SOURCE_TYPE,
274
+ ),
275
+ )
276
+ raise
277
+ crewai_event_bus.emit(
278
+ self,
279
+ MemoryQueryCompletedEvent(
280
+ query=query,
281
+ results=matches,
282
+ limit=limit,
283
+ score_threshold=None,
284
+ query_time_ms=(time.perf_counter() - start) * 1000,
285
+ source_type=_SOURCE_TYPE,
286
+ ),
287
+ )
288
+ return matches
289
+
290
+ # -- delete: Delete data point -----------------------------------------
291
+
292
+ def forget(
293
+ self,
294
+ scope: str | None = None,
295
+ categories: list[str] | None = None,
296
+ older_than: datetime | None = None,
297
+ metadata_filter: dict[str, Any] | None = None,
298
+ record_ids: list[str] | None = None,
299
+ ) -> int:
300
+ """Delete data points by source id; returns how many were found.
301
+
302
+ The ids are the ``MemoryRecord.id`` values that ``remember()``
303
+ returns. past dev deletes by source id only, so the other filters
304
+ are refused.
305
+ """
306
+ if scope is not None or categories or older_than is not None or metadata_filter:
307
+ raise NotImplementedError(
308
+ "past dev deletes data points by source id only: pass record_ids=[...]."
309
+ )
310
+ self.drain_writes()
311
+ deleted = 0
312
+ for record_id in record_ids or []:
313
+ try:
314
+ self._client.delete_data_point(record_id)
315
+ except PastDevError as exc:
316
+ if exc.status == 404:
317
+ continue
318
+ raise
319
+ deleted += 1
320
+ return deleted
321
+
322
+ def update(self, *args: Any, **kwargs: Any) -> MemoryRecord:
323
+ raise NotImplementedError(
324
+ "To change a past dev memory, remember() the new content; "
325
+ "past dev keeps both and marks the earlier statement as superseded."
326
+ )
327
+
328
+ def reset(self, scope: str | None = None) -> None:
329
+ raise NotImplementedError(
330
+ "past dev does not erase a project from CrewAI. Delete data points "
331
+ "with forget(record_ids=[...]), or use the past dev console."
332
+ )
333
+
334
+ def reset_all(self) -> None:
335
+ self.reset()
336
+
337
+ def close(self) -> None:
338
+ super().close()
339
+ if self._owns_client:
340
+ self._client.close()
341
+
342
+
343
+ def _to_match(result: dict[str, Any]) -> MemoryMatch:
344
+ artifact = result.get("artifact") or {}
345
+ sources = result.get("sources") or []
346
+ occurred_at = parse_datetime(result.get("occurredAt"))
347
+ metadata: dict[str, Any] = {
348
+ "occurred_at": result.get("occurredAt"),
349
+ "kind": artifact.get("kind"),
350
+ "superseded_at": artifact.get("supersededAt"),
351
+ "source_ids": [s.get("sourceId") for s in sources] or None,
352
+ "excerpts": [e for s in sources for e in (s.get("excerpts") or [])] or None,
353
+ }
354
+ record_fields: dict[str, Any] = {
355
+ "id": str(result.get("id")),
356
+ "content": str(result.get("content", "")),
357
+ "metadata": {k: v for k, v in metadata.items() if v is not None},
358
+ }
359
+ if occurred_at is not None:
360
+ record_fields["created_at"] = occurred_at
361
+ rank = int(result.get("rank") or 1)
362
+ return MemoryMatch(
363
+ record=MemoryRecord(**record_fields),
364
+ score=1.0 / rank,
365
+ match_reasons=[f"past dev rank {rank}"],
366
+ )
File without changes
@@ -0,0 +1,168 @@
1
+ """past dev Recall and Ingest as CrewAI tools.
2
+
3
+ The model gives only the query or the content. The identity, the key and
4
+ every other setting are fixed when you build the tool, so an agent can never
5
+ read or write the memory of another person.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Annotated, Any
12
+
13
+ from crewai.tools import BaseTool, EnvVar
14
+ from pydantic import BaseModel, Field, PlainValidator, PrivateAttr, SecretStr, field_validator
15
+
16
+ from crewai_pastdotdev._common import (
17
+ check_identity,
18
+ clamp_limit,
19
+ format_recall,
20
+ make_client,
21
+ source_id_for,
22
+ )
23
+ from crewai_pastdotdev.client import PastDevClient, PastDevError, RecallLevel
24
+
25
+
26
+ def _passthrough(value: Any) -> Any:
27
+ return value
28
+
29
+
30
+ def _describe(error: PastDevError, operation: str) -> str:
31
+ text = f"past dev {operation} failed: {error}."
32
+ if error.status in (400, 401, 402, 403, 404, 409, 413) or error.code.endswith("-cap-reached"):
33
+ text += " Do not retry this call."
34
+ return text
35
+
36
+
37
+ class PastDevRecallInput(BaseModel):
38
+ query: str = Field(
39
+ ...,
40
+ description="What to look for in memory, as a natural-language question or topic.",
41
+ )
42
+
43
+
44
+ class PastDevIngestInput(BaseModel):
45
+ content: str = Field(
46
+ ...,
47
+ description=(
48
+ "The facts, decisions or observations to keep, written as complete, "
49
+ "self-contained text."
50
+ ),
51
+ )
52
+
53
+
54
+ def _env_vars() -> list[EnvVar]:
55
+ return [
56
+ EnvVar(name="PAST_API_KEY", description="A past dev project API key (past_sk_...)."),
57
+ EnvVar(
58
+ name="PAST_API_HOST",
59
+ description="The past dev API host, for a deployment in your own cloud.",
60
+ required=False,
61
+ default="https://api.past.dev",
62
+ ),
63
+ ]
64
+
65
+
66
+ class _PastDevTool(BaseTool):
67
+ identity: str = Field(description="The past dev identity, fixed by the builder.")
68
+ env_vars: list[EnvVar] = Field(default_factory=_env_vars)
69
+ api_key: SecretStr | None = Field(default=None, exclude=True, repr=False)
70
+ api_host: str | None = Field(default=None)
71
+ client: Annotated[PastDevClient | None, PlainValidator(_passthrough)] = Field(
72
+ default=None, exclude=True, repr=False
73
+ )
74
+
75
+ _client: PastDevClient = PrivateAttr()
76
+
77
+ @field_validator("identity")
78
+ @classmethod
79
+ def _valid_identity(cls, value: str) -> str:
80
+ return check_identity(value)
81
+
82
+ def model_post_init(self, context: Any, /) -> None:
83
+ super().model_post_init(context)
84
+ self._client = make_client(self.client, self.api_key, self.api_host)
85
+
86
+
87
+ class PastDevRecallTool(_PastDevTool):
88
+ """Recall what past dev remembers about the fixed identity.
89
+
90
+ Args:
91
+ identity: The past dev identity to recall for. Set by your code.
92
+ api_key: A project API key; defaults to ``PAST_API_KEY``.
93
+ api_host: Defaults to ``PAST_API_HOST``, else ``https://api.past.dev``.
94
+ limit: The maximum number of memories returned (1 to 200).
95
+ level: The evidence budget: ``low``, ``medium``, ``high`` or ``extra-high``.
96
+ """
97
+
98
+ name: str = "past dev Recall"
99
+ description: str = (
100
+ "Recall memories from past dev: earlier facts, decisions, preferences and "
101
+ "events about the current user. Returns the most relevant memories with "
102
+ "their dates and quotations from their sources."
103
+ )
104
+ args_schema: type[BaseModel] = PastDevRecallInput
105
+ limit: int = Field(default=10, ge=1, le=200)
106
+ level: RecallLevel | None = Field(default=None)
107
+
108
+ def _run(self, query: str, **kwargs: Any) -> str:
109
+ if not query or not query.strip():
110
+ return "past dev Recall needs a query."
111
+ try:
112
+ response = self._client.recall(
113
+ query, identity=self.identity, limit=clamp_limit(self.limit), level=self.level
114
+ )
115
+ except PastDevError as error:
116
+ return _describe(error, "Recall")
117
+ return format_recall(response)
118
+
119
+ async def _arun(self, query: str, **kwargs: Any) -> str:
120
+ return await asyncio.to_thread(self._run, query)
121
+
122
+
123
+ class PastDevIngestTool(_PastDevTool):
124
+ """Ingest new information into past dev for the fixed identity.
125
+
126
+ Args:
127
+ identity: The past dev identity the content belongs to. Set by your code.
128
+ api_key: A project API key; defaults to ``PAST_API_KEY``.
129
+ api_host: Defaults to ``PAST_API_HOST``, else ``https://api.past.dev``.
130
+ audience: An audience slug; omit it for project-visible memory.
131
+ label: An optional short title stored with each data point.
132
+ metadata: Optional JSON stored with each data point.
133
+ """
134
+
135
+ name: str = "past dev Ingest"
136
+ description: str = (
137
+ "Ingest information into past dev so it can be recalled later: facts, "
138
+ "decisions, preferences or outcomes worth keeping about the current user. "
139
+ "Send complete, self-contained text."
140
+ )
141
+ args_schema: type[BaseModel] = PastDevIngestInput
142
+ audience: str | None = Field(default=None)
143
+ label: str | None = Field(default=None)
144
+ metadata: dict[str, Any] | None = Field(default=None)
145
+
146
+ def _run(self, content: str, **kwargs: Any) -> str:
147
+ if not content or not content.strip():
148
+ return "past dev Ingest needs content."
149
+ try:
150
+ receipt = self._client.ingest(
151
+ content,
152
+ identity=self.identity,
153
+ id=source_id_for(self.identity, content),
154
+ audience=self.audience,
155
+ label=self.label,
156
+ metadata=self.metadata,
157
+ )
158
+ except PastDevError as error:
159
+ return _describe(error, "Ingest")
160
+ if receipt.get("unchanged"):
161
+ return f"past dev already has this content (source id {receipt.get('sourceId')})."
162
+ return (
163
+ f"Ingested into past dev (source id {receipt.get('sourceId')}, "
164
+ f"status {receipt.get('status')}). It can be recalled once processing completes."
165
+ )
166
+
167
+ async def _arun(self, content: str, **kwargs: Any) -> str:
168
+ return await asyncio.to_thread(self._run, content)
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.5
2
+ Name: crewai-pastdotdev
3
+ Version: 0.1.0
4
+ Summary: past dev for CrewAI: Ingest and Recall as crew memory and as agent tools.
5
+ Project-URL: Homepage, https://past.dev
6
+ Project-URL: Documentation, https://past.dev/docs/memory-api/quickstart
7
+ Project-URL: API reference, https://past.dev/docs/memory-api/api-reference
8
+ Project-URL: Source, https://github.com/pastdotdev/crewai-pastdotdev
9
+ Author-email: "past.dev" <louis@past.dev>
10
+ Maintainer-email: "past.dev" <louis@past.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agents,crewai,llm,memory,past dev,recall
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: <3.14,>=3.10
25
+ Requires-Dist: crewai<2,>=1.14.6
26
+ Requires-Dist: httpx<1,>=0.27
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=8; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # crewai-pastdotdev
32
+
33
+ past dev is a memory API for AI agents. This package connects it to
34
+ [CrewAI](https://www.crewai.com): your crews keep what they learn in past dev
35
+ and recall it in later runs, with the date and the source of every memory.
36
+
37
+ It gives you two ways to use past dev:
38
+
39
+ - **`PastDevMemory`**: crew memory. Pass it as `memory=` to a `Crew` or an
40
+ `Agent`. CrewAI recalls before each task and saves each task result. A save
41
+ is a past dev **Ingest** and a search is a past dev **Recall**.
42
+ - **`PastDevRecallTool`** and **`PastDevIngestTool`**: tools that an agent
43
+ calls when it decides to. The model gives only the query or the content.
44
+
45
+ [Website](https://past.dev) ·
46
+ [Quickstart](https://past.dev/docs/memory-api/quickstart) ·
47
+ [API reference](https://past.dev/docs/memory-api/api-reference)
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install crewai-pastdotdev
53
+ ```
54
+
55
+ The package needs Python 3.10 to 3.13 and CrewAI 1.14.6 or later.
56
+
57
+ ## Set the API key
58
+
59
+ Make a project API key in the past dev console. It starts with `past_sk_`.
60
+
61
+ ```bash
62
+ export PAST_API_KEY="past_sk_..."
63
+ ```
64
+
65
+ If you run past dev in your own cloud, also set the API host. The default is
66
+ `https://api.past.dev`.
67
+
68
+ ```bash
69
+ export PAST_API_HOST="https://past.example.com"
70
+ ```
71
+
72
+ You can also give `api_key=` and `api_host=` to each class.
73
+
74
+ ## The identity
75
+
76
+ Every memory in past dev belongs to an **identity**: the id of the person or
77
+ account the memory is about, for example the user id in your application.
78
+ Recall requires an identity. Ingest makes the identity the first time it sees
79
+ it.
80
+
81
+ **Your code sets the identity, never the model.** Take it from the user
82
+ record or the trigger of the run, and give it to the constructor. The tools
83
+ have no identity argument, so an agent cannot read or write the memory of
84
+ another person. Make one memory or one tool for each user.
85
+
86
+ ## Use past dev as crew memory
87
+
88
+ ```python
89
+ from crewai import Agent, Crew, Task
90
+ from crewai_pastdotdev import PastDevMemory
91
+
92
+ memory = PastDevMemory(identity=current_user.id)
93
+
94
+ writer = Agent(
95
+ role="Account manager",
96
+ goal="Write the weekly report the way the customer wants it",
97
+ backstory="You know the customer well.",
98
+ )
99
+ report = Task(
100
+ description="Write this week's report for the customer.",
101
+ expected_output="A report in the format the customer prefers.",
102
+ agent=writer,
103
+ )
104
+
105
+ crew = Crew(agents=[writer], tasks=[report], memory=memory)
106
+ crew.kickoff()
107
+ ```
108
+
109
+ What happens in a run:
110
+
111
+ 1. Before each task, CrewAI recalls with the task description. The recalled
112
+ memories go into the agent's prompt.
113
+ 2. After each task, CrewAI saves the task and its result. `PastDevMemory`
114
+ ingests them as one data point. past dev extracts the memories on its own
115
+ side, so no extra LLM call runs in your crew.
116
+ 3. CrewAI also gives the agents its own "Search memory" and "Save to memory"
117
+ tools. They go through past dev too.
118
+
119
+ You can also call the memory directly:
120
+
121
+ ```python
122
+ record = memory.remember("The customer wants the report on Mondays.")
123
+ for match in memory.recall("When does the customer want the report?"):
124
+ print(match.record.content, match.record.metadata["occurred_at"])
125
+ memory.forget(record_ids=[record.id]) # Delete data point
126
+ ```
127
+
128
+ ### Settings
129
+
130
+ | Argument | Default | What it does |
131
+ | :- | :- | :- |
132
+ | `identity` | required | The identity for every Ingest and Recall. |
133
+ | `api_key` | `PAST_API_KEY` | The project API key. |
134
+ | `api_host` | `PAST_API_HOST`, else `https://api.past.dev` | The API host. |
135
+ | `audience` | none | An audience slug for ingested memories. Without it, a memory is visible to the whole project. |
136
+ | `label` | none | A short title stored with each data point. |
137
+ | `recall_level` | `low` | The evidence budget of a recall: `low`, `medium`, `high` or `extra-high`. |
138
+ | `recall_max_tokens` | none | An exact evidence budget in tokens, instead of `recall_level`. |
139
+ | `read_only` | `False` | Recall only. Nothing is ingested. |
140
+
141
+ ### How CrewAI concepts map to past dev
142
+
143
+ | CrewAI | past dev |
144
+ | :- | :- |
145
+ | `remember()`, `remember_many()` | Ingest (`POST /api/v1/ingest`). The source id is stable for the same identity and content, so a repeated save is free. |
146
+ | `recall()` | Recall (`POST /api/v1/recall`). `limit` is the number of memories, at most 200. |
147
+ | `forget(record_ids=[...])` | Delete data point (`DELETE /api/v1/data-points/{sourceId}`). |
148
+ | `MemoryMatch.score` | `1 / rank`. past dev ranks the results; it does not return a score. |
149
+ | Scopes, categories, agent role | Kept in the data point's metadata (`crewai_scope`, `crewai_categories`, `crewai_agent_role`). Recall does not filter on them. |
150
+ | `tree()`, `info()`, `list_records()`, `update()`, `reset()` | Not available: past dev keeps memory on its servers. Use the past dev console to browse. To change a fact, remember the new one: past dev marks the earlier statement as superseded. |
151
+
152
+ ## Use past dev as agent tools
153
+
154
+ ```python
155
+ from crewai import Agent
156
+ from crewai_pastdotdev import PastDevIngestTool, PastDevRecallTool
157
+
158
+ agent = Agent(
159
+ role="Support agent",
160
+ goal="Help the customer with what you know about them",
161
+ backstory="You remember every earlier conversation.",
162
+ tools=[
163
+ PastDevRecallTool(identity=current_user.id),
164
+ PastDevIngestTool(identity=current_user.id),
165
+ ],
166
+ )
167
+ ```
168
+
169
+ | Tool | The model gives | Other settings (in the constructor) |
170
+ | :- | :- | :- |
171
+ | `PastDevRecallTool` ("past dev Recall") | `query` | `identity`, `limit` (10), `level`, `api_key`, `api_host` |
172
+ | `PastDevIngestTool` ("past dev Ingest") | `content` | `identity`, `audience`, `label`, `metadata`, `api_key`, `api_host` |
173
+
174
+ The recall tool returns the memories as text: rank, date, content, and the
175
+ quotations from their sources. If a memory was superseded by a later
176
+ statement, the text says so.
177
+
178
+ ## Credits and errors
179
+
180
+ Every Ingest and every Recall spends credits on your past dev plan. The
181
+ connection check (`PastDevClient().check_connection()`, which reads the
182
+ project's audiences) and Delete data point are free.
183
+
184
+ An error from the API raises `PastDevError` with the API's `code` (for
185
+ example `out-of-credits`), the HTTP `status`, the message and the
186
+ `request_id`. Give the request id to past dev support when you ask about a
187
+ call. The tools return the error to the agent as text, and tell it not to
188
+ retry when a retry cannot help.
189
+
190
+ The client sends a request again only when the API answers 429 for the rate
191
+ limit, after the wait the API asks for (two times at most). It never retries
192
+ `402 out-of-credits`, a monthly cap, or a `400`.
193
+
194
+ ## The HTTP client
195
+
196
+ `PastDevClient` is a small client for the calls this package uses. You can
197
+ use it on its own:
198
+
199
+ ```python
200
+ from crewai_pastdotdev import PastDevClient
201
+
202
+ with PastDevClient() as past:
203
+ past.check_connection()
204
+ past.ingest("The customer moved to Lyon.", identity="user-42", id="crm-note-17")
205
+ page = past.recall("Where does the customer live?", identity="user-42", limit=5)
206
+ past.delete_data_point("crm-note-17")
207
+ audiences = past.list_audiences()
208
+ identities = list(past.list_identities())
209
+ ```
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ python -m venv .venv && . .venv/bin/activate
215
+ pip install -e ".[test]"
216
+ pytest
217
+ ```
218
+
219
+ The tests use `httpx.MockTransport` and a scripted LLM, so they need no key
220
+ and no network. One live test runs a real Ingest, Recall and Delete data
221
+ point when `PAST_API_KEY` is set. It spends a few credits.
222
+
223
+ ## License
224
+
225
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ crewai_pastdotdev/__init__.py,sha256=ziRGLh13Hjl-4JNUk8ARHsXPJDyhe5rKmvsEYkDFXB0,622
2
+ crewai_pastdotdev/_common.py,sha256=IAbdDdocOcfHpOw4R9u2mIABviYJGWs_LECZwc1HJJk,3033
3
+ crewai_pastdotdev/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ crewai_pastdotdev/client.py,sha256=Ojs6KAbOs1mA7HoWPD3bqy2RS-cA-nXv_uxw2qVXAEg,9919
5
+ crewai_pastdotdev/memory.py,sha256=DStXICIc2w8ewm-IGZ37SowC2jp6tIExYrGgCMWm2eA,13860
6
+ crewai_pastdotdev/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ crewai_pastdotdev/tools.py,sha256=vYZHF0_ZGqIkj_evoB4pcFJ8SX9-G7DmZINI9OEj2F4,6115
8
+ crewai_pastdotdev-0.1.0.dist-info/METADATA,sha256=-BO6KWy6QJNrIBxXhkLrhzYXCwVS40Mq_e_A2gOXCmg,8655
9
+ crewai_pastdotdev-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ crewai_pastdotdev-0.1.0.dist-info/licenses/LICENSE,sha256=IZOT7vW7GYUkxb0mb6kEAwmuFCEDN_0iZZZRvV-W0GI,1065
11
+ crewai_pastdotdev-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 past.dev
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.