gnosisllm-knowledge 0.2.0__py3-none-any.whl → 0.4.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.
- gnosisllm_knowledge/__init__.py +91 -39
- gnosisllm_knowledge/api/__init__.py +3 -2
- gnosisllm_knowledge/api/knowledge.py +502 -32
- gnosisllm_knowledge/api/memory.py +966 -0
- gnosisllm_knowledge/backends/__init__.py +14 -5
- gnosisllm_knowledge/backends/memory/indexer.py +27 -2
- gnosisllm_knowledge/backends/memory/searcher.py +111 -10
- gnosisllm_knowledge/backends/opensearch/agentic.py +355 -48
- gnosisllm_knowledge/backends/opensearch/config.py +49 -28
- gnosisllm_knowledge/backends/opensearch/indexer.py +49 -3
- gnosisllm_knowledge/backends/opensearch/mappings.py +14 -5
- gnosisllm_knowledge/backends/opensearch/memory/__init__.py +12 -0
- gnosisllm_knowledge/backends/opensearch/memory/client.py +1380 -0
- gnosisllm_knowledge/backends/opensearch/memory/config.py +127 -0
- gnosisllm_knowledge/backends/opensearch/memory/setup.py +322 -0
- gnosisllm_knowledge/backends/opensearch/queries.py +33 -33
- gnosisllm_knowledge/backends/opensearch/searcher.py +238 -0
- gnosisllm_knowledge/backends/opensearch/setup.py +308 -148
- gnosisllm_knowledge/cli/app.py +436 -31
- gnosisllm_knowledge/cli/commands/agentic.py +26 -9
- gnosisllm_knowledge/cli/commands/load.py +169 -19
- gnosisllm_knowledge/cli/commands/memory.py +733 -0
- gnosisllm_knowledge/cli/commands/search.py +9 -10
- gnosisllm_knowledge/cli/commands/setup.py +49 -23
- gnosisllm_knowledge/cli/display/service.py +43 -0
- gnosisllm_knowledge/cli/utils/config.py +62 -4
- gnosisllm_knowledge/core/domain/__init__.py +54 -0
- gnosisllm_knowledge/core/domain/discovery.py +166 -0
- gnosisllm_knowledge/core/domain/document.py +19 -19
- gnosisllm_knowledge/core/domain/memory.py +440 -0
- gnosisllm_knowledge/core/domain/result.py +11 -3
- gnosisllm_knowledge/core/domain/search.py +12 -25
- gnosisllm_knowledge/core/domain/source.py +11 -12
- gnosisllm_knowledge/core/events/__init__.py +8 -0
- gnosisllm_knowledge/core/events/types.py +198 -5
- gnosisllm_knowledge/core/exceptions.py +227 -0
- gnosisllm_knowledge/core/interfaces/__init__.py +17 -0
- gnosisllm_knowledge/core/interfaces/agentic.py +11 -3
- gnosisllm_knowledge/core/interfaces/indexer.py +10 -1
- gnosisllm_knowledge/core/interfaces/memory.py +524 -0
- gnosisllm_knowledge/core/interfaces/searcher.py +10 -1
- gnosisllm_knowledge/core/interfaces/streaming.py +133 -0
- gnosisllm_knowledge/core/streaming/__init__.py +36 -0
- gnosisllm_knowledge/core/streaming/pipeline.py +228 -0
- gnosisllm_knowledge/fetchers/__init__.py +8 -0
- gnosisllm_knowledge/fetchers/config.py +27 -0
- gnosisllm_knowledge/fetchers/neoreader.py +31 -3
- gnosisllm_knowledge/fetchers/neoreader_discovery.py +505 -0
- gnosisllm_knowledge/loaders/__init__.py +5 -1
- gnosisllm_knowledge/loaders/base.py +3 -4
- gnosisllm_knowledge/loaders/discovery.py +338 -0
- gnosisllm_knowledge/loaders/discovery_streaming.py +343 -0
- gnosisllm_knowledge/loaders/factory.py +46 -0
- gnosisllm_knowledge/loaders/sitemap.py +129 -1
- gnosisllm_knowledge/loaders/sitemap_streaming.py +258 -0
- gnosisllm_knowledge/services/indexing.py +100 -93
- gnosisllm_knowledge/services/search.py +84 -31
- gnosisllm_knowledge/services/streaming_pipeline.py +334 -0
- {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/METADATA +73 -10
- gnosisllm_knowledge-0.4.0.dist-info/RECORD +81 -0
- gnosisllm_knowledge-0.2.0.dist-info/RECORD +0 -64
- {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/WHEEL +0 -0
- {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
"""OpenSearch Agentic Memory API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from gnosisllm_knowledge.core.domain.memory import (
|
|
12
|
+
ContainerConfig,
|
|
13
|
+
ContainerInfo,
|
|
14
|
+
HistoryAction,
|
|
15
|
+
HistoryEntry,
|
|
16
|
+
MemoryEntry,
|
|
17
|
+
MemoryStats,
|
|
18
|
+
MemoryStrategy,
|
|
19
|
+
MemoryType,
|
|
20
|
+
Message,
|
|
21
|
+
Namespace,
|
|
22
|
+
PayloadType,
|
|
23
|
+
RecallResult,
|
|
24
|
+
SessionInfo,
|
|
25
|
+
StoreRequest,
|
|
26
|
+
StoreResult,
|
|
27
|
+
)
|
|
28
|
+
from gnosisllm_knowledge.core.exceptions import ContainerNotFoundError, MemoryError
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from gnosisllm_knowledge.backends.opensearch.memory.config import MemoryConfig
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenSearchMemoryClient:
|
|
37
|
+
"""Client for OpenSearch Agentic Memory APIs.
|
|
38
|
+
|
|
39
|
+
Implements memory operations using the OpenSearch ML Memory plugin.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
client = OpenSearchMemoryClient(config)
|
|
44
|
+
|
|
45
|
+
# Create container
|
|
46
|
+
container = await client.create_container(ContainerConfig(
|
|
47
|
+
name="my-memory",
|
|
48
|
+
strategies=[
|
|
49
|
+
StrategyConfig(type=MemoryStrategy.SEMANTIC, namespace=["user_id"]),
|
|
50
|
+
],
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# List containers
|
|
54
|
+
containers = await client.list_containers()
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: MemoryConfig) -> None:
|
|
59
|
+
"""Initialize the client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
config: Memory configuration.
|
|
63
|
+
"""
|
|
64
|
+
self._config = config
|
|
65
|
+
self._base_url = config.url
|
|
66
|
+
self._auth = config.auth
|
|
67
|
+
|
|
68
|
+
# === Container Management ===
|
|
69
|
+
|
|
70
|
+
async def create_container(
|
|
71
|
+
self,
|
|
72
|
+
config: ContainerConfig,
|
|
73
|
+
**options: Any,
|
|
74
|
+
) -> ContainerInfo:
|
|
75
|
+
"""Create a memory container.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config: Container configuration.
|
|
79
|
+
**options: Additional options.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Created container info.
|
|
83
|
+
"""
|
|
84
|
+
# Build strategies list
|
|
85
|
+
strategies = [s.to_dict() for s in config.strategies]
|
|
86
|
+
if not strategies:
|
|
87
|
+
# Use default strategies from config
|
|
88
|
+
strategies = [
|
|
89
|
+
{
|
|
90
|
+
"type": s.value,
|
|
91
|
+
"namespace": ["user_id"],
|
|
92
|
+
"configuration": {
|
|
93
|
+
"llm_result_path": self._config.llm_result_path,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
for s in self._config.default_strategies
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
body: dict[str, Any] = {
|
|
100
|
+
"name": config.name,
|
|
101
|
+
"configuration": {
|
|
102
|
+
"embedding_model_id": config.embedding_model_id
|
|
103
|
+
or self._config.embedding_model_id,
|
|
104
|
+
"embedding_model_type": config.embedding_model_type.value,
|
|
105
|
+
"embedding_dimension": config.embedding_dimension,
|
|
106
|
+
"llm_id": config.llm_model_id or self._config.llm_model_id,
|
|
107
|
+
"strategies": strategies,
|
|
108
|
+
"use_system_index": config.use_system_index,
|
|
109
|
+
"parameters": {
|
|
110
|
+
"llm_result_path": config.llm_result_path,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if config.description:
|
|
116
|
+
body["description"] = config.description
|
|
117
|
+
if config.index_prefix:
|
|
118
|
+
body["configuration"]["index_prefix"] = config.index_prefix
|
|
119
|
+
if config.index_settings:
|
|
120
|
+
body["configuration"]["index_settings"] = config.index_settings.to_dict()
|
|
121
|
+
|
|
122
|
+
async with httpx.AsyncClient(
|
|
123
|
+
verify=self._config.verify_certs,
|
|
124
|
+
timeout=self._config.connect_timeout,
|
|
125
|
+
) as client:
|
|
126
|
+
response = await client.post(
|
|
127
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/_create",
|
|
128
|
+
json=body,
|
|
129
|
+
auth=self._auth,
|
|
130
|
+
)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
result = response.json()
|
|
133
|
+
|
|
134
|
+
container_id = result.get("memory_container_id")
|
|
135
|
+
|
|
136
|
+
return ContainerInfo(
|
|
137
|
+
id=container_id,
|
|
138
|
+
name=config.name,
|
|
139
|
+
description=config.description,
|
|
140
|
+
strategies=[s.type for s in config.strategies] if config.strategies else [],
|
|
141
|
+
embedding_model_id=config.embedding_model_id,
|
|
142
|
+
llm_model_id=config.llm_model_id,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def get_container(
|
|
146
|
+
self,
|
|
147
|
+
container_id: str,
|
|
148
|
+
**options: Any,
|
|
149
|
+
) -> ContainerInfo | None:
|
|
150
|
+
"""Get container by ID.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
container_id: Container ID.
|
|
154
|
+
**options: Additional options.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Container info or None if not found.
|
|
158
|
+
"""
|
|
159
|
+
async with httpx.AsyncClient(
|
|
160
|
+
verify=self._config.verify_certs,
|
|
161
|
+
timeout=self._config.connect_timeout,
|
|
162
|
+
) as client:
|
|
163
|
+
response = await client.get(
|
|
164
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}",
|
|
165
|
+
auth=self._auth,
|
|
166
|
+
)
|
|
167
|
+
if response.status_code == 404:
|
|
168
|
+
return None
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
data = response.json()
|
|
171
|
+
|
|
172
|
+
return self._parse_container_info(container_id, data)
|
|
173
|
+
|
|
174
|
+
async def list_containers(
|
|
175
|
+
self,
|
|
176
|
+
limit: int = 100,
|
|
177
|
+
**options: Any,
|
|
178
|
+
) -> list[ContainerInfo]:
|
|
179
|
+
"""List all containers.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
limit: Maximum number of containers to return.
|
|
183
|
+
**options: Additional options.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of container info.
|
|
187
|
+
"""
|
|
188
|
+
async with httpx.AsyncClient(
|
|
189
|
+
verify=self._config.verify_certs,
|
|
190
|
+
timeout=self._config.connect_timeout,
|
|
191
|
+
) as client:
|
|
192
|
+
response = await client.post(
|
|
193
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/_search",
|
|
194
|
+
json={"query": {"match_all": {}}, "size": limit},
|
|
195
|
+
auth=self._auth,
|
|
196
|
+
)
|
|
197
|
+
response.raise_for_status()
|
|
198
|
+
data = response.json()
|
|
199
|
+
|
|
200
|
+
containers = []
|
|
201
|
+
for hit in data.get("hits", {}).get("hits", []):
|
|
202
|
+
container = self._parse_container_info(hit["_id"], hit["_source"])
|
|
203
|
+
containers.append(container)
|
|
204
|
+
|
|
205
|
+
return containers
|
|
206
|
+
|
|
207
|
+
async def update_container(
|
|
208
|
+
self,
|
|
209
|
+
container_id: str,
|
|
210
|
+
config: ContainerConfig,
|
|
211
|
+
**options: Any,
|
|
212
|
+
) -> ContainerInfo:
|
|
213
|
+
"""Update a container configuration.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
container_id: Container ID.
|
|
217
|
+
config: Updated configuration.
|
|
218
|
+
**options: Additional options.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Updated container info.
|
|
222
|
+
"""
|
|
223
|
+
body: dict[str, Any] = {}
|
|
224
|
+
|
|
225
|
+
if config.description is not None:
|
|
226
|
+
body["description"] = config.description
|
|
227
|
+
|
|
228
|
+
if config.strategies:
|
|
229
|
+
body["configuration"] = {
|
|
230
|
+
"strategies": [s.to_dict() for s in config.strategies],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async with httpx.AsyncClient(
|
|
234
|
+
verify=self._config.verify_certs,
|
|
235
|
+
timeout=self._config.connect_timeout,
|
|
236
|
+
) as client:
|
|
237
|
+
response = await client.put(
|
|
238
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}",
|
|
239
|
+
json=body,
|
|
240
|
+
auth=self._auth,
|
|
241
|
+
)
|
|
242
|
+
response.raise_for_status()
|
|
243
|
+
data = response.json()
|
|
244
|
+
|
|
245
|
+
return self._parse_container_info(container_id, data)
|
|
246
|
+
|
|
247
|
+
async def delete_container(
|
|
248
|
+
self,
|
|
249
|
+
container_id: str,
|
|
250
|
+
**options: Any,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Delete a container.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
container_id: Container ID.
|
|
256
|
+
**options: Additional options.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if deleted, False if not found.
|
|
260
|
+
"""
|
|
261
|
+
async with httpx.AsyncClient(
|
|
262
|
+
verify=self._config.verify_certs,
|
|
263
|
+
timeout=self._config.connect_timeout,
|
|
264
|
+
) as client:
|
|
265
|
+
response = await client.delete(
|
|
266
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}",
|
|
267
|
+
auth=self._auth,
|
|
268
|
+
)
|
|
269
|
+
if response.status_code == 404:
|
|
270
|
+
return False
|
|
271
|
+
response.raise_for_status()
|
|
272
|
+
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
# === Memory Storage ===
|
|
276
|
+
|
|
277
|
+
async def store(
|
|
278
|
+
self,
|
|
279
|
+
container_id: str,
|
|
280
|
+
request: StoreRequest,
|
|
281
|
+
**options: Any,
|
|
282
|
+
) -> StoreResult:
|
|
283
|
+
"""Store memory with optional inference.
|
|
284
|
+
|
|
285
|
+
Which strategies run is determined by:
|
|
286
|
+
1. How the container was configured (strategy -> namespace field mapping)
|
|
287
|
+
2. Which namespace fields are present in the request
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
Container has SEMANTIC scoped to "user_id", SUMMARY scoped to "session_id".
|
|
291
|
+
- namespace={"user_id": "123"} -> Runs SEMANTIC only
|
|
292
|
+
- namespace={"user_id": "123", "session_id": "abc"} -> Runs both
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
container_id: Target container.
|
|
296
|
+
request: Store request with namespace values.
|
|
297
|
+
**options: Additional options.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Store result.
|
|
301
|
+
"""
|
|
302
|
+
# Build request body
|
|
303
|
+
body: dict[str, Any] = {
|
|
304
|
+
"payload_type": request.payload_type.value,
|
|
305
|
+
"infer": request.infer,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if request.payload_type == PayloadType.CONVERSATIONAL:
|
|
309
|
+
if not request.messages:
|
|
310
|
+
raise MemoryError("Messages required for conversational payload")
|
|
311
|
+
body["messages"] = [m.to_dict() for m in request.messages]
|
|
312
|
+
else:
|
|
313
|
+
if not request.structured_data:
|
|
314
|
+
raise MemoryError("Structured data required for data payload")
|
|
315
|
+
body["structured_data"] = request.structured_data
|
|
316
|
+
|
|
317
|
+
# Namespace determines which strategies run based on container config
|
|
318
|
+
body["namespace"] = request.namespace.to_dict()
|
|
319
|
+
|
|
320
|
+
if request.metadata:
|
|
321
|
+
body["metadata"] = request.metadata
|
|
322
|
+
if request.tags:
|
|
323
|
+
body["tags"] = request.tags
|
|
324
|
+
|
|
325
|
+
async with httpx.AsyncClient(
|
|
326
|
+
verify=self._config.verify_certs,
|
|
327
|
+
timeout=self._config.inference_timeout
|
|
328
|
+
if request.infer
|
|
329
|
+
else self._config.connect_timeout,
|
|
330
|
+
) as client:
|
|
331
|
+
response = await client.post(
|
|
332
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories",
|
|
333
|
+
json=body,
|
|
334
|
+
auth=self._auth,
|
|
335
|
+
)
|
|
336
|
+
response.raise_for_status()
|
|
337
|
+
result = response.json()
|
|
338
|
+
|
|
339
|
+
return StoreResult(
|
|
340
|
+
session_id=result.get("session_id"),
|
|
341
|
+
working_memory_id=result.get("working_memory_id"),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# === Working Memory ===
|
|
345
|
+
|
|
346
|
+
async def get_working_memory(
|
|
347
|
+
self,
|
|
348
|
+
container_id: str,
|
|
349
|
+
session_id: str | None = None,
|
|
350
|
+
namespace: Namespace | None = None,
|
|
351
|
+
limit: int = 50,
|
|
352
|
+
offset: int = 0,
|
|
353
|
+
**options: Any,
|
|
354
|
+
) -> list[Message]:
|
|
355
|
+
"""Get working memory messages.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
container_id: Container ID.
|
|
359
|
+
session_id: Optional session filter.
|
|
360
|
+
namespace: Optional namespace filter.
|
|
361
|
+
limit: Maximum number of messages to return.
|
|
362
|
+
offset: Number of messages to skip.
|
|
363
|
+
**options: Additional options.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List of messages.
|
|
367
|
+
"""
|
|
368
|
+
search_body: dict[str, Any] = {
|
|
369
|
+
"query": {"match_all": {}},
|
|
370
|
+
"size": limit,
|
|
371
|
+
"from": offset,
|
|
372
|
+
"sort": [{"created_time": "asc"}],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
filters = []
|
|
376
|
+
if session_id:
|
|
377
|
+
filters.append({"term": {"session_id": session_id}})
|
|
378
|
+
if namespace:
|
|
379
|
+
for key, value in namespace.values.items():
|
|
380
|
+
filters.append({"term": {f"namespace.{key}": value}})
|
|
381
|
+
|
|
382
|
+
if filters:
|
|
383
|
+
search_body["query"] = {"bool": {"filter": filters}}
|
|
384
|
+
|
|
385
|
+
async with httpx.AsyncClient(
|
|
386
|
+
verify=self._config.verify_certs,
|
|
387
|
+
timeout=self._config.connect_timeout,
|
|
388
|
+
) as client:
|
|
389
|
+
response = await client.post(
|
|
390
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/working/_search",
|
|
391
|
+
json=search_body,
|
|
392
|
+
auth=self._auth,
|
|
393
|
+
)
|
|
394
|
+
response.raise_for_status()
|
|
395
|
+
result = response.json()
|
|
396
|
+
|
|
397
|
+
messages = []
|
|
398
|
+
for hit in result.get("hits", {}).get("hits", []):
|
|
399
|
+
source = hit["_source"]
|
|
400
|
+
content_parts = source.get("content", [])
|
|
401
|
+
content = content_parts[0].get("text", "") if content_parts else ""
|
|
402
|
+
messages.append(
|
|
403
|
+
Message(
|
|
404
|
+
role=source.get("role", "user"),
|
|
405
|
+
content=content,
|
|
406
|
+
timestamp=datetime.fromtimestamp(source["created_time"] / 1000)
|
|
407
|
+
if source.get("created_time")
|
|
408
|
+
else None,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return messages
|
|
413
|
+
|
|
414
|
+
async def clear_working_memory(
|
|
415
|
+
self,
|
|
416
|
+
container_id: str,
|
|
417
|
+
session_id: str | None = None,
|
|
418
|
+
namespace: Namespace | None = None,
|
|
419
|
+
**options: Any,
|
|
420
|
+
) -> int:
|
|
421
|
+
"""Clear working memory.
|
|
422
|
+
|
|
423
|
+
Deletes working memory messages matching the filter criteria.
|
|
424
|
+
Uses delete-by-query internally.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
container_id: Container ID.
|
|
428
|
+
session_id: Optional session filter.
|
|
429
|
+
namespace: Optional namespace filter.
|
|
430
|
+
**options: Backend-specific options.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Number of messages deleted.
|
|
434
|
+
"""
|
|
435
|
+
# Build query for delete-by-query
|
|
436
|
+
filters = []
|
|
437
|
+
if session_id:
|
|
438
|
+
filters.append({"term": {"session_id": session_id}})
|
|
439
|
+
if namespace:
|
|
440
|
+
for key, value in namespace.values.items():
|
|
441
|
+
filters.append({"term": {f"namespace.{key}": value}})
|
|
442
|
+
|
|
443
|
+
if filters:
|
|
444
|
+
query: dict[str, Any] = {"bool": {"filter": filters}}
|
|
445
|
+
else:
|
|
446
|
+
query = {"match_all": {}}
|
|
447
|
+
|
|
448
|
+
return await self.delete_by_query(
|
|
449
|
+
container_id=container_id,
|
|
450
|
+
memory_type=MemoryType.WORKING,
|
|
451
|
+
query=query,
|
|
452
|
+
**options,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
async def delete_by_query(
|
|
456
|
+
self,
|
|
457
|
+
container_id: str,
|
|
458
|
+
memory_type: MemoryType,
|
|
459
|
+
query: dict[str, Any],
|
|
460
|
+
**options: Any,
|
|
461
|
+
) -> int:
|
|
462
|
+
"""Delete memories matching an OpenSearch Query DSL query.
|
|
463
|
+
|
|
464
|
+
Provides full flexibility for complex deletion criteria.
|
|
465
|
+
|
|
466
|
+
Example:
|
|
467
|
+
```python
|
|
468
|
+
# Delete all memories for a user older than 30 days
|
|
469
|
+
await client.delete_by_query(
|
|
470
|
+
container_id=container_id,
|
|
471
|
+
memory_type=MemoryType.WORKING,
|
|
472
|
+
query={
|
|
473
|
+
"bool": {
|
|
474
|
+
"must": [
|
|
475
|
+
{"term": {"namespace.user_id": "user123"}},
|
|
476
|
+
{"range": {"created_time": {"lt": "now-30d"}}}
|
|
477
|
+
]
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
container_id: Container ID.
|
|
485
|
+
memory_type: Memory type to delete from.
|
|
486
|
+
query: OpenSearch Query DSL query.
|
|
487
|
+
**options: Backend-specific options.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
Number of documents deleted.
|
|
491
|
+
"""
|
|
492
|
+
body = {"query": query}
|
|
493
|
+
|
|
494
|
+
async with httpx.AsyncClient(
|
|
495
|
+
verify=self._config.verify_certs,
|
|
496
|
+
timeout=self._config.inference_timeout,
|
|
497
|
+
) as client:
|
|
498
|
+
response = await client.post(
|
|
499
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type.value}/_delete_by_query",
|
|
500
|
+
json=body,
|
|
501
|
+
auth=self._auth,
|
|
502
|
+
)
|
|
503
|
+
response.raise_for_status()
|
|
504
|
+
result = response.json()
|
|
505
|
+
|
|
506
|
+
return result.get("deleted", 0)
|
|
507
|
+
|
|
508
|
+
# === Memory Recall (Phase 5) ===
|
|
509
|
+
|
|
510
|
+
async def recall(
|
|
511
|
+
self,
|
|
512
|
+
container_id: str,
|
|
513
|
+
query: str,
|
|
514
|
+
namespace: Namespace | None = None,
|
|
515
|
+
strategies: list[MemoryStrategy] | None = None,
|
|
516
|
+
min_score: float | None = None,
|
|
517
|
+
limit: int = 10,
|
|
518
|
+
after: datetime | None = None,
|
|
519
|
+
before: datetime | None = None,
|
|
520
|
+
**options: Any,
|
|
521
|
+
) -> RecallResult:
|
|
522
|
+
"""Semantic search over long-term memory.
|
|
523
|
+
|
|
524
|
+
Uses the ML API endpoint (NOT direct index access).
|
|
525
|
+
CRITICAL: Memory content is in the 'memory' field, not 'content'.
|
|
526
|
+
|
|
527
|
+
Example:
|
|
528
|
+
```python
|
|
529
|
+
result = await client.recall(
|
|
530
|
+
container_id="container-123",
|
|
531
|
+
query="user preferences",
|
|
532
|
+
namespace=Namespace({"user_id": "alice-123"}),
|
|
533
|
+
strategies=[MemoryStrategy.SEMANTIC],
|
|
534
|
+
limit=5,
|
|
535
|
+
)
|
|
536
|
+
for item in result.items:
|
|
537
|
+
print(f"{item.content} (score: {item.score})")
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
container_id: Container ID.
|
|
542
|
+
query: Search query text.
|
|
543
|
+
namespace: Optional namespace filter.
|
|
544
|
+
strategies: Filter by specific strategies.
|
|
545
|
+
min_score: Minimum similarity score.
|
|
546
|
+
limit: Maximum results to return.
|
|
547
|
+
after: Filter by created after timestamp.
|
|
548
|
+
before: Filter by created before timestamp.
|
|
549
|
+
**options: Additional options.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
RecallResult with matching memory entries.
|
|
553
|
+
"""
|
|
554
|
+
# Build neural query
|
|
555
|
+
search_body: dict[str, Any] = {
|
|
556
|
+
"query": {
|
|
557
|
+
"neural": {
|
|
558
|
+
"memory_embedding": {
|
|
559
|
+
"query_text": query,
|
|
560
|
+
"model_id": self._config.embedding_model_id,
|
|
561
|
+
"k": limit,
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
"size": limit,
|
|
566
|
+
"_source": ["memory", "strategy_type", "namespace", "created_time"],
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# Add filters
|
|
570
|
+
filters = []
|
|
571
|
+
if namespace:
|
|
572
|
+
for key, value in namespace.values.items():
|
|
573
|
+
filters.append({"term": {f"namespace.{key}": value}})
|
|
574
|
+
if strategies:
|
|
575
|
+
filters.append({"terms": {"strategy_type": [s.value for s in strategies]}})
|
|
576
|
+
if after:
|
|
577
|
+
filters.append(
|
|
578
|
+
{"range": {"created_time": {"gte": int(after.timestamp() * 1000)}}}
|
|
579
|
+
)
|
|
580
|
+
if before:
|
|
581
|
+
filters.append(
|
|
582
|
+
{"range": {"created_time": {"lte": int(before.timestamp() * 1000)}}}
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if filters:
|
|
586
|
+
search_body["query"] = {
|
|
587
|
+
"bool": {
|
|
588
|
+
"must": [search_body["query"]],
|
|
589
|
+
"filter": filters,
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if min_score:
|
|
594
|
+
search_body["min_score"] = min_score
|
|
595
|
+
|
|
596
|
+
async with httpx.AsyncClient(
|
|
597
|
+
verify=self._config.verify_certs,
|
|
598
|
+
timeout=self._config.connect_timeout,
|
|
599
|
+
) as client:
|
|
600
|
+
response = await client.post(
|
|
601
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/long-term/_search",
|
|
602
|
+
json=search_body,
|
|
603
|
+
auth=self._auth,
|
|
604
|
+
)
|
|
605
|
+
response.raise_for_status()
|
|
606
|
+
result = response.json()
|
|
607
|
+
|
|
608
|
+
items = []
|
|
609
|
+
for hit in result.get("hits", {}).get("hits", []):
|
|
610
|
+
source = hit["_source"]
|
|
611
|
+
items.append(
|
|
612
|
+
MemoryEntry(
|
|
613
|
+
id=hit["_id"],
|
|
614
|
+
content=source.get("memory", ""), # CRITICAL: field is "memory"
|
|
615
|
+
strategy=MemoryStrategy(source["strategy_type"])
|
|
616
|
+
if source.get("strategy_type")
|
|
617
|
+
else None,
|
|
618
|
+
score=hit.get("_score", 0.0),
|
|
619
|
+
namespace=source.get("namespace", {}),
|
|
620
|
+
created_at=datetime.fromtimestamp(source["created_time"] / 1000)
|
|
621
|
+
if source.get("created_time")
|
|
622
|
+
else None,
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
return RecallResult(
|
|
627
|
+
items=items,
|
|
628
|
+
total=result.get("hits", {}).get("total", {}).get("value", len(items)),
|
|
629
|
+
query=query,
|
|
630
|
+
took_ms=result.get("took", 0),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
async def get_memory(
|
|
634
|
+
self,
|
|
635
|
+
container_id: str,
|
|
636
|
+
memory_id: str,
|
|
637
|
+
memory_type: MemoryType,
|
|
638
|
+
**options: Any,
|
|
639
|
+
) -> MemoryEntry | None:
|
|
640
|
+
"""Get a specific memory by ID.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
container_id: Container ID.
|
|
644
|
+
memory_id: Memory document ID.
|
|
645
|
+
memory_type: Memory type (WORKING or LONG_TERM).
|
|
646
|
+
**options: Backend-specific options.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Memory entry or None if not found.
|
|
650
|
+
|
|
651
|
+
Raises:
|
|
652
|
+
ValueError: If memory_type is SESSIONS or HISTORY.
|
|
653
|
+
"""
|
|
654
|
+
if memory_type == MemoryType.SESSIONS:
|
|
655
|
+
raise ValueError("Use get_session() for session retrieval")
|
|
656
|
+
if memory_type == MemoryType.HISTORY:
|
|
657
|
+
raise ValueError("Use get_history_entry() for history retrieval")
|
|
658
|
+
|
|
659
|
+
async with httpx.AsyncClient(
|
|
660
|
+
verify=self._config.verify_certs,
|
|
661
|
+
timeout=self._config.connect_timeout,
|
|
662
|
+
) as client:
|
|
663
|
+
response = await client.get(
|
|
664
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type.value}/{memory_id}",
|
|
665
|
+
auth=self._auth,
|
|
666
|
+
)
|
|
667
|
+
if response.status_code == 404:
|
|
668
|
+
return None
|
|
669
|
+
response.raise_for_status()
|
|
670
|
+
data = response.json()
|
|
671
|
+
|
|
672
|
+
return self._parse_memory_entry(memory_id, data, memory_type)
|
|
673
|
+
|
|
674
|
+
async def delete_memory(
|
|
675
|
+
self,
|
|
676
|
+
container_id: str,
|
|
677
|
+
memory_id: str,
|
|
678
|
+
memory_type: MemoryType,
|
|
679
|
+
**options: Any,
|
|
680
|
+
) -> bool:
|
|
681
|
+
"""Delete a specific memory by ID.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
container_id: Container ID.
|
|
685
|
+
memory_id: Memory document ID.
|
|
686
|
+
memory_type: Memory type (WORKING, LONG_TERM).
|
|
687
|
+
**options: Backend-specific options.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
True if deleted, False if not found.
|
|
691
|
+
"""
|
|
692
|
+
async with httpx.AsyncClient(
|
|
693
|
+
verify=self._config.verify_certs,
|
|
694
|
+
timeout=self._config.connect_timeout,
|
|
695
|
+
) as client:
|
|
696
|
+
response = await client.delete(
|
|
697
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type.value}/{memory_id}",
|
|
698
|
+
auth=self._auth,
|
|
699
|
+
)
|
|
700
|
+
if response.status_code == 404:
|
|
701
|
+
return False
|
|
702
|
+
response.raise_for_status()
|
|
703
|
+
|
|
704
|
+
return True
|
|
705
|
+
|
|
706
|
+
async def update_memory(
|
|
707
|
+
self,
|
|
708
|
+
container_id: str,
|
|
709
|
+
memory_id: str,
|
|
710
|
+
memory_type: MemoryType,
|
|
711
|
+
*,
|
|
712
|
+
memory: str | None = None,
|
|
713
|
+
tags: dict[str, str] | None = None,
|
|
714
|
+
**options: Any,
|
|
715
|
+
) -> MemoryEntry:
|
|
716
|
+
"""Update a specific memory.
|
|
717
|
+
|
|
718
|
+
Note: History memory type does NOT support updates.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
container_id: Container ID.
|
|
722
|
+
memory_id: Memory document ID.
|
|
723
|
+
memory_type: Memory type (working, long-term, sessions).
|
|
724
|
+
memory: Updated memory content (for long-term).
|
|
725
|
+
tags: Updated tags.
|
|
726
|
+
**options: Additional options.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
Updated memory entry.
|
|
730
|
+
|
|
731
|
+
Raises:
|
|
732
|
+
MemoryError: If trying to update history.
|
|
733
|
+
"""
|
|
734
|
+
if memory_type == MemoryType.HISTORY:
|
|
735
|
+
raise MemoryError("History memory does not support updates")
|
|
736
|
+
|
|
737
|
+
body: dict[str, Any] = {}
|
|
738
|
+
if memory is not None:
|
|
739
|
+
body["memory"] = memory
|
|
740
|
+
if tags is not None:
|
|
741
|
+
body["tags"] = tags
|
|
742
|
+
|
|
743
|
+
async with httpx.AsyncClient(
|
|
744
|
+
verify=self._config.verify_certs,
|
|
745
|
+
timeout=self._config.connect_timeout,
|
|
746
|
+
) as client:
|
|
747
|
+
response = await client.put(
|
|
748
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type.value}/{memory_id}",
|
|
749
|
+
json=body,
|
|
750
|
+
auth=self._auth,
|
|
751
|
+
)
|
|
752
|
+
response.raise_for_status()
|
|
753
|
+
data = response.json()
|
|
754
|
+
|
|
755
|
+
return MemoryEntry(
|
|
756
|
+
id=memory_id,
|
|
757
|
+
content=data.get("memory", ""),
|
|
758
|
+
strategy=MemoryStrategy(data["strategy_type"])
|
|
759
|
+
if data.get("strategy_type")
|
|
760
|
+
else None,
|
|
761
|
+
namespace=data.get("namespace", {}),
|
|
762
|
+
metadata=data.get("tags", {}),
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
async def delete_memories(
|
|
766
|
+
self,
|
|
767
|
+
container_id: str,
|
|
768
|
+
session_id: str | None = None,
|
|
769
|
+
namespace: Namespace | None = None,
|
|
770
|
+
before: datetime | None = None,
|
|
771
|
+
**options: Any,
|
|
772
|
+
) -> int:
|
|
773
|
+
"""Delete memories by filter.
|
|
774
|
+
|
|
775
|
+
This is a convenience wrapper around delete_by_query.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
container_id: Container ID.
|
|
779
|
+
session_id: Filter by session.
|
|
780
|
+
namespace: Filter by namespace.
|
|
781
|
+
before: Delete memories created before this timestamp.
|
|
782
|
+
**options: Additional options (memory_type defaults to WORKING).
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
Number of memories deleted.
|
|
786
|
+
"""
|
|
787
|
+
# Build query from filters
|
|
788
|
+
filters = []
|
|
789
|
+
|
|
790
|
+
if session_id:
|
|
791
|
+
filters.append({"term": {"session_id": session_id}})
|
|
792
|
+
if namespace:
|
|
793
|
+
for key, value in namespace.values.items():
|
|
794
|
+
filters.append({"term": {f"namespace.{key}": value}})
|
|
795
|
+
if before:
|
|
796
|
+
filters.append(
|
|
797
|
+
{"range": {"created_time": {"lt": int(before.timestamp() * 1000)}}}
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
if filters:
|
|
801
|
+
query: dict[str, Any] = {"bool": {"filter": filters}}
|
|
802
|
+
else:
|
|
803
|
+
query = {"match_all": {}}
|
|
804
|
+
|
|
805
|
+
# Default to working memory if not specified
|
|
806
|
+
memory_type = options.pop("memory_type", MemoryType.WORKING)
|
|
807
|
+
|
|
808
|
+
return await self.delete_by_query(
|
|
809
|
+
container_id=container_id,
|
|
810
|
+
memory_type=memory_type,
|
|
811
|
+
query=query,
|
|
812
|
+
**options,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# === Session Management (Phase 6) ===
|
|
816
|
+
|
|
817
|
+
async def create_session(
|
|
818
|
+
self,
|
|
819
|
+
container_id: str,
|
|
820
|
+
*,
|
|
821
|
+
session_id: str | None = None,
|
|
822
|
+
summary: str | None = None,
|
|
823
|
+
namespace: Namespace | None = None,
|
|
824
|
+
metadata: dict[str, Any] | None = None,
|
|
825
|
+
**options: Any,
|
|
826
|
+
) -> SessionInfo:
|
|
827
|
+
"""Create a new session.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
container_id: Container ID.
|
|
831
|
+
session_id: Custom session ID (auto-generated if not provided).
|
|
832
|
+
summary: Session summary text.
|
|
833
|
+
namespace: Session namespace.
|
|
834
|
+
metadata: Custom metadata (stored as additional_info).
|
|
835
|
+
**options: Additional options.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Created session info.
|
|
839
|
+
"""
|
|
840
|
+
body: dict[str, Any] = {}
|
|
841
|
+
if session_id:
|
|
842
|
+
body["session_id"] = session_id
|
|
843
|
+
if summary:
|
|
844
|
+
body["summary"] = summary
|
|
845
|
+
if namespace:
|
|
846
|
+
body["namespace"] = namespace.values
|
|
847
|
+
if metadata:
|
|
848
|
+
body["additional_info"] = metadata
|
|
849
|
+
|
|
850
|
+
async with httpx.AsyncClient(
|
|
851
|
+
verify=self._config.verify_certs,
|
|
852
|
+
timeout=self._config.connect_timeout,
|
|
853
|
+
) as client:
|
|
854
|
+
response = await client.post(
|
|
855
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/sessions",
|
|
856
|
+
json=body,
|
|
857
|
+
auth=self._auth,
|
|
858
|
+
)
|
|
859
|
+
response.raise_for_status()
|
|
860
|
+
result = response.json()
|
|
861
|
+
|
|
862
|
+
return SessionInfo(
|
|
863
|
+
id=result.get("session_id"),
|
|
864
|
+
container_id=container_id,
|
|
865
|
+
summary=summary,
|
|
866
|
+
namespace=namespace.values if namespace else {},
|
|
867
|
+
metadata=metadata or {},
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
async def get_session(
|
|
871
|
+
self,
|
|
872
|
+
container_id: str,
|
|
873
|
+
session_id: str,
|
|
874
|
+
include_messages: bool = False,
|
|
875
|
+
message_limit: int = 50,
|
|
876
|
+
**options: Any,
|
|
877
|
+
) -> SessionInfo | None:
|
|
878
|
+
"""Get session by ID.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
container_id: Container ID.
|
|
882
|
+
session_id: Session ID.
|
|
883
|
+
include_messages: Whether to include session messages.
|
|
884
|
+
message_limit: Max messages to include.
|
|
885
|
+
**options: Additional options.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
Session info or None if not found.
|
|
889
|
+
"""
|
|
890
|
+
async with httpx.AsyncClient(
|
|
891
|
+
verify=self._config.verify_certs,
|
|
892
|
+
timeout=self._config.connect_timeout,
|
|
893
|
+
) as client:
|
|
894
|
+
response = await client.get(
|
|
895
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/sessions/{session_id}",
|
|
896
|
+
auth=self._auth,
|
|
897
|
+
)
|
|
898
|
+
if response.status_code == 404:
|
|
899
|
+
return None
|
|
900
|
+
response.raise_for_status()
|
|
901
|
+
data = response.json()
|
|
902
|
+
|
|
903
|
+
session = SessionInfo(
|
|
904
|
+
id=session_id,
|
|
905
|
+
container_id=container_id,
|
|
906
|
+
summary=data.get("summary"),
|
|
907
|
+
namespace=data.get("namespace", {}),
|
|
908
|
+
metadata=data.get("additional_info", {}),
|
|
909
|
+
started_at=datetime.fromtimestamp(data["created_time"] / 1000)
|
|
910
|
+
if data.get("created_time")
|
|
911
|
+
else None,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if include_messages:
|
|
915
|
+
session.messages = await self.get_working_memory(
|
|
916
|
+
container_id=container_id,
|
|
917
|
+
session_id=session_id,
|
|
918
|
+
limit=message_limit,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
return session
|
|
922
|
+
|
|
923
|
+
async def list_sessions(
|
|
924
|
+
self,
|
|
925
|
+
container_id: str,
|
|
926
|
+
namespace: Namespace | None = None,
|
|
927
|
+
limit: int = 100,
|
|
928
|
+
**options: Any,
|
|
929
|
+
) -> list[SessionInfo]:
|
|
930
|
+
"""List sessions.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
container_id: Container ID.
|
|
934
|
+
namespace: Optional namespace filter.
|
|
935
|
+
limit: Maximum sessions to return.
|
|
936
|
+
**options: Additional options.
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
List of session info.
|
|
940
|
+
"""
|
|
941
|
+
search_body: dict[str, Any] = {
|
|
942
|
+
"query": {"match_all": {}},
|
|
943
|
+
"size": limit,
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if namespace:
|
|
947
|
+
filters = [
|
|
948
|
+
{"term": {f"namespace.{k}": v}} for k, v in namespace.values.items()
|
|
949
|
+
]
|
|
950
|
+
search_body["query"] = {"bool": {"filter": filters}}
|
|
951
|
+
|
|
952
|
+
async with httpx.AsyncClient(
|
|
953
|
+
verify=self._config.verify_certs,
|
|
954
|
+
timeout=self._config.connect_timeout,
|
|
955
|
+
) as client:
|
|
956
|
+
response = await client.post(
|
|
957
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/sessions/_search",
|
|
958
|
+
json=search_body,
|
|
959
|
+
auth=self._auth,
|
|
960
|
+
)
|
|
961
|
+
# Handle 500 error when sessions index doesn't exist yet
|
|
962
|
+
# OpenSearch returns "index must not be null" when no sessions have been created
|
|
963
|
+
if response.status_code == 500:
|
|
964
|
+
try:
|
|
965
|
+
error_body = response.json()
|
|
966
|
+
error_reason = error_body.get("error", {}).get("reason", "")
|
|
967
|
+
if "index must not be null" in error_reason:
|
|
968
|
+
return []
|
|
969
|
+
except Exception:
|
|
970
|
+
pass
|
|
971
|
+
response.raise_for_status()
|
|
972
|
+
result = response.json()
|
|
973
|
+
|
|
974
|
+
sessions = []
|
|
975
|
+
for hit in result.get("hits", {}).get("hits", []):
|
|
976
|
+
source = hit["_source"]
|
|
977
|
+
sessions.append(
|
|
978
|
+
SessionInfo(
|
|
979
|
+
id=hit["_id"],
|
|
980
|
+
container_id=container_id,
|
|
981
|
+
summary=source.get("summary"),
|
|
982
|
+
namespace=source.get("namespace", {}),
|
|
983
|
+
metadata=source.get("additional_info", {}),
|
|
984
|
+
started_at=datetime.fromtimestamp(source["created_time"] / 1000)
|
|
985
|
+
if source.get("created_time")
|
|
986
|
+
else None,
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
return sessions
|
|
991
|
+
|
|
992
|
+
async def update_session(
|
|
993
|
+
self,
|
|
994
|
+
container_id: str,
|
|
995
|
+
session_id: str,
|
|
996
|
+
*,
|
|
997
|
+
summary: str | None = None,
|
|
998
|
+
metadata: dict[str, Any] | None = None,
|
|
999
|
+
**options: Any,
|
|
1000
|
+
) -> SessionInfo:
|
|
1001
|
+
"""Update a session.
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
container_id: Container ID.
|
|
1005
|
+
session_id: Session ID.
|
|
1006
|
+
summary: Updated summary text.
|
|
1007
|
+
metadata: Updated metadata (additional_info).
|
|
1008
|
+
**options: Additional options.
|
|
1009
|
+
|
|
1010
|
+
Returns:
|
|
1011
|
+
Updated session info.
|
|
1012
|
+
"""
|
|
1013
|
+
body: dict[str, Any] = {}
|
|
1014
|
+
if summary is not None:
|
|
1015
|
+
body["summary"] = summary
|
|
1016
|
+
if metadata is not None:
|
|
1017
|
+
body["additional_info"] = metadata
|
|
1018
|
+
|
|
1019
|
+
async with httpx.AsyncClient(
|
|
1020
|
+
verify=self._config.verify_certs,
|
|
1021
|
+
timeout=self._config.connect_timeout,
|
|
1022
|
+
) as client:
|
|
1023
|
+
response = await client.put(
|
|
1024
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/sessions/{session_id}",
|
|
1025
|
+
json=body,
|
|
1026
|
+
auth=self._auth,
|
|
1027
|
+
)
|
|
1028
|
+
response.raise_for_status()
|
|
1029
|
+
data = response.json()
|
|
1030
|
+
|
|
1031
|
+
return SessionInfo(
|
|
1032
|
+
id=session_id,
|
|
1033
|
+
container_id=container_id,
|
|
1034
|
+
summary=data.get("summary"),
|
|
1035
|
+
namespace=data.get("namespace", {}),
|
|
1036
|
+
metadata=data.get("additional_info", {}),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
async def delete_session(
|
|
1040
|
+
self,
|
|
1041
|
+
container_id: str,
|
|
1042
|
+
session_id: str,
|
|
1043
|
+
**options: Any,
|
|
1044
|
+
) -> bool:
|
|
1045
|
+
"""Delete a session.
|
|
1046
|
+
|
|
1047
|
+
Args:
|
|
1048
|
+
container_id: Container ID.
|
|
1049
|
+
session_id: Session ID.
|
|
1050
|
+
**options: Additional options.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
True if deleted, False if not found.
|
|
1054
|
+
"""
|
|
1055
|
+
async with httpx.AsyncClient(
|
|
1056
|
+
verify=self._config.verify_certs,
|
|
1057
|
+
timeout=self._config.connect_timeout,
|
|
1058
|
+
) as client:
|
|
1059
|
+
response = await client.delete(
|
|
1060
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/sessions/{session_id}",
|
|
1061
|
+
auth=self._auth,
|
|
1062
|
+
)
|
|
1063
|
+
if response.status_code == 404:
|
|
1064
|
+
return False
|
|
1065
|
+
response.raise_for_status()
|
|
1066
|
+
|
|
1067
|
+
return True
|
|
1068
|
+
|
|
1069
|
+
# === History (Audit Trail - Phase 6) ===
|
|
1070
|
+
|
|
1071
|
+
async def get_history_entry(
|
|
1072
|
+
self,
|
|
1073
|
+
container_id: str,
|
|
1074
|
+
history_id: str,
|
|
1075
|
+
**options: Any,
|
|
1076
|
+
) -> HistoryEntry | None:
|
|
1077
|
+
"""Get a specific history entry by ID.
|
|
1078
|
+
|
|
1079
|
+
History entries are READ-ONLY audit trail records.
|
|
1080
|
+
|
|
1081
|
+
Args:
|
|
1082
|
+
container_id: Container ID.
|
|
1083
|
+
history_id: History entry ID.
|
|
1084
|
+
**options: Additional options.
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
History entry or None if not found.
|
|
1088
|
+
"""
|
|
1089
|
+
async with httpx.AsyncClient(
|
|
1090
|
+
verify=self._config.verify_certs,
|
|
1091
|
+
timeout=self._config.connect_timeout,
|
|
1092
|
+
) as client:
|
|
1093
|
+
response = await client.get(
|
|
1094
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/history/{history_id}",
|
|
1095
|
+
auth=self._auth,
|
|
1096
|
+
)
|
|
1097
|
+
if response.status_code == 404:
|
|
1098
|
+
return None
|
|
1099
|
+
response.raise_for_status()
|
|
1100
|
+
data = response.json()
|
|
1101
|
+
|
|
1102
|
+
return self._parse_history_entry(history_id, container_id, data)
|
|
1103
|
+
|
|
1104
|
+
async def list_history(
|
|
1105
|
+
self,
|
|
1106
|
+
container_id: str,
|
|
1107
|
+
memory_id: str | None = None,
|
|
1108
|
+
namespace: Namespace | None = None,
|
|
1109
|
+
limit: int = 100,
|
|
1110
|
+
**options: Any,
|
|
1111
|
+
) -> list[HistoryEntry]:
|
|
1112
|
+
"""List history entries.
|
|
1113
|
+
|
|
1114
|
+
Args:
|
|
1115
|
+
container_id: Container ID.
|
|
1116
|
+
memory_id: Filter by specific memory ID.
|
|
1117
|
+
namespace: Filter by namespace.
|
|
1118
|
+
limit: Maximum entries to return.
|
|
1119
|
+
**options: Additional options.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
List of history entries (most recent first).
|
|
1123
|
+
"""
|
|
1124
|
+
search_body: dict[str, Any] = {
|
|
1125
|
+
"query": {"match_all": {}},
|
|
1126
|
+
"size": limit,
|
|
1127
|
+
"sort": [{"created_time": "desc"}],
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
filters = []
|
|
1131
|
+
if memory_id:
|
|
1132
|
+
filters.append({"term": {"memory_id": memory_id}})
|
|
1133
|
+
if namespace:
|
|
1134
|
+
for key, value in namespace.values.items():
|
|
1135
|
+
filters.append({"term": {f"namespace.{key}": value}})
|
|
1136
|
+
|
|
1137
|
+
if filters:
|
|
1138
|
+
search_body["query"] = {"bool": {"filter": filters}}
|
|
1139
|
+
|
|
1140
|
+
async with httpx.AsyncClient(
|
|
1141
|
+
verify=self._config.verify_certs,
|
|
1142
|
+
timeout=self._config.connect_timeout,
|
|
1143
|
+
) as client:
|
|
1144
|
+
response = await client.post(
|
|
1145
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/history/_search",
|
|
1146
|
+
json=search_body,
|
|
1147
|
+
auth=self._auth,
|
|
1148
|
+
)
|
|
1149
|
+
response.raise_for_status()
|
|
1150
|
+
result = response.json()
|
|
1151
|
+
|
|
1152
|
+
entries = []
|
|
1153
|
+
for hit in result.get("hits", {}).get("hits", []):
|
|
1154
|
+
entry = self._parse_history_entry(hit["_id"], container_id, hit["_source"])
|
|
1155
|
+
entries.append(entry)
|
|
1156
|
+
|
|
1157
|
+
return entries
|
|
1158
|
+
|
|
1159
|
+
# === Statistics (Phase 6) ===
|
|
1160
|
+
|
|
1161
|
+
async def get_stats(
|
|
1162
|
+
self,
|
|
1163
|
+
container_id: str,
|
|
1164
|
+
**options: Any,
|
|
1165
|
+
) -> MemoryStats:
|
|
1166
|
+
"""Get container statistics.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
container_id: Container ID.
|
|
1170
|
+
**options: Additional options.
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
Memory statistics for the container.
|
|
1174
|
+
|
|
1175
|
+
Raises:
|
|
1176
|
+
ContainerNotFoundError: If container doesn't exist.
|
|
1177
|
+
"""
|
|
1178
|
+
container = await self.get_container(container_id)
|
|
1179
|
+
if not container:
|
|
1180
|
+
raise ContainerNotFoundError(container_id=container_id)
|
|
1181
|
+
|
|
1182
|
+
# Count working memory
|
|
1183
|
+
working_count = await self._count_memories(container_id, MemoryType.WORKING)
|
|
1184
|
+
|
|
1185
|
+
# Count long-term memory
|
|
1186
|
+
long_term_count = await self._count_memories(container_id, MemoryType.LONG_TERM)
|
|
1187
|
+
|
|
1188
|
+
# Count sessions
|
|
1189
|
+
session_count = await self._count_memories(container_id, MemoryType.SESSIONS)
|
|
1190
|
+
|
|
1191
|
+
# Get strategy breakdown
|
|
1192
|
+
breakdown: dict[MemoryStrategy, int] = {}
|
|
1193
|
+
for strategy in MemoryStrategy:
|
|
1194
|
+
count = await self._count_memories(
|
|
1195
|
+
container_id,
|
|
1196
|
+
MemoryType.LONG_TERM,
|
|
1197
|
+
strategy=strategy,
|
|
1198
|
+
)
|
|
1199
|
+
if count > 0:
|
|
1200
|
+
breakdown[strategy] = count
|
|
1201
|
+
|
|
1202
|
+
return MemoryStats(
|
|
1203
|
+
container_id=container_id,
|
|
1204
|
+
container_name=container.name,
|
|
1205
|
+
working_memory_count=working_count,
|
|
1206
|
+
long_term_memory_count=long_term_count,
|
|
1207
|
+
session_count=session_count,
|
|
1208
|
+
strategies_breakdown=breakdown,
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
async def _count_memories(
|
|
1212
|
+
self,
|
|
1213
|
+
container_id: str,
|
|
1214
|
+
memory_type: MemoryType,
|
|
1215
|
+
strategy: MemoryStrategy | None = None,
|
|
1216
|
+
) -> int:
|
|
1217
|
+
"""Count memories by type and optional strategy.
|
|
1218
|
+
|
|
1219
|
+
Args:
|
|
1220
|
+
container_id: Container ID.
|
|
1221
|
+
memory_type: Memory type to count.
|
|
1222
|
+
strategy: Optional strategy filter.
|
|
1223
|
+
|
|
1224
|
+
Returns:
|
|
1225
|
+
Number of memories matching criteria.
|
|
1226
|
+
"""
|
|
1227
|
+
search_body: dict[str, Any] = {
|
|
1228
|
+
"query": {"match_all": {}},
|
|
1229
|
+
"size": 0,
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if strategy:
|
|
1233
|
+
search_body["query"] = {"term": {"strategy_type": strategy.value}}
|
|
1234
|
+
|
|
1235
|
+
async with httpx.AsyncClient(
|
|
1236
|
+
verify=self._config.verify_certs,
|
|
1237
|
+
timeout=self._config.connect_timeout,
|
|
1238
|
+
) as client:
|
|
1239
|
+
response = await client.post(
|
|
1240
|
+
f"{self._base_url}/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type.value}/_search",
|
|
1241
|
+
json=search_body,
|
|
1242
|
+
auth=self._auth,
|
|
1243
|
+
)
|
|
1244
|
+
if response.status_code == 404:
|
|
1245
|
+
return 0
|
|
1246
|
+
# Handle 500 error when sessions index doesn't exist yet
|
|
1247
|
+
# OpenSearch returns "index must not be null" when no sessions have been created
|
|
1248
|
+
if response.status_code == 500:
|
|
1249
|
+
try:
|
|
1250
|
+
error_body = response.json()
|
|
1251
|
+
error_reason = error_body.get("error", {}).get("reason", "")
|
|
1252
|
+
if "index must not be null" in error_reason:
|
|
1253
|
+
return 0
|
|
1254
|
+
except Exception:
|
|
1255
|
+
pass
|
|
1256
|
+
response.raise_for_status()
|
|
1257
|
+
result = response.json()
|
|
1258
|
+
|
|
1259
|
+
return result.get("hits", {}).get("total", {}).get("value", 0)
|
|
1260
|
+
|
|
1261
|
+
# === Helpers ===
|
|
1262
|
+
|
|
1263
|
+
def _parse_container_info(
|
|
1264
|
+
self, container_id: str, data: dict[str, Any]
|
|
1265
|
+
) -> ContainerInfo:
|
|
1266
|
+
"""Parse container info from API response.
|
|
1267
|
+
|
|
1268
|
+
Args:
|
|
1269
|
+
container_id: Container ID.
|
|
1270
|
+
data: Raw API response data.
|
|
1271
|
+
|
|
1272
|
+
Returns:
|
|
1273
|
+
Parsed ContainerInfo object.
|
|
1274
|
+
"""
|
|
1275
|
+
config = data.get("configuration", {})
|
|
1276
|
+
strategies = [
|
|
1277
|
+
MemoryStrategy(s["type"]) for s in config.get("strategies", [])
|
|
1278
|
+
]
|
|
1279
|
+
|
|
1280
|
+
return ContainerInfo(
|
|
1281
|
+
id=container_id,
|
|
1282
|
+
name=data.get("name", ""),
|
|
1283
|
+
description=data.get("description"),
|
|
1284
|
+
strategies=strategies,
|
|
1285
|
+
embedding_model_id=config.get("embedding_model_id"),
|
|
1286
|
+
llm_model_id=config.get("llm_id"),
|
|
1287
|
+
created_at=datetime.fromtimestamp(data["created_time"] / 1000)
|
|
1288
|
+
if data.get("created_time")
|
|
1289
|
+
else None,
|
|
1290
|
+
updated_at=datetime.fromtimestamp(data["last_updated_time"] / 1000)
|
|
1291
|
+
if data.get("last_updated_time")
|
|
1292
|
+
else None,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
def _parse_memory_entry(
|
|
1296
|
+
self,
|
|
1297
|
+
memory_id: str,
|
|
1298
|
+
data: dict[str, Any],
|
|
1299
|
+
memory_type: MemoryType,
|
|
1300
|
+
) -> MemoryEntry:
|
|
1301
|
+
"""Parse memory entry from API response.
|
|
1302
|
+
|
|
1303
|
+
Handles both working memory and long-term memory formats.
|
|
1304
|
+
CRITICAL: Long-term memory content is in 'memory' field, not 'content'.
|
|
1305
|
+
|
|
1306
|
+
Args:
|
|
1307
|
+
memory_id: Memory document ID.
|
|
1308
|
+
data: Raw API response data.
|
|
1309
|
+
memory_type: Type of memory being parsed.
|
|
1310
|
+
|
|
1311
|
+
Returns:
|
|
1312
|
+
Parsed MemoryEntry object.
|
|
1313
|
+
"""
|
|
1314
|
+
# Long-term memory has 'memory' field with extracted content
|
|
1315
|
+
# Working memory has 'messages' array with conversation
|
|
1316
|
+
if memory_type == MemoryType.LONG_TERM:
|
|
1317
|
+
content = data.get("memory", "")
|
|
1318
|
+
strategy = (
|
|
1319
|
+
MemoryStrategy(data["strategy_type"])
|
|
1320
|
+
if data.get("strategy_type")
|
|
1321
|
+
else None
|
|
1322
|
+
)
|
|
1323
|
+
else:
|
|
1324
|
+
# Working memory: extract text from messages
|
|
1325
|
+
messages = data.get("messages", [])
|
|
1326
|
+
content_parts = []
|
|
1327
|
+
for msg in messages:
|
|
1328
|
+
msg_content = msg.get("content", [])
|
|
1329
|
+
for part in msg_content:
|
|
1330
|
+
if part.get("type") == "text":
|
|
1331
|
+
content_parts.append(
|
|
1332
|
+
f"[{msg.get('role', 'unknown')}]: {part.get('text', '')}"
|
|
1333
|
+
)
|
|
1334
|
+
content = "\n".join(content_parts)
|
|
1335
|
+
strategy = None
|
|
1336
|
+
|
|
1337
|
+
return MemoryEntry(
|
|
1338
|
+
id=memory_id,
|
|
1339
|
+
content=content,
|
|
1340
|
+
strategy=strategy,
|
|
1341
|
+
score=0.0,
|
|
1342
|
+
namespace=data.get("namespace", {}),
|
|
1343
|
+
created_at=datetime.fromtimestamp(data["created_time"] / 1000)
|
|
1344
|
+
if data.get("created_time")
|
|
1345
|
+
else None,
|
|
1346
|
+
metadata=data.get("tags", {}),
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
def _parse_history_entry(
|
|
1350
|
+
self,
|
|
1351
|
+
history_id: str,
|
|
1352
|
+
container_id: str,
|
|
1353
|
+
data: dict[str, Any],
|
|
1354
|
+
) -> HistoryEntry:
|
|
1355
|
+
"""Parse history entry from API response.
|
|
1356
|
+
|
|
1357
|
+
Args:
|
|
1358
|
+
history_id: History entry ID.
|
|
1359
|
+
container_id: Parent container ID.
|
|
1360
|
+
data: Raw API response data.
|
|
1361
|
+
|
|
1362
|
+
Returns:
|
|
1363
|
+
Parsed HistoryEntry object.
|
|
1364
|
+
"""
|
|
1365
|
+
return HistoryEntry(
|
|
1366
|
+
id=history_id,
|
|
1367
|
+
memory_id=data.get("memory_id", ""),
|
|
1368
|
+
container_id=container_id,
|
|
1369
|
+
action=HistoryAction(data["action"])
|
|
1370
|
+
if data.get("action")
|
|
1371
|
+
else HistoryAction.ADD,
|
|
1372
|
+
owner_id=data.get("owner_id"),
|
|
1373
|
+
before=data.get("before"),
|
|
1374
|
+
after=data.get("after"),
|
|
1375
|
+
namespace=data.get("namespace", {}),
|
|
1376
|
+
tags=data.get("tags", {}),
|
|
1377
|
+
created_at=datetime.fromtimestamp(data["created_time"] / 1000)
|
|
1378
|
+
if data.get("created_time")
|
|
1379
|
+
else None,
|
|
1380
|
+
)
|