redis-iris 0.1.0a1__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.
Files changed (61) hide show
  1. redis_iris/__init__.py +21 -0
  2. redis_iris/__main__.py +6 -0
  3. redis_iris/_settings.py +30 -0
  4. redis_iris/agent_memory/__init__.py +43 -0
  5. redis_iris/agent_memory/_wire.py +47 -0
  6. redis_iris/agent_memory/conversation.py +81 -0
  7. redis_iris/agent_memory/hooks.py +55 -0
  8. redis_iris/agent_memory/ids.py +27 -0
  9. redis_iris/agent_memory/memory.py +309 -0
  10. redis_iris/agent_memory/settings.py +66 -0
  11. redis_iris/agent_memory/values.py +66 -0
  12. redis_iris/cli/__init__.py +1 -0
  13. redis_iris/cli/__main__.py +6 -0
  14. redis_iris/cli/core/__init__.py +1 -0
  15. redis_iris/cli/core/branding.py +22 -0
  16. redis_iris/cli/core/config.py +95 -0
  17. redis_iris/cli/core/errors.py +65 -0
  18. redis_iris/cli/core/logging.py +12 -0
  19. redis_iris/cli/core/output.py +43 -0
  20. redis_iris/cli/core/registry.py +122 -0
  21. redis_iris/cli/main.py +69 -0
  22. redis_iris/cli/services/__init__.py +1 -0
  23. redis_iris/cli/services/agent_memory/__init__.py +1 -0
  24. redis_iris/cli/services/agent_memory/client.py +189 -0
  25. redis_iris/cli/services/agent_memory/commands.py +231 -0
  26. redis_iris/cli/services/playbook/__init__.py +1 -0
  27. redis_iris/cli/services/playbook/apply.py +349 -0
  28. redis_iris/cli/services/playbook/client.py +764 -0
  29. redis_iris/cli/services/playbook/commands.py +1806 -0
  30. redis_iris/cli/services/playbook/config.py +80 -0
  31. redis_iris/cli/services/playbook/evidence.py +103 -0
  32. redis_iris/cli/services/playbook/exporting.py +260 -0
  33. redis_iris/cli/services/playbook/faq_lifecycle.py +376 -0
  34. redis_iris/cli/services/playbook/faqs.py +314 -0
  35. redis_iris/cli/services/playbook/output.py +194 -0
  36. redis_iris/cli/services/playbook/planner.py +401 -0
  37. redis_iris/cli/services/playbook/progress.py +134 -0
  38. redis_iris/cli/services/playbook/service.py +448 -0
  39. redis_iris/cli/services/playbook/skills.py +622 -0
  40. redis_iris/context_retriever/__init__.py +32 -0
  41. redis_iris/context_retriever/provisioning.py +31 -0
  42. redis_iris/context_retriever/results.py +57 -0
  43. redis_iris/context_retriever/retriever.py +189 -0
  44. redis_iris/context_retriever/settings.py +63 -0
  45. redis_iris/errors.py +23 -0
  46. redis_iris/integrations/__init__.py +5 -0
  47. redis_iris/integrations/langgraph.py +105 -0
  48. redis_iris/iris.py +74 -0
  49. redis_iris/playbook/__init__.py +33 -0
  50. redis_iris/playbook/_wire.py +68 -0
  51. redis_iris/playbook/filters.py +11 -0
  52. redis_iris/playbook/hooks.py +120 -0
  53. redis_iris/playbook/playbook.py +180 -0
  54. redis_iris/playbook/settings.py +64 -0
  55. redis_iris/playbook/values.py +65 -0
  56. redis_iris/py.typed +1 -0
  57. redis_iris-0.1.0a1.dist-info/METADATA +154 -0
  58. redis_iris-0.1.0a1.dist-info/RECORD +61 -0
  59. redis_iris-0.1.0a1.dist-info/WHEEL +4 -0
  60. redis_iris-0.1.0a1.dist-info/entry_points.txt +6 -0
  61. redis_iris-0.1.0a1.dist-info/licenses/LICENSE +21 -0
redis_iris/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Redis Iris: the goal-level toolkit for the Iris products.
2
+
3
+ ``iris-sdks`` publishes one generated client per service; this package wraps what an agent
4
+ developer is trying to do with them (``AgentMemory``, ``Playbook``, ``ContextRetriever``) with
5
+ smart defaults, one ``IRIS_*`` configuration model, and the ``iris`` command line. Start with the
6
+ product you need; ``Iris`` gathers them when the second one arrives.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ from redis_iris.agent_memory import AgentMemory
12
+ from redis_iris.context_retriever import ContextRetriever
13
+ from redis_iris.iris import Iris, IrisSettings
14
+ from redis_iris.playbook import Playbook
15
+
16
+ try:
17
+ __version__ = version("redis-iris")
18
+ except PackageNotFoundError: # pragma: no cover - source trees are normally installed by uv
19
+ __version__ = "0.0.0"
20
+
21
+ __all__ = ["AgentMemory", "ContextRetriever", "Iris", "IrisSettings", "Playbook", "__version__"]
redis_iris/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run the CLI with ``python -m redis_iris``."""
2
+
3
+ from redis_iris.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,30 @@
1
+ """Read product settings from ``IRIS_*`` environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+ from redis_iris.errors import ConfigurationError
9
+
10
+ DEFAULT_TIMEOUT = 30.0 # seconds, every product
11
+
12
+
13
+ def env_value(environ: Mapping[str, str] | None, name: str) -> str | None:
14
+ """Return the stripped value of ``name`` or None when unset or blank."""
15
+
16
+ source = os.environ if environ is None else environ
17
+ value = source.get(name, "").strip()
18
+ return value or None
19
+
20
+
21
+ def env_float(environ: Mapping[str, str] | None, name: str, default: float) -> float:
22
+ """Return ``name`` as a float, ``default`` when unset. Non-numeric values are a config error."""
23
+
24
+ value = env_value(environ, name)
25
+ if value is None:
26
+ return default
27
+ try:
28
+ return float(value)
29
+ except ValueError:
30
+ raise ConfigurationError(f"{name} must be a number, got {value!r}") from None
@@ -0,0 +1,43 @@
1
+ """Agent Memory the way an agent developer thinks about it.
2
+
3
+ An agent has *conversations* with an *owner* (a user, a customer). Before it answers, it
4
+ *recalls* what it knows about them and what the team knows today. Afterwards it *remembers*.
5
+ When the owner asks, it *forgets* everything about them. Those are the verbs here.
6
+
7
+ The full ``redis_agent_memory`` client is always one attribute away as ``AgentMemory.client``.
8
+ Everything below is sensible defaults over it:
9
+
10
+ * ownership scoping on every read, so one customer never sees another's memories;
11
+ * shared namespaces (hierarchical memory) for what every conversation may read;
12
+ * session ids the store accepts, derived from the owner when you do not supply one;
13
+ * deterministic memory ids, so re-running a seed does not duplicate memories;
14
+ * one ``Recall`` object that renders straight into a prompt.
15
+
16
+ Sync methods for scripts and notebooks; ``a``-prefixed twins for async agents.
17
+
18
+ Layout: ``memory`` (the goal object), ``conversation``, ``hooks`` (``before`` / ``after`` for
19
+ any framework), ``values`` (what calls return), ``ids``, ``settings`` (``IRIS_AGENT_MEMORY_*``
20
+ and the client factory). ``_wire`` is private.
21
+ """
22
+
23
+ from redis_iris.agent_memory.conversation import Conversation
24
+ from redis_iris.agent_memory.hooks import MemoryHooks
25
+ from redis_iris.agent_memory.ids import memory_id_for, safe_id, session_id_for
26
+ from redis_iris.agent_memory.memory import AgentMemory
27
+ from redis_iris.agent_memory.settings import AgentMemorySettings, connect
28
+ from redis_iris.agent_memory.values import Forgotten, Memory, Recall, Transcript
29
+
30
+ __all__ = [
31
+ "AgentMemory",
32
+ "AgentMemorySettings",
33
+ "Conversation",
34
+ "Forgotten",
35
+ "Memory",
36
+ "MemoryHooks",
37
+ "Recall",
38
+ "Transcript",
39
+ "connect",
40
+ "memory_id_for",
41
+ "safe_id",
42
+ "session_id_for",
43
+ ]
@@ -0,0 +1,47 @@
1
+ """Translation to and from the generated ``redis_agent_memory`` client. Private."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ from redis_iris.agent_memory.ids import memory_id_for
9
+ from redis_iris.agent_memory.values import Memory, Transcript
10
+
11
+
12
+ def memory(record: Any) -> Memory:
13
+ return Memory(
14
+ id=str(record.id),
15
+ text=str(record.text),
16
+ owner=getattr(record, "owner_id", None),
17
+ shared=getattr(record, "namespace_ref", None) is not None,
18
+ topics=tuple(getattr(record, "topics", None) or ()),
19
+ )
20
+
21
+
22
+ def search_request(query: str, filter_: dict[str, Any], limit: int) -> Any:
23
+ return {"text": query, "filter_": filter_, "limit": limit}
24
+
25
+
26
+ def memory_record(
27
+ text: str, owner: str, topics: Iterable[str], namespace_id: str | None, memory_id: str | None
28
+ ) -> Any:
29
+ record: dict[str, Any] = {
30
+ "id": memory_id or memory_id_for(owner, text),
31
+ "text": text,
32
+ "owner_id": owner,
33
+ "topics": list(topics) or None,
34
+ }
35
+ if namespace_id:
36
+ record["namespace_ref"] = {"namespace_id": namespace_id}
37
+ return record
38
+
39
+
40
+ def transcript(session_id: str, response: Any) -> Transcript:
41
+ turns = []
42
+ for event in response.events:
43
+ role = getattr(event.role, "value", str(event.role)).lower()
44
+ text = " ".join(part.text for part in event.content if getattr(part, "text", None))
45
+ turns.append((role, text))
46
+ summary = response.summary.text if response.summary else None
47
+ return Transcript(session_id=session_id, turns=tuple(turns), summary=summary)
@@ -0,0 +1,81 @@
1
+ """One session between an owner and the assistant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from redis_iris.agent_memory import _wire
9
+ from redis_iris.agent_memory.values import Transcript
10
+
11
+ if TYPE_CHECKING:
12
+ from redis_iris.agent_memory.memory import AgentMemory
13
+
14
+
15
+ class Conversation:
16
+ """One session between an owner and the assistant. Every message is a session event."""
17
+
18
+ def __init__(
19
+ self,
20
+ memory: AgentMemory,
21
+ owner: str,
22
+ session_id: str,
23
+ namespace_id: str | None = None,
24
+ ) -> None:
25
+ self.memory = memory
26
+ self.owner = owner
27
+ self.session_id = session_id
28
+ self.namespace_id = namespace_id
29
+
30
+ def _event(self, actor_id: str, role: str, text: str) -> dict[str, Any]:
31
+ from redis_agent_memory import models
32
+
33
+ event: dict[str, Any] = {
34
+ "session_id": self.session_id,
35
+ "actor_id": actor_id,
36
+ "role": models.MessageRole[role.upper()],
37
+ "content": [{"text": text}],
38
+ "created_at": datetime.now(UTC),
39
+ }
40
+ if self.namespace_id:
41
+ event["namespace_ref"] = {"namespace_id": self.namespace_id}
42
+ return event
43
+
44
+ def user(self, text: str) -> None:
45
+ """The owner said something."""
46
+
47
+ self.memory.client.add_session_event(**self._event(self.owner, "user", text))
48
+
49
+ def assistant(self, text: str) -> None:
50
+ """The assistant answered."""
51
+
52
+ self.memory.client.add_session_event(
53
+ **self._event(self.memory.assistant_id, "assistant", text)
54
+ )
55
+
56
+ def transcript(self) -> Transcript:
57
+ """The session as stored, with the summary once the store has written one."""
58
+
59
+ return _wire.transcript(
60
+ self.session_id, self.memory.client.get_session_memory(session_id=self.session_id)
61
+ )
62
+
63
+ async def auser(self, text: str) -> None:
64
+ """Async ``user``."""
65
+
66
+ await self.memory.client.add_session_event_async(**self._event(self.owner, "user", text))
67
+
68
+ async def aassistant(self, text: str) -> None:
69
+ """Async ``assistant``."""
70
+
71
+ await self.memory.client.add_session_event_async(
72
+ **self._event(self.memory.assistant_id, "assistant", text)
73
+ )
74
+
75
+ async def atranscript(self) -> Transcript:
76
+ """Async ``transcript``."""
77
+
78
+ return _wire.transcript(
79
+ self.session_id,
80
+ await self.memory.client.get_session_memory_async(session_id=self.session_id),
81
+ )
@@ -0,0 +1,55 @@
1
+ """The two calls every framework adapter makes. Write your own adapter against these."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from redis_iris.agent_memory.memory import AgentMemory
10
+
11
+
12
+ class MemoryHooks:
13
+ """Agent Memory as two hooks: ``before`` the model runs, ``after`` the turn ends.
14
+
15
+ ```python
16
+ hooks = memory.hooks(shared=["support-floor"])
17
+ system = f"{SYSTEM}\\n\\n{hooks.before(message, owner=user_id)}"
18
+ answer = call_model(system, message) # your framework
19
+ hooks.after(message, answer, owner=user_id, session=thread_id)
20
+ ```
21
+
22
+ ``redis_iris.integrations.<framework>.attach`` is this pair placed at that framework's own
23
+ hook points. Any framework with a "before the model" and an "after the turn" seam fits.
24
+ """
25
+
26
+ name = "memory"
27
+
28
+ def __init__(self, memory: AgentMemory, *, shared: Iterable[str] = ()) -> None:
29
+ self.memory = memory
30
+ self.floors = [memory.shared_namespace(name) for name in shared] # resolved once
31
+
32
+ def before(self, message: str, *, owner: str, session: str | None = None) -> str:
33
+ """What the agent knows about ``owner`` for this message, as a system-prompt block."""
34
+
35
+ return self.memory.recall(message, owner=owner, shared=self.floors).as_prompt()
36
+
37
+ def after(self, message: str, answer: str, *, owner: str, session: str) -> None:
38
+ """Record one exchange; the service extracts long-term memories from it."""
39
+
40
+ convo = self.memory.conversation(owner=owner, session_id=session)
41
+ convo.user(message)
42
+ convo.assistant(answer)
43
+
44
+ async def abefore(self, message: str, *, owner: str, session: str | None = None) -> str:
45
+ """Async ``before``."""
46
+
47
+ known = await self.memory.arecall(message, owner=owner, shared=self.floors)
48
+ return known.as_prompt()
49
+
50
+ async def aafter(self, message: str, answer: str, *, owner: str, session: str) -> None:
51
+ """Async ``after``."""
52
+
53
+ convo = self.memory.conversation(owner=owner, session_id=session)
54
+ await convo.auser(message)
55
+ await convo.aassistant(answer)
@@ -0,0 +1,27 @@
1
+ """Identifiers the store accepts: session ids from owners, deterministic memory ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from datetime import UTC, datetime
8
+
9
+
10
+ def session_id_for(owner: str, at: datetime | None = None) -> str:
11
+ """A session id the store accepts (alphanumerics and hyphens), unique per owner and time."""
12
+
13
+ stamp = (at or datetime.now(UTC)).strftime("%Y%m%d%H%M%S")
14
+ return safe_id(f"{owner}-{stamp}")
15
+
16
+
17
+ def safe_id(value: str) -> str:
18
+ """``value`` with every run of characters the store rejects replaced by one hyphen."""
19
+
20
+ return re.sub(r"[^A-Za-z0-9-]+", "-", value).strip("-")
21
+
22
+
23
+ def memory_id_for(owner: str, text: str) -> str:
24
+ """Deterministic, so remembering the same fact twice is a no-op instead of a duplicate."""
25
+
26
+ digest = hashlib.sha1(f"{owner}\n{text.strip()}".encode()).hexdigest()
27
+ return f"mem-{digest[:24]}"
@@ -0,0 +1,309 @@
1
+ """``AgentMemory``: conversations, recall, remember and forget over Redis Agent Memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from redis_iris.agent_memory import _wire
10
+ from redis_iris.agent_memory.conversation import Conversation
11
+ from redis_iris.agent_memory.ids import safe_id, session_id_for
12
+ from redis_iris.agent_memory.settings import AgentMemorySettings, connect
13
+ from redis_iris.agent_memory.values import Forgotten, Memory, Recall
14
+
15
+ if TYPE_CHECKING:
16
+ from redis_agent_memory import AgentMemory as AgentMemoryClient
17
+
18
+ from redis_iris.agent_memory.hooks import MemoryHooks
19
+
20
+
21
+ class AgentMemory:
22
+ """Plug Redis Agent Memory into an agent you already have.
23
+
24
+ ```python
25
+ memory = AgentMemory.from_env()
26
+ floor = memory.shared_namespace("support-floor")
27
+ convo = memory.conversation(owner="cust-1042")
28
+ known = memory.recall("dispute", owner="cust-1042", shared=[floor])
29
+ ... # answer with known.as_prompt() in the system prompt
30
+ convo.user(message); convo.assistant(answer)
31
+ ```
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ settings: AgentMemorySettings | None = None,
37
+ *,
38
+ assistant_id: str = "assistant",
39
+ client: AgentMemoryClient | None = None,
40
+ ) -> None:
41
+ self.settings = settings or AgentMemorySettings()
42
+ self.assistant_id = assistant_id
43
+ self._client = client
44
+
45
+ @classmethod
46
+ def from_env(
47
+ cls, environ: Mapping[str, str] | None = None, *, assistant_id: str = "assistant"
48
+ ) -> AgentMemory:
49
+ """Settings from ``IRIS_AGENT_MEMORY_*``; ``assistant_id`` names the agent in sessions."""
50
+
51
+ return cls(AgentMemorySettings.from_env(environ), assistant_id=assistant_id)
52
+
53
+ @property
54
+ def client(self) -> AgentMemoryClient:
55
+ """The generated ``redis_agent_memory`` client: everything this module does not wrap."""
56
+
57
+ if self._client is None:
58
+ self._client = connect(self.settings)
59
+ return self._client
60
+
61
+ def __enter__(self) -> AgentMemory:
62
+ self.client.__enter__()
63
+ return self
64
+
65
+ def __exit__(self, *exc: object) -> None:
66
+ self.client.__exit__(*exc)
67
+
68
+ async def __aenter__(self) -> AgentMemory:
69
+ await self.client.__aenter__()
70
+ return self
71
+
72
+ async def __aexit__(self, *exc: object) -> None:
73
+ await self.client.__aexit__(*exc)
74
+
75
+ # hooks for any framework
76
+
77
+ def hooks(self, *, shared: Iterable[str] = ()) -> MemoryHooks:
78
+ """``before`` / ``after`` for the two seams every agent framework has."""
79
+
80
+ from redis_iris.agent_memory.hooks import MemoryHooks
81
+
82
+ return MemoryHooks(self, shared=shared)
83
+
84
+ # conversations
85
+
86
+ def conversation(
87
+ self, owner: str, session_id: str | None = None, *, namespace_id: str | None = None
88
+ ) -> Conversation:
89
+ """Start or continue a session.
90
+
91
+ Without ``session_id`` a new session starts, named from the owner and the current time.
92
+ Keep ``Conversation.session_id`` and pass it back to continue the same session later.
93
+ """
94
+
95
+ return Conversation(self, owner, safe_id(session_id or session_id_for(owner)), namespace_id)
96
+
97
+ # namespaces
98
+
99
+ def _find_shared(self, response: Any, name: str) -> str | None:
100
+ return next((str(ns.namespace_id) for ns in response.items if ns.name == name), None)
101
+
102
+ def shared_namespace(self, name: str) -> str:
103
+ """Get or create a SHARED namespace by name and return its id. Idempotent."""
104
+
105
+ from redis_agent_memory import models
106
+
107
+ page = self.client.list_namespaces(scope=models.NamespaceScope.SHARED)
108
+ found = self._find_shared(page, name)
109
+ if found:
110
+ return found
111
+ created = self.client.create_namespace(
112
+ request={"name": name, "scope": models.NamespaceScope.SHARED}
113
+ )
114
+ return str(created.namespace.namespace_id)
115
+
116
+ async def ashared_namespace(self, name: str) -> str:
117
+ """Async ``shared_namespace``."""
118
+
119
+ from redis_agent_memory import models
120
+
121
+ page = await self.client.list_namespaces_async(scope=models.NamespaceScope.SHARED)
122
+ found = self._find_shared(page, name)
123
+ if found:
124
+ return found
125
+ created = await self.client.create_namespace_async(
126
+ request={"name": name, "scope": models.NamespaceScope.SHARED}
127
+ )
128
+ return str(created.namespace.namespace_id)
129
+
130
+ # recall
131
+
132
+ def _recall_requests(
133
+ self, query: str, owner: str, shared: Iterable[str], limit: int
134
+ ) -> list[Any]:
135
+ requests = [_wire.search_request(query, {"owner_id": {"eq": owner}}, limit)]
136
+ requests += [
137
+ _wire.search_request(query, {"namespace_ref": {"eq": namespace_id}}, limit)
138
+ for namespace_id in shared
139
+ ]
140
+ return requests
141
+
142
+ @staticmethod
143
+ def _recall(memories: Iterable[Memory]) -> Recall:
144
+ # A memory that is both owned and in a shared namespace matches two searches; keep one.
145
+ unique: dict[str, Memory] = {}
146
+ for memory in memories:
147
+ unique.setdefault(memory.id, memory)
148
+ return Recall(tuple(unique.values()))
149
+
150
+ def recall(
151
+ self, query: str, *, owner: str, shared: Iterable[str] = (), limit: int = 5
152
+ ) -> Recall:
153
+ """What the agent may know: the owner's memories, then each shared namespace.
154
+
155
+ One search per source, up to ``limit`` each, de-duplicated by memory id.
156
+ """
157
+
158
+ memories: list[Memory] = []
159
+ for request in self._recall_requests(query, owner, shared, limit):
160
+ memories += [
161
+ _wire.memory(r) for r in self.client.search_long_term_memory(request=request).items
162
+ ]
163
+ return self._recall(memories)
164
+
165
+ async def arecall(
166
+ self, query: str, *, owner: str, shared: Iterable[str] = (), limit: int = 5
167
+ ) -> Recall:
168
+ """Async ``recall``. The searches are independent, so they run at once."""
169
+
170
+ pages = await asyncio.gather(
171
+ *(
172
+ self.client.search_long_term_memory_async(request=request)
173
+ for request in self._recall_requests(query, owner, shared, limit)
174
+ )
175
+ )
176
+ # gather keeps request order, so the owner's memories still come first.
177
+ return self._recall(_wire.memory(r) for page in pages for r in page.items)
178
+
179
+ # remember
180
+
181
+ def remember(
182
+ self,
183
+ text: str,
184
+ *,
185
+ owner: str,
186
+ topics: Iterable[str] = (),
187
+ namespace_id: str | None = None,
188
+ memory_id: str | None = None,
189
+ ) -> Memory:
190
+ """Write one long-term memory explicitly; the store also extracts them from sessions."""
191
+
192
+ record = _wire.memory_record(text, owner, topics, namespace_id, memory_id)
193
+ self.client.bulk_create_long_term_memories(memories=[record])
194
+ return Memory(record["id"], text, owner, namespace_id is not None, tuple(topics))
195
+
196
+ async def aremember(
197
+ self,
198
+ text: str,
199
+ *,
200
+ owner: str,
201
+ topics: Iterable[str] = (),
202
+ namespace_id: str | None = None,
203
+ memory_id: str | None = None,
204
+ ) -> Memory:
205
+ """Async ``remember``."""
206
+
207
+ record = _wire.memory_record(text, owner, topics, namespace_id, memory_id)
208
+ await self.client.bulk_create_long_term_memories_async(memories=[record])
209
+ return Memory(record["id"], text, owner, namespace_id is not None, tuple(topics))
210
+
211
+ # inventory and erasure
212
+
213
+ @staticmethod
214
+ def _inventory_request(owner: str, token: str | None) -> Any:
215
+ request: Any = {"filter_": {"owner_id": {"eq": owner}}, "limit": 100}
216
+ if token:
217
+ request["page_token"] = token
218
+ return request
219
+
220
+ def memories(self, owner: str) -> list[Memory]:
221
+ """Every long-term memory the owner holds, seeded or extracted."""
222
+
223
+ items: list[Memory] = []
224
+ token = None
225
+ while True:
226
+ page = self.client.search_long_term_memory(
227
+ request=self._inventory_request(owner, token)
228
+ )
229
+ items += [_wire.memory(r) for r in page.items]
230
+ token = page.next_page_token
231
+ if not token:
232
+ return items
233
+
234
+ async def amemories(self, owner: str) -> list[Memory]:
235
+ """Async ``memories``."""
236
+
237
+ items: list[Memory] = []
238
+ token = None
239
+ while True:
240
+ page = await self.client.search_long_term_memory_async(
241
+ request=self._inventory_request(owner, token)
242
+ )
243
+ items += [_wire.memory(r) for r in page.items]
244
+ token = page.next_page_token
245
+ if not token:
246
+ return items
247
+
248
+ def sessions(self, owner: str) -> list[str]:
249
+ """Every session id the owner holds."""
250
+
251
+ ids: list[str] = []
252
+ token = None
253
+ while True:
254
+ page = self.client.list_sessions(filter_owner_id=owner, limit=100, page_token=token)
255
+ ids.extend(page.items)
256
+ token = page.next_page_token
257
+ if not token:
258
+ return ids
259
+
260
+ async def asessions(self, owner: str) -> list[str]:
261
+ """Async ``sessions``."""
262
+
263
+ ids: list[str] = []
264
+ token = None
265
+ while True:
266
+ page = await self.client.list_sessions_async(
267
+ filter_owner_id=owner, limit=100, page_token=token
268
+ )
269
+ ids.extend(page.items)
270
+ token = page.next_page_token
271
+ if not token:
272
+ return ids
273
+
274
+ def forget(self, owner: str) -> Forgotten:
275
+ """Right to erasure: every session and long-term memory the owner holds."""
276
+
277
+ sessions = self.sessions(owner)
278
+ memories = self.memories(owner)
279
+ for session_id in sessions:
280
+ self.client.delete_session_memory(session_id=session_id)
281
+ if memories:
282
+ self.client.bulk_delete_long_term_memories(memory_ids=[m.id for m in memories])
283
+ return Forgotten(owner, len(sessions), len(memories))
284
+
285
+ async def aforget(self, owner: str) -> Forgotten:
286
+ """Async ``forget``. The two inventory reads are independent, so they run at once."""
287
+
288
+ sessions, memories = await asyncio.gather(self.asessions(owner), self.amemories(owner))
289
+ for session_id in sessions:
290
+ await self.client.delete_session_memory_async(session_id=session_id)
291
+ if memories:
292
+ await self.client.bulk_delete_long_term_memories_async(
293
+ memory_ids=[m.id for m in memories]
294
+ )
295
+ return Forgotten(owner, len(sessions), len(memories))
296
+
297
+ def delete(self, memory_ids: Iterable[str]) -> None:
298
+ """Delete specific long-term memories by id. Empty input is a no-op."""
299
+
300
+ ids = list(memory_ids)
301
+ if ids:
302
+ self.client.bulk_delete_long_term_memories(memory_ids=ids)
303
+
304
+ async def adelete(self, memory_ids: Iterable[str]) -> None:
305
+ """Async ``delete``."""
306
+
307
+ ids = list(memory_ids)
308
+ if ids:
309
+ await self.client.bulk_delete_long_term_memories_async(memory_ids=ids)