gnosisllm-knowledge 0.2.0__py3-none-any.whl → 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.
Files changed (44) hide show
  1. gnosisllm_knowledge/__init__.py +91 -39
  2. gnosisllm_knowledge/api/__init__.py +3 -2
  3. gnosisllm_knowledge/api/knowledge.py +287 -7
  4. gnosisllm_knowledge/api/memory.py +966 -0
  5. gnosisllm_knowledge/backends/__init__.py +14 -5
  6. gnosisllm_knowledge/backends/opensearch/agentic.py +341 -39
  7. gnosisllm_knowledge/backends/opensearch/config.py +49 -28
  8. gnosisllm_knowledge/backends/opensearch/indexer.py +1 -0
  9. gnosisllm_knowledge/backends/opensearch/mappings.py +2 -1
  10. gnosisllm_knowledge/backends/opensearch/memory/__init__.py +12 -0
  11. gnosisllm_knowledge/backends/opensearch/memory/client.py +1380 -0
  12. gnosisllm_knowledge/backends/opensearch/memory/config.py +127 -0
  13. gnosisllm_knowledge/backends/opensearch/memory/setup.py +322 -0
  14. gnosisllm_knowledge/backends/opensearch/searcher.py +235 -0
  15. gnosisllm_knowledge/backends/opensearch/setup.py +308 -148
  16. gnosisllm_knowledge/cli/app.py +378 -12
  17. gnosisllm_knowledge/cli/commands/agentic.py +11 -0
  18. gnosisllm_knowledge/cli/commands/memory.py +723 -0
  19. gnosisllm_knowledge/cli/commands/setup.py +24 -22
  20. gnosisllm_knowledge/cli/display/service.py +43 -0
  21. gnosisllm_knowledge/cli/utils/config.py +58 -0
  22. gnosisllm_knowledge/core/domain/__init__.py +41 -0
  23. gnosisllm_knowledge/core/domain/document.py +5 -0
  24. gnosisllm_knowledge/core/domain/memory.py +440 -0
  25. gnosisllm_knowledge/core/domain/result.py +11 -3
  26. gnosisllm_knowledge/core/domain/search.py +2 -0
  27. gnosisllm_knowledge/core/events/types.py +76 -0
  28. gnosisllm_knowledge/core/exceptions.py +134 -0
  29. gnosisllm_knowledge/core/interfaces/__init__.py +17 -0
  30. gnosisllm_knowledge/core/interfaces/memory.py +524 -0
  31. gnosisllm_knowledge/core/interfaces/streaming.py +127 -0
  32. gnosisllm_knowledge/core/streaming/__init__.py +36 -0
  33. gnosisllm_knowledge/core/streaming/pipeline.py +228 -0
  34. gnosisllm_knowledge/loaders/base.py +3 -4
  35. gnosisllm_knowledge/loaders/sitemap.py +129 -1
  36. gnosisllm_knowledge/loaders/sitemap_streaming.py +258 -0
  37. gnosisllm_knowledge/services/indexing.py +67 -75
  38. gnosisllm_knowledge/services/search.py +47 -11
  39. gnosisllm_knowledge/services/streaming_pipeline.py +302 -0
  40. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.3.0.dist-info}/METADATA +44 -1
  41. gnosisllm_knowledge-0.3.0.dist-info/RECORD +77 -0
  42. gnosisllm_knowledge-0.2.0.dist-info/RECORD +0 -64
  43. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.3.0.dist-info}/WHEEL +0 -0
  44. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.3.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
+ )