agent-restore-harness/scripts/ingest/memory_api_fix.py
Mariusz Kreft dcd1241a94 feat: ingestion pipeline + memory-api bug fix + nasr context
Ingestion scripts:
- scripts/ingest/telegram_export.py (stdlib only, from spark3)
- scripts/ingest/push_to_memory.py (JSONL → memory API, importance scoring)
- scripts/ingest/memory_api_fix.py (memory.py ownership-aware close fix)

memory-api bug fixed (deployed to running container):
- memory.py: AgentMemory.close() was closing shared _qdrant client
- Fix: track ownership (_owns_qdrant/embeddings/redis) — only close what we created
- main.py: qdrant_client= → qdrant= parameter name fix

agents/nasr/MEMORY.md:
- Added GMI clinic context (machine.machine.zip ingested 2026-04-22)
- Added Hermes agent note (Mariusz to give details)
- Added active projects and skills inventory

telegram/:
- machine.machine.zip (572KB — MM group chat, GMI context)
- manifest.json (checksums for all zips)
- .gitignore (large zips excluded: m2.zip, parlobyg.zip, MuhlAI.zip)
2026-04-22 23:59:52 +02:00

650 lines
21 KiB
Python

"""Main Agent Memory class - the primary interface."""
import logging
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
import redis.asyncio as redis
from qdrant_client import AsyncQdrantClient, models
from qdrant_client.models import (
NamedSparseVector,
NamedVector,
PointStruct,
SparseVector,
)
from config import get_settings
from embeddings import BGEm3Client
from hybrid_search import HybridSearcher
from models import (
Memory,
MemoryCreate,
MemorySource,
MemoryType,
SearchQuery,
SearchResult,
)
from routing.router import QueryRouter
from routing.stats_store import RoutingStatsStore
logger = logging.getLogger(__name__)
class AgentMemory:
"""
Main interface for agent memory operations.
Provides:
- Store memories with automatic embedding
- Hybrid search (dense + sparse)
- Time-based retrieval
- Importance decay
- Memory consolidation
"""
def __init__(
self,
agent_id: str,
qdrant: AsyncQdrantClient | None = None,
embedding_client: BGEm3Client | None = None,
redis_client: redis.Redis | None = None,
):
self.agent_id = agent_id
self.settings = get_settings()
# Initialize clients
self._qdrant = qdrant
self._embeddings = embedding_client
self._redis = redis_client
self._searcher: HybridSearcher | None = None
self._router: QueryRouter | None = None
# Only close clients this instance created — not externally-shared ones
self._owns_qdrant = qdrant is None
self._owns_embeddings = embedding_client is None
self._owns_redis = redis_client is None
self._initialized = False
async def _ensure_initialized(self):
"""Lazy initialization of clients."""
if self._initialized:
return
# Qdrant client
if self._qdrant is None:
self._qdrant = AsyncQdrantClient(
url=self.settings.qdrant_url,
api_key=self.settings.qdrant_api_key,
)
# Redis client (optional)
if self._redis is None and self.settings.cache_embeddings:
try:
self._redis = redis.from_url(self.settings.redis_url)
await self._redis.ping()
except Exception as e:
logger.warning(f"Redis connection failed, caching disabled: {e}")
self._redis = None
# Embedding client
if self._embeddings is None:
self._embeddings = BGEm3Client(redis_client=self._redis)
# Hybrid searcher
self._searcher = HybridSearcher(
qdrant=self._qdrant,
embedding_client=self._embeddings,
)
# Query router (M4 smart routing)
stats_store = None
if self._redis:
stats_store = RoutingStatsStore(self._redis)
self._router = QueryRouter(
qdrant=self._qdrant,
embedding_client=self._embeddings,
stats_store=stats_store,
)
self._initialized = True
async def close(self):
"""Close connections owned by this instance (not externally-shared clients)."""
if self._embeddings and self._owns_embeddings:
await self._embeddings.close()
if self._redis and self._owns_redis:
await self._redis.close()
if self._qdrant and self._owns_qdrant:
await self._qdrant.close()
async def __aenter__(self):
await self._ensure_initialized()
return self
async def __aexit__(self, *args):
await self.close()
# =========================================================================
# STORE OPERATIONS
# =========================================================================
async def store(
self,
content: str,
memory_type: MemoryType = MemoryType.EPISODIC,
importance: float | None = None,
source: MemorySource = MemorySource.CONVERSATION,
entities: list[str] | None = None,
session_id: str | None = None,
user_id: str | None = None,
language: str = "en",
metadata: dict[str, Any] | None = None,
) -> Memory:
"""
Store a new memory with automatic embedding.
Args:
content: The memory content
memory_type: Type of memory (episodic, semantic, working)
importance: Importance score (0-1), defaults to settings default
source: Source of the memory
entities: Extracted entities
session_id: Session ID for episodic memories
user_id: Associated user ID
language: ISO language code
metadata: Additional metadata
Returns:
The created Memory object
"""
await self._ensure_initialized()
# Create memory object
memory = Memory(
content=content,
memory_type=memory_type,
agent_id=self.agent_id,
session_id=session_id,
user_id=user_id,
importance=importance or self.settings.default_importance,
initial_importance=importance or self.settings.default_importance,
source=source,
entities=entities or [],
language=language,
metadata=metadata or {},
)
# Episodic memories start unconsolidated
if memory_type == MemoryType.EPISODIC:
memory.consolidated = False
# Generate embeddings
embedding_response = await self._embeddings.embed_full(content)
memory.has_dense = embedding_response.dense is not None
memory.has_sparse = embedding_response.sparse is not None
memory.has_colbert = embedding_response.colbert is not None
# Build vectors dict for Qdrant
vectors = {}
if embedding_response.dense:
vectors["dense"] = embedding_response.dense
# Prepare point
point = PointStruct(
id=str(memory.id),
vector=vectors,
payload=memory.model_dump(mode="json"),
)
# If sparse embeddings, add as named sparse vector
if embedding_response.sparse:
sparse_indices = list(embedding_response.sparse.keys())
sparse_values = list(embedding_response.sparse.values())
# Qdrant requires sparse vectors in a specific format
point.vector["sparse"] = SparseVector(
indices=sparse_indices,
values=sparse_values,
)
# Upsert to Qdrant
await self._qdrant.upsert(
collection_name=self.settings.collection_name,
points=[point],
)
logger.info(f"Stored memory {memory.id} for agent {self.agent_id}")
return memory
async def store_batch(
self,
memories: list[MemoryCreate],
) -> list[Memory]:
"""Store multiple memories efficiently."""
await self._ensure_initialized()
if not memories:
return []
# Create memory objects
memory_objects = []
contents = []
for mem_create in memories:
memory = Memory(
content=mem_create.content,
memory_type=mem_create.memory_type,
agent_id=self.agent_id,
session_id=mem_create.session_id,
user_id=mem_create.user_id,
importance=mem_create.importance,
initial_importance=mem_create.importance,
source=mem_create.source,
entities=mem_create.entities,
language=mem_create.language,
metadata=mem_create.metadata,
)
# Episodic memories start unconsolidated
if mem_create.memory_type == MemoryType.EPISODIC:
memory.consolidated = False
memory_objects.append(memory)
contents.append(mem_create.content)
# Batch embed
embedding_responses = await self._embeddings.embed_batch(
contents,
include_sparse=True,
include_colbert=False,
)
# Build points
points = []
for memory, emb_response in zip(memory_objects, embedding_responses):
memory.has_dense = emb_response.dense is not None
memory.has_sparse = emb_response.sparse is not None
vectors = {}
if emb_response.dense:
vectors["dense"] = emb_response.dense
point = PointStruct(
id=str(memory.id),
vector=vectors,
payload=memory.model_dump(mode="json"),
)
if emb_response.sparse:
point.vector["sparse"] = SparseVector(
indices=list(emb_response.sparse.keys()),
values=list(emb_response.sparse.values()),
)
points.append(point)
# Batch upsert
await self._qdrant.upsert(
collection_name=self.settings.collection_name,
points=points,
)
logger.info(f"Stored {len(points)} memories for agent {self.agent_id}")
return memory_objects
# =========================================================================
# SEARCH OPERATIONS
# =========================================================================
async def search(
self,
query: str,
memory_types: list[MemoryType] | None = None,
limit: int = 10,
min_importance: float = 0.0,
session_id: str | None = None,
user_id: str | None = None,
after: datetime | None = None,
before: datetime | None = None,
routing_strategy: str | None = None,
) -> list[SearchResult]:
"""
Search memories using hybrid search.
Args:
query: Search query text
memory_types: Filter by memory types
limit: Maximum results
min_importance: Minimum importance threshold
session_id: Filter by session
user_id: Filter by user
after: Only memories after this time
before: Only memories before this time
routing_strategy: Smart routing strategy ('auto', 'lookup',
'standard', 'deep', 'synthesis'). None = legacy hybrid search.
Returns:
List of SearchResult with relevance scores
"""
await self._ensure_initialized()
# --- Smart routing path (M4) ---
if routing_strategy is not None:
routing_result = await self._router.route(
query=query,
agent_id=self.agent_id,
strategy=routing_strategy,
memory_types=memory_types,
limit=limit,
min_importance=min_importance,
session_id=session_id,
user_id=user_id,
after=after,
before=before,
)
results = routing_result.strategy_result.results
for result in results:
await self._update_access(result.memory.id)
return results
# --- Legacy hybrid search path (default, backward compat) ---
search_query = SearchQuery(
query=query,
memory_types=memory_types,
limit=limit,
min_importance=min_importance,
session_id=session_id,
user_id=user_id,
after=after,
before=before,
)
results = await self._searcher.search(search_query, self.agent_id)
# Update access count and last_accessed for retrieved memories
for result in results:
await self._update_access(result.memory.id)
return results
async def _update_access(self, memory_id: UUID):
"""Update access tracking for a memory."""
try:
await self._qdrant.set_payload(
collection_name=self.settings.collection_name,
payload={
"last_accessed": datetime.utcnow().isoformat(),
},
points=[str(memory_id)],
)
except Exception as e:
logger.warning(f"Failed to update access for {memory_id}: {e}")
# =========================================================================
# RETRIEVAL OPERATIONS
# =========================================================================
async def get(self, memory_id: UUID) -> Memory | None:
"""Get a specific memory by ID."""
await self._ensure_initialized()
results = await self._qdrant.retrieve(
collection_name=self.settings.collection_name,
ids=[str(memory_id)],
with_payload=True,
)
if results:
return Memory(**results[0].payload)
return None
async def get_recent(
self,
memory_type: MemoryType | None = None,
hours: int = 24,
limit: int = 50,
session_id: str | None = None,
) -> list[Memory]:
"""Get recent memories by time."""
await self._ensure_initialized()
# Build filter
must_conditions = [
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
models.FieldCondition(
key="timestamp",
range=models.Range(
gte=(datetime.utcnow() - timedelta(hours=hours)).timestamp(),
),
),
]
if memory_type:
must_conditions.append(
models.FieldCondition(
key="memory_type",
match=models.MatchValue(value=memory_type.value),
)
)
if session_id:
must_conditions.append(
models.FieldCondition(
key="session_id",
match=models.MatchValue(value=session_id),
)
)
# Scroll through results
results, _ = await self._qdrant.scroll(
collection_name=self.settings.collection_name,
scroll_filter=models.Filter(must=must_conditions),
limit=limit,
with_payload=True,
order_by=models.OrderBy(
key="timestamp",
direction=models.Direction.DESC,
),
)
return [Memory(**r.payload) for r in results]
async def get_by_entities(
self,
entities: list[str],
memory_type: MemoryType | None = None,
limit: int = 20,
) -> list[Memory]:
"""Get memories containing specific entities."""
await self._ensure_initialized()
must_conditions = [
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
]
# Match any of the entities
for entity in entities:
must_conditions.append(
models.FieldCondition(
key="entities",
match=models.MatchValue(value=entity),
)
)
if memory_type:
must_conditions.append(
models.FieldCondition(
key="memory_type",
match=models.MatchValue(value=memory_type.value),
)
)
results, _ = await self._qdrant.scroll(
collection_name=self.settings.collection_name,
scroll_filter=models.Filter(must=must_conditions),
limit=limit,
with_payload=True,
)
return [Memory(**r.payload) for r in results]
# =========================================================================
# UPDATE OPERATIONS
# =========================================================================
async def update_importance(
self,
memory_id: UUID,
importance: float,
):
"""Update importance score for a memory."""
await self._ensure_initialized()
await self._qdrant.set_payload(
collection_name=self.settings.collection_name,
payload={"importance": max(0.0, min(1.0, importance))},
points=[str(memory_id)],
)
async def reinforce(self, memory_id: UUID, boost: float = 0.1):
"""Reinforce a memory (increase importance)."""
memory = await self.get(memory_id)
if memory:
new_importance = min(1.0, memory.importance + boost)
await self.update_importance(memory_id, new_importance)
async def decay_importance(self, days_old: int = 7):
"""Apply importance decay to old memories."""
await self._ensure_initialized()
cutoff = datetime.utcnow() - timedelta(days=days_old)
# Find old memories
results, _ = await self._qdrant.scroll(
collection_name=self.settings.collection_name,
scroll_filter=models.Filter(
must=[
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
models.FieldCondition(
key="timestamp",
range=models.Range(lte=cutoff.timestamp()),
),
]
),
limit=1000,
with_payload=True,
)
decay_rate = self.settings.importance_decay_rate
for point in results:
memory = Memory(**point.payload)
days_since = (datetime.utcnow() - memory.timestamp).days
decayed = memory.initial_importance * (1 - decay_rate * days_since)
decayed = max(0.05, decayed) # Minimum importance
if abs(decayed - memory.importance) > 0.01:
await self.update_importance(memory.id, decayed)
# =========================================================================
# DELETE OPERATIONS
# =========================================================================
async def delete(self, memory_id: UUID) -> bool:
"""Delete a specific memory."""
await self._ensure_initialized()
await self._qdrant.delete(
collection_name=self.settings.collection_name,
points_selector=models.PointIdsList(
points=[str(memory_id)],
),
)
return True
async def delete_session(self, session_id: str) -> int:
"""Delete all memories from a session."""
await self._ensure_initialized()
result = await self._qdrant.delete(
collection_name=self.settings.collection_name,
points_selector=models.FilterSelector(
filter=models.Filter(
must=[
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
models.FieldCondition(
key="session_id",
match=models.MatchValue(value=session_id),
),
]
)
),
)
return result.status
async def clear_all(self):
"""Delete all memories for this agent. Use with caution!"""
await self._ensure_initialized()
await self._qdrant.delete(
collection_name=self.settings.collection_name,
points_selector=models.FilterSelector(
filter=models.Filter(
must=[
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
]
)
),
)
logger.warning(f"Cleared all memories for agent {self.agent_id}")
# =========================================================================
# STATS
# =========================================================================
async def count(
self,
memory_type: MemoryType | None = None,
) -> int:
"""Count memories for this agent."""
await self._ensure_initialized()
must_conditions = [
models.FieldCondition(
key="agent_id",
match=models.MatchValue(value=self.agent_id),
),
]
if memory_type:
must_conditions.append(
models.FieldCondition(
key="memory_type",
match=models.MatchValue(value=memory_type.value),
)
)
result = await self._qdrant.count(
collection_name=self.settings.collection_name,
count_filter=models.Filter(must=must_conditions),
)
return result.count