alexein 0.3.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.
alexein/__init__.py ADDED
@@ -0,0 +1,111 @@
1
+ """Alexein for Python: long-term memory for your app users, behind one API key.
2
+
3
+ ```python
4
+ from alexein import Memory
5
+
6
+ dana = Memory().user("dana")
7
+ dana.add("I moved to Lisbon for the job at Acme.")
8
+ print(dana.search("Where does Dana live?").context())
9
+ ```
10
+
11
+ The framework integrations are modules of their own, imported only when you import them:
12
+ `alexein.langgraph`, `alexein.openai_agents` and `alexein.crewai`.
13
+ """
14
+
15
+ from alexein._async_user import AsyncUser
16
+ from alexein._client import AsyncMemory, Memory
17
+ from alexein._errors import (
18
+ AlexeinError,
19
+ APIConnectionError,
20
+ APITimeoutError,
21
+ AuthenticationError,
22
+ FieldError,
23
+ InvalidRequestError,
24
+ NotFoundError,
25
+ PermissionDeniedError,
26
+ RateLimitError,
27
+ ServiceUnavailableError,
28
+ )
29
+ from alexein._memories import AnyMemory, Excerpt, Fact, Known, Memories, Opened, SearchResult
30
+ from alexein._results import (
31
+ AppUser,
32
+ ExportPage,
33
+ FactsPage,
34
+ FollowUp,
35
+ History,
36
+ ItemsPage,
37
+ Me,
38
+ OpenItem,
39
+ OpenItems,
40
+ Source,
41
+ Sources,
42
+ StoredItem,
43
+ Summary,
44
+ UsersPage,
45
+ Value,
46
+ )
47
+ from alexein._types import Author, Item
48
+ from alexein._user import User
49
+ from alexein._version import __version__
50
+ from alexein._writes import (
51
+ AddedItem,
52
+ AddResult,
53
+ Correction,
54
+ Deleted,
55
+ Erased,
56
+ Forgotten,
57
+ ItemStatus,
58
+ Statuses,
59
+ Versions,
60
+ )
61
+
62
+ __all__ = [
63
+ "APIConnectionError",
64
+ "APITimeoutError",
65
+ "AddResult",
66
+ "AddedItem",
67
+ "AlexeinError",
68
+ "AnyMemory",
69
+ "AppUser",
70
+ "AsyncMemory",
71
+ "AsyncUser",
72
+ "AuthenticationError",
73
+ "Author",
74
+ "Correction",
75
+ "Deleted",
76
+ "Erased",
77
+ "Excerpt",
78
+ "ExportPage",
79
+ "Fact",
80
+ "FactsPage",
81
+ "FieldError",
82
+ "FollowUp",
83
+ "Forgotten",
84
+ "History",
85
+ "InvalidRequestError",
86
+ "Item",
87
+ "ItemStatus",
88
+ "ItemsPage",
89
+ "Known",
90
+ "Me",
91
+ "Memories",
92
+ "Memory",
93
+ "NotFoundError",
94
+ "OpenItem",
95
+ "OpenItems",
96
+ "Opened",
97
+ "PermissionDeniedError",
98
+ "RateLimitError",
99
+ "SearchResult",
100
+ "ServiceUnavailableError",
101
+ "Source",
102
+ "Sources",
103
+ "Statuses",
104
+ "StoredItem",
105
+ "Summary",
106
+ "User",
107
+ "UsersPage",
108
+ "Value",
109
+ "Versions",
110
+ "__version__",
111
+ ]
alexein/_async_user.py ADDED
@@ -0,0 +1,259 @@
1
+ """`AsyncUser`: `User` for asyncio, the same methods awaited. Kept beside `_user.py` line for
2
+ line, so the two read alike."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import AsyncIterator, Sequence
7
+ from typing import Any
8
+
9
+ from alexein import _calls
10
+ from alexein._errors import AlexeinError
11
+ from alexein._http import AsyncTransport, ride_out
12
+ from alexein._memories import Fact, Memories, Opened, SearchResult
13
+ from alexein._results import (
14
+ ExportPage,
15
+ FactsPage,
16
+ History,
17
+ ItemsPage,
18
+ OpenItems,
19
+ Sources,
20
+ StoredItem,
21
+ Summary,
22
+ )
23
+ from alexein._types import Author, Item, Json, Kinds
24
+ from alexein._writes import (
25
+ AddResult,
26
+ Correction,
27
+ Deleted,
28
+ Forgotten,
29
+ Statuses,
30
+ Versions,
31
+ merged,
32
+ )
33
+
34
+
35
+ class AsyncUser:
36
+ """`User`, for asyncio: the same methods, awaited; `iter_*` are async generators."""
37
+
38
+ def __init__(self, transport: AsyncTransport, user_id: str, *, fail_open: bool = False) -> None:
39
+ self._transport = transport
40
+ self.id = user_id
41
+ self.fail_open = fail_open
42
+
43
+ def __repr__(self) -> str:
44
+ return f"AsyncUser({self.id!r})"
45
+
46
+ async def _send(self, call: _calls.Call[Any]) -> Any:
47
+ return await self._transport.send(call, self.id)
48
+
49
+ async def add(
50
+ self,
51
+ items: str | Item | Sequence[str | Item],
52
+ *,
53
+ wait: bool = False,
54
+ redact: bool = False,
55
+ kind: str | None = None,
56
+ session: str | None = None,
57
+ role: str | None = None,
58
+ author: str | Author | None = None,
59
+ title: str | None = None,
60
+ about: Sequence[str] | None = None,
61
+ expires: str | None = None,
62
+ at: str | None = None,
63
+ metadata: Json | None = None,
64
+ ) -> AddResult:
65
+ """Remember text: a string, an item dict, or a list of either. The keyword fields fill
66
+ every item that does not set its own. With `wait`, returns once they are searchable;
67
+ with `redact`, phone numbers, cards, keys and the like are replaced before memory keeps
68
+ any copy. More than 500 items go in batches; a retry never saves an item twice."""
69
+ defaults: Json = {
70
+ "kind": kind, "session": session, "role": role, "author": author, "title": title,
71
+ "about": list(about) if about is not None else None, "expires": expires, "at": at,
72
+ "metadata": metadata,
73
+ } # fmt: skip
74
+ calls = _calls.add(items, wait=wait, redact=redact, defaults=defaults)
75
+ if not calls:
76
+ return AddResult(items=[])
77
+ try:
78
+ return merged([await self._send(c) for c in calls])
79
+ except AlexeinError as error:
80
+ ride_out(error, self.fail_open, "add", self.id)
81
+ return AddResult(items=[], waiting=0, failed=True)
82
+
83
+ async def search(
84
+ self,
85
+ query: str,
86
+ *,
87
+ before: str | None = None,
88
+ after: str | None = None,
89
+ filters: Json | None = None,
90
+ provider: str | None = None,
91
+ source: str | None = None,
92
+ kind: Kinds | None = None,
93
+ fast: bool = False,
94
+ ) -> SearchResult:
95
+ """What memory holds for a question. `fast` answers sooner, a little less precisely, and
96
+ free. `before` and `after` are ISO dates; `filters`, `provider`, `source` and `kind`
97
+ narrow it to the facts and excerpts that match."""
98
+ call = _calls.search(
99
+ query, before=before, after=after, filters=filters, provider=provider,
100
+ source=source, kind=kind, fast=fast or None,
101
+ ) # fmt: skip
102
+ try:
103
+ return await self._send(call)
104
+ except AlexeinError as error:
105
+ ride_out(error, self.fail_open, "search", self.id)
106
+ return SearchResult.unavailable(query)
107
+
108
+ async def grep(
109
+ self,
110
+ pattern: str,
111
+ *,
112
+ limit: int | None = None,
113
+ provider: str | None = None,
114
+ source: str | None = None,
115
+ kind: Kinds | None = None,
116
+ ) -> Memories:
117
+ """Excerpts holding an exact phrase, ignoring case."""
118
+ call = _calls.grep(pattern, limit=limit, provider=provider, source=source, kind=kind)
119
+ return await self._send(call)
120
+
121
+ async def latest(
122
+ self,
123
+ *,
124
+ k: int | None = None,
125
+ place: str | None = None,
126
+ after: str | None = None,
127
+ before: str | None = None,
128
+ filters: Json | None = None,
129
+ provider: str | None = None,
130
+ source: str | None = None,
131
+ kind: Kinds | None = None,
132
+ ) -> Memories:
133
+ """The most recent excerpts, newest first (10 by default): everywhere or in one `place`,
134
+ or everything between `after` and `before`."""
135
+ call = _calls.latest(
136
+ k=k, place=place, after=after, before=before, filters=filters, provider=provider,
137
+ source=source, kind=kind,
138
+ ) # fmt: skip
139
+ return await self._send(call)
140
+
141
+ async def around(self, ref: str, *, width: int | None = None) -> Memories:
142
+ """The excerpts just before and after one, from the same conversation, in order."""
143
+ return await self._send(_calls.around(ref, width=width))
144
+
145
+ async def open(self, ref: str) -> Opened:
146
+ """One memory by its ref, whole: an excerpt in full, or a fact with the excerpts it was
147
+ learned from."""
148
+ return await self._send(_calls.open(ref))
149
+
150
+ async def history(self, subject: str, *, attribute: str | None = None) -> History:
151
+ """Every value a subject was stated to have, and since when."""
152
+ return await self._send(_calls.history(subject, attribute=attribute))
153
+
154
+ async def summary(self) -> Summary:
155
+ """What this memory holds, in brief."""
156
+ return await self._send(_calls.summary())
157
+
158
+ async def facts(self, *, limit: int | None = None, cursor: str | None = None) -> FactsPage:
159
+ """A page of the facts memory learned, newest first (20 by default)."""
160
+ return await self._send(_calls.facts(limit=limit, cursor=cursor))
161
+
162
+ async def iter_facts(self, *, limit: int | None = None) -> AsyncIterator[Fact]:
163
+ """Every fact memory learned, newest first, page after page."""
164
+ cursor: str | None = None
165
+ while True:
166
+ page = await self.facts(limit=limit, cursor=cursor)
167
+ for each in page.facts:
168
+ yield each
169
+ if not page.next:
170
+ return
171
+ cursor = page.next
172
+
173
+ async def open_items(
174
+ self,
175
+ *,
176
+ kind: Kinds | None = None,
177
+ provider: str | None = None,
178
+ place: str | None = None,
179
+ mine: bool | None = None,
180
+ within_days: int | None = None,
181
+ limit: int | None = None,
182
+ ) -> OpenItems:
183
+ """Issues and pull requests still open, newest first - by default the person's own -
184
+ and follow-ups nobody answered."""
185
+ call = _calls.open_items(
186
+ kind=kind, provider=provider, place=place, mine=mine, within_days=within_days,
187
+ limit=limit,
188
+ ) # fmt: skip
189
+ return await self._send(call)
190
+
191
+ async def sources(self) -> Sources:
192
+ """What each connected app delivered to this memory."""
193
+ return await self._send(_calls.sources())
194
+
195
+ async def items(
196
+ self, *, limit: int | None = None, cursor: str | None = None, filters: Json | None = None
197
+ ) -> ItemsPage:
198
+ """A page of the items added to this memory, newest first, as they were given."""
199
+ return await self._send(_calls.items(limit=limit, cursor=cursor, filters=filters))
200
+
201
+ async def iter_items(
202
+ self, *, limit: int | None = None, filters: Json | None = None
203
+ ) -> AsyncIterator[StoredItem]:
204
+ """Every item added to this memory, newest first, page after page."""
205
+ cursor: str | None = None
206
+ while True:
207
+ page = await self.items(limit=limit, cursor=cursor, filters=filters)
208
+ for each in page.items:
209
+ yield each
210
+ if not page.next:
211
+ return
212
+ cursor = page.next
213
+
214
+ async def item(self, item_id: str) -> StoredItem | None:
215
+ """One added item by its id, whole; None for an id memory does not hold."""
216
+ return await self._send(_calls.item(item_id))
217
+
218
+ async def status(self, ids: Sequence[str]) -> Statuses:
219
+ """How far each item has got: queued, stored, processed or unknown."""
220
+ return await self._send(_calls.status(ids))
221
+
222
+ async def forget(
223
+ self,
224
+ *,
225
+ items: Sequence[str] | None = None,
226
+ sessions: Sequence[str] | None = None,
227
+ refs: Sequence[str] | None = None,
228
+ ) -> Forgotten:
229
+ """Erase what is named - items by id, whole sessions, memories by ref - with every copy
230
+ and everything learned from it, for good. Needs a key that writes."""
231
+ return await self._send(_calls.forget(items=items, sessions=sessions, refs=refs))
232
+
233
+ async def correct(self, ref: str, replacement: str | None = None) -> Correction:
234
+ """Retract one fact by its ref, and optionally say what is true instead."""
235
+ return await self._send(_calls.correct(ref, replacement))
236
+
237
+ async def versions(self, item_id: str) -> Versions:
238
+ """Every version of one added item, and when and why it went. Needs a key that writes."""
239
+ return await self._send(_calls.versions(item_id))
240
+
241
+ async def export(self, *, limit: int | None = None, cursor: str | None = None) -> ExportPage:
242
+ """A page of everything memory holds for this app user, for a person who asks for theirs."""
243
+ return await self._send(_calls.export(limit=limit, cursor=cursor))
244
+
245
+ async def iter_export(self, *, limit: int | None = None) -> AsyncIterator[Json]:
246
+ """Every record of the export, page after page."""
247
+ cursor: str | None = None
248
+ while True:
249
+ page = await self.export(limit=limit, cursor=cursor)
250
+ for each in page.records:
251
+ yield each
252
+ if not page.next:
253
+ return
254
+ cursor = page.next
255
+
256
+ async def delete(self) -> Deleted:
257
+ """Erase this app user whole: every item, fact and version. Not undone; their next add
258
+ starts a new memory. Needs a key that writes."""
259
+ return await self._send(_calls.delete())
alexein/_calls.py ADDED
@@ -0,0 +1,200 @@
1
+ """Each operation as the request it is: path, body and how to read the answer. Written once here
2
+ and sent by both clients, so the sync and async methods cannot drift apart on the wire."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import uuid
8
+ from collections.abc import Callable, Iterable, Sequence
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Generic, TypeVar, cast
11
+
12
+ from alexein._memories import Memories, Opened, SearchResult
13
+ from alexein._results import (
14
+ ExportPage,
15
+ FactsPage,
16
+ History,
17
+ ItemsPage,
18
+ Me,
19
+ OpenItems,
20
+ Sources,
21
+ StoredItem,
22
+ Summary,
23
+ UsersPage,
24
+ )
25
+ from alexein._types import Item, Json
26
+ from alexein._writes import (
27
+ AddResult,
28
+ Correction,
29
+ Deleted,
30
+ Forgotten,
31
+ Statuses,
32
+ Versions,
33
+ )
34
+
35
+ T = TypeVar("T")
36
+
37
+ # The API takes at most this many items in one add; a longer list is sent in batches.
38
+ BATCH = 500
39
+ # What the API accepts as an app user id, checked here so a typo fails where it was made.
40
+ _USER_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,34}")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Call(Generic[T]):
45
+ """One request: `user` is whether it names an app user in `X-Alexein-User`."""
46
+
47
+ method: str
48
+ path: str
49
+ parse: Callable[[Json], T]
50
+ body: Json | None = None
51
+ headers: dict[str, str] = field(default_factory=dict[str, str])
52
+ user: bool = True
53
+
54
+
55
+ def checked_user(user_id: str) -> str:
56
+ if not isinstance(user_id, str) or not _USER_ID.fullmatch(user_id): # pyright: ignore[reportUnnecessaryIsInstance]
57
+ raise ValueError(
58
+ f"not a usable app user id: {user_id!r} (1 to 35 letters, digits, '.', '_' and "
59
+ "'-', starting with a letter or digit)"
60
+ )
61
+ return user_id
62
+
63
+
64
+ def _set(**fields: Any) -> Json:
65
+ """The fields the caller set: None is not sent, so the API's defaults apply."""
66
+ return {k: _sent(v) for k, v in fields.items() if v is not None}
67
+
68
+
69
+ def _sent(value: Any) -> Any:
70
+ """A sequence as the JSON list it is sent as; anything else as it is."""
71
+ if isinstance(value, Sequence) and not isinstance(value, str):
72
+ return list(cast("Sequence[Any]", value))
73
+ return value
74
+
75
+
76
+ def _post(route: str, parse: Callable[[Json], T], body: Json | None = None) -> Call[T]:
77
+ return Call("POST", f"/v2/memory/{route}", parse, body or {})
78
+
79
+
80
+ def me() -> Call[Me]:
81
+ return Call("GET", "/v2/me", Me.parse, user=False)
82
+
83
+
84
+ def users(*, limit: int | None, cursor: str | None) -> Call[UsersPage]:
85
+ call = _post("users", UsersPage.parse, _set(limit=limit, cursor=cursor))
86
+ return Call(call.method, call.path, call.parse, call.body, user=False)
87
+
88
+
89
+ def add(
90
+ items: str | Item | Sequence[str | Item],
91
+ *,
92
+ wait: bool,
93
+ redact: bool,
94
+ defaults: Json,
95
+ ) -> list[Call[AddResult]]:
96
+ """One call per batch of `BATCH` items, each with an `Idempotency-Key` of one uuid4 for the
97
+ whole add and the batch's index, so a retried batch is the same items again."""
98
+ given: Iterable[str | Item] = [items] if isinstance(items, (str, dict)) else items
99
+ shaped = [_item(i, _set(**defaults)) for i in given]
100
+ key = str(uuid.uuid4())
101
+ options = _set(wait=wait or None, redact=redact or None)
102
+ return [
103
+ Call(
104
+ "POST",
105
+ "/v2/memory/add",
106
+ AddResult.parse,
107
+ {"items": shaped[at : at + BATCH], **options},
108
+ {"Idempotency-Key": f"{key}-{at // BATCH}"},
109
+ )
110
+ for at in range(0, len(shaped), BATCH)
111
+ ]
112
+
113
+
114
+ def _item(given: str | Item, defaults: Json) -> Json:
115
+ item: Json = {"text": given} if isinstance(given, str) else dict(given)
116
+ if not isinstance(item.get("text"), str) or not item["text"]:
117
+ raise ValueError(f"an item needs text: {given!r}")
118
+ return defaults | item
119
+
120
+
121
+ def search(query: str, **narrowed: Any) -> Call[SearchResult]:
122
+ return _post("search", SearchResult.parse, _set(query=query, **narrowed))
123
+
124
+
125
+ def grep(pattern: str, **narrowed: Any) -> Call[Memories]:
126
+ return _post("grep", Memories.parse, _set(pattern=pattern, **narrowed))
127
+
128
+
129
+ def latest(**narrowed: Any) -> Call[Memories]:
130
+ return _post("latest", Memories.parse, _set(**narrowed))
131
+
132
+
133
+ def around(ref: str, *, width: int | None) -> Call[Memories]:
134
+ return _post("around", Memories.parse, _set(ref=ref, width=width))
135
+
136
+
137
+ def open(ref: str) -> Call[Opened]:
138
+ return _post("open", Opened.parse, {"ref": ref})
139
+
140
+
141
+ def history(subject: str, *, attribute: str | None) -> Call[History]:
142
+ return _post("history", History.parse, _set(subject=subject, attribute=attribute))
143
+
144
+
145
+ def summary() -> Call[Summary]:
146
+ return _post("summary", Summary.parse)
147
+
148
+
149
+ def facts(*, limit: int | None, cursor: str | None) -> Call[FactsPage]:
150
+ return _post("facts", FactsPage.parse, _set(limit=limit, cursor=cursor))
151
+
152
+
153
+ def open_items(**narrowed: Any) -> Call[OpenItems]:
154
+ return _post("open_items", OpenItems.parse, _set(**narrowed))
155
+
156
+
157
+ def sources() -> Call[Sources]:
158
+ return _post("sources", Sources.parse)
159
+
160
+
161
+ def items(*, limit: int | None, cursor: str | None, filters: Json | None) -> Call[ItemsPage]:
162
+ return _post("items", ItemsPage.parse, _set(limit=limit, cursor=cursor, filters=filters))
163
+
164
+
165
+ def _found(raw: Json) -> StoredItem | None:
166
+ return StoredItem.parse(raw["item"]) if raw.get("found") and raw.get("item") else None
167
+
168
+
169
+ def item(item_id: str) -> Call[StoredItem | None]:
170
+ return _post("item", _found, {"id": item_id})
171
+
172
+
173
+ def status(ids: Sequence[str]) -> Call[Statuses]:
174
+ return _post("status", Statuses.parse, {"items": list(ids)})
175
+
176
+
177
+ def forget(
178
+ *, items: Sequence[str] | None, sessions: Sequence[str] | None, refs: Sequence[str] | None
179
+ ) -> Call[Forgotten]:
180
+ named = {"items": items, "sessions": sessions, "refs": refs}
181
+ body: Json = {k: list(v) for k, v in named.items() if v}
182
+ if not body:
183
+ raise ValueError("forget needs at least one of items, sessions or refs")
184
+ return _post("forget", Forgotten.parse, body)
185
+
186
+
187
+ def correct(ref: str, replacement: str | None) -> Call[Correction]:
188
+ return _post("correct", Correction.parse, _set(ref=ref, replacement=replacement))
189
+
190
+
191
+ def versions(item_id: str) -> Call[Versions]:
192
+ return _post("versions", Versions.parse, {"id": item_id})
193
+
194
+
195
+ def export(*, limit: int | None, cursor: str | None) -> Call[ExportPage]:
196
+ return _post("export", ExportPage.parse, _set(limit=limit, cursor=cursor))
197
+
198
+
199
+ def delete() -> Call[Deleted]:
200
+ return _post("users/delete", Deleted.parse)