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.
- crewai_pastdotdev/__init__.py +23 -0
- crewai_pastdotdev/_common.py +85 -0
- crewai_pastdotdev/_version.py +1 -0
- crewai_pastdotdev/client.py +289 -0
- crewai_pastdotdev/memory.py +366 -0
- crewai_pastdotdev/py.typed +0 -0
- crewai_pastdotdev/tools.py +168 -0
- crewai_pastdotdev-0.1.0.dist-info/METADATA +225 -0
- crewai_pastdotdev-0.1.0.dist-info/RECORD +11 -0
- crewai_pastdotdev-0.1.0.dist-info/WHEEL +4 -0
- crewai_pastdotdev-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|