mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* security(context): harden Markdown import against TOCTOU symlink races Closes #856 * fix(context): harden markdown import security tests * docs(changelog): add entry for Markdown import TOCTOU symlink hardening Documents the (#932, closes #856) fix in the Unreleased/Fixed section. --------- Co-authored-by: Sameer Kadam <sskadam6305@gmail.com> Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2303 lines
80 KiB
Python
2303 lines
80 KiB
Python
"""
|
|
Agent Memory Manager
|
|
|
|
This module provides comprehensive agent memory management and context retrieval,
|
|
integrating RAG (Retrieval-Augmented Generation) with knowledge graphs to give
|
|
agents persistent context across conversations and interactions.
|
|
|
|
Algorithms Used:
|
|
|
|
Memory Storage:
|
|
- Vector Embedding: Embedding generation for memory items using embedding models
|
|
- Vector Indexing: Vector store indexing for efficient similarity search
|
|
- Memory Indexing: Deque-based memory index for efficient temporal access
|
|
- Knowledge Graph Integration: Entity and relationship updates to knowledge graph
|
|
- Metadata Storage: Dictionary-based metadata storage and retrieval
|
|
|
|
Memory Retrieval:
|
|
- Vector Similarity Search: Cosine similarity search in vector space
|
|
- Keyword Search: Fallback keyword-based search using word overlap
|
|
- Score Ranking: Relevance score-based result ranking
|
|
- Filter Matching: Metadata-based filtering (type, date range, etc.)
|
|
- Result Deduplication: Content-based deduplication of results
|
|
|
|
Memory Management:
|
|
- Retention Policy: Time-based memory retention and cleanup
|
|
- Memory Statistics: Counter-based statistics tracking
|
|
- Conversation History: Temporal-based conversation history retrieval
|
|
- Memory Deletion: Cascading deletion from vector store and memory index
|
|
|
|
Key Features:
|
|
- Persistent memory storage for agents
|
|
- Vector-based context retrieval with embedding support
|
|
- Knowledge graph context integration
|
|
- Conversation history management
|
|
- Context accumulation over time
|
|
- Memory retrieval for agent decision-making
|
|
- Retention policy management (time-based cleanup)
|
|
- Memory statistics and analytics
|
|
- Metadata-based filtering and search
|
|
- Fallback keyword search when vector store unavailable
|
|
|
|
Main Classes:
|
|
- MemoryItem: Memory item data structure with content, timestamp, metadata,
|
|
entities, relationships
|
|
- AgentMemory: Agent memory manager with RAG integration
|
|
|
|
Example Usage:
|
|
>>> from semantica.context import AgentMemory
|
|
>>> memory = AgentMemory(vector_store=vs, knowledge_graph=kg)
|
|
>>> memory_id = memory.store(
|
|
... "User asked about Python", metadata={"type": "conversation"}
|
|
... )
|
|
>>> results = memory.retrieve("Python", max_results=5)
|
|
>>> history = memory.get_conversation_history(conversation_id="conv_123")
|
|
>>> stats = memory.get_statistics()
|
|
|
|
Author: Semantica Contributors
|
|
License: MIT
|
|
"""
|
|
|
|
import copy
|
|
import errno
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
|
|
import numpy as np
|
|
import yaml
|
|
|
|
from ..utils.logging import get_logger
|
|
from ..utils.progress_tracker import get_progress_tracker
|
|
from ..utils.types import EntityDict, RelationshipDict
|
|
|
|
|
|
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
|
"""Safe YAML loader that rejects ambiguous duplicate mapping keys."""
|
|
|
|
|
|
def _construct_unique_mapping(
|
|
loader: _UniqueKeySafeLoader, node: yaml.MappingNode, deep: bool = False
|
|
) -> Dict[Any, Any]:
|
|
loader.flatten_mapping(node)
|
|
mapping = {}
|
|
for key_node, value_node in node.value:
|
|
key = loader.construct_object(key_node, deep=deep)
|
|
try:
|
|
duplicate = key in mapping
|
|
except TypeError as exc:
|
|
raise yaml.constructor.ConstructorError(
|
|
"while constructing a mapping",
|
|
node.start_mark,
|
|
"found an unhashable key",
|
|
key_node.start_mark,
|
|
) from exc
|
|
if duplicate:
|
|
raise yaml.constructor.ConstructorError(
|
|
"while constructing a mapping",
|
|
node.start_mark,
|
|
f"found duplicate key {key!r}",
|
|
key_node.start_mark,
|
|
)
|
|
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
return mapping
|
|
|
|
|
|
_UniqueKeySafeLoader.add_constructor(
|
|
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class MemoryItem:
|
|
"""Memory item structure."""
|
|
|
|
content: str
|
|
timestamp: datetime
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
entities: List[EntityDict] = field(default_factory=list)
|
|
relationships: List[RelationshipDict] = field(default_factory=list)
|
|
embedding: Optional[Any] = None
|
|
memory_id: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Serialise to a JSON-safe dict. Embeddings are dropped (not JSON-safe)."""
|
|
return {
|
|
"content": self.content,
|
|
"timestamp": self.timestamp.isoformat(),
|
|
"metadata": self.metadata,
|
|
"entities": self.entities,
|
|
"relationships": self.relationships,
|
|
"memory_id": self.memory_id,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "MemoryItem":
|
|
"""Reconstruct a MemoryItem from a serialised dict."""
|
|
raw_ts = data.get("timestamp")
|
|
try:
|
|
ts = datetime.fromisoformat(raw_ts) if raw_ts else datetime.utcnow()
|
|
except (ValueError, TypeError):
|
|
ts = datetime.utcnow()
|
|
return cls(
|
|
content=data.get("content", ""),
|
|
timestamp=ts,
|
|
metadata=data.get("metadata", {}),
|
|
entities=data.get("entities", []),
|
|
relationships=data.get("relationships", []),
|
|
embedding=None, # embeddings are not persisted; regenerate on demand
|
|
memory_id=data.get("memory_id"),
|
|
)
|
|
|
|
|
|
class AgentMemory:
|
|
"""
|
|
Agent memory manager with RAG integration and Hierarchical Memory.
|
|
|
|
• Short-term Memory: In-memory buffer for recent context
|
|
• Long-term Memory: Vector store for persistent semantic history
|
|
• Knowledge Graph: Structured context integration
|
|
"""
|
|
|
|
_MARKDOWN_RESERVED_FIELDS = frozenset(
|
|
{
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"type",
|
|
"kind",
|
|
"entities",
|
|
"relationships",
|
|
"metadata",
|
|
}
|
|
)
|
|
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown"})
|
|
|
|
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
"""
|
|
Initialize agent memory.
|
|
|
|
Args:
|
|
config: Configuration dictionary
|
|
**kwargs: Additional configuration options:
|
|
- vector_store: Vector store instance
|
|
- knowledge_graph: Knowledge graph instance
|
|
- retention_policy: Memory retention policy
|
|
- max_memory_size: Max items in memory index
|
|
- short_term_limit: Size of short-term memory buffer (default: 10)
|
|
"""
|
|
self.logger = get_logger("agent_memory")
|
|
self.config = config or {}
|
|
self.config.update(kwargs)
|
|
|
|
self.vector_store = self.config.get("vector_store")
|
|
self.knowledge_graph = self.config.get("knowledge_graph")
|
|
|
|
self.retention_policy = self.config.get("retention_policy", "unlimited")
|
|
self.max_memory_size = self.config.get("max_memory_size", 10000)
|
|
self.short_term_limit = self.config.get("short_term_limit", 10)
|
|
self.token_limit = self.config.get("token_limit", 2000)
|
|
|
|
# In-memory storage
|
|
self.memory_items: Dict[str, MemoryItem] = {}
|
|
self.memory_index: deque = deque(maxlen=self.max_memory_size)
|
|
self._vector_ids: Dict[str, List[str]] = {}
|
|
|
|
# Hierarchical Memory: Short-term buffer
|
|
# Note: We use a list for flexible pruning (tokens & count).
|
|
self.short_term_memory: List[MemoryItem] = []
|
|
|
|
# Initialize progress tracker
|
|
self.progress_tracker = get_progress_tracker()
|
|
# Ensure progress tracker is enabled
|
|
if not self.progress_tracker.enabled:
|
|
self.progress_tracker.enabled = True
|
|
|
|
# Statistics
|
|
self.stats = {"total_items": 0, "items_by_type": {}, "last_accessed": None}
|
|
|
|
def save(self, path: str) -> None:
|
|
"""
|
|
Save memory state to disk.
|
|
|
|
Args:
|
|
path: Directory path to save to
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
data = {
|
|
"memory_items": {k: v.to_dict() for k, v in self.memory_items.items()},
|
|
"memory_index": list(self.memory_index),
|
|
"short_term_memory": [item.to_dict() for item in self.short_term_memory],
|
|
"vector_ids": self._vector_ids,
|
|
"stats": self.stats,
|
|
}
|
|
|
|
with open(os.path.join(path, "agent_memory.json"), "w", encoding="utf-8") as f:
|
|
json.dump(data, f)
|
|
|
|
self.logger.info(f"Saved agent memory to {path}")
|
|
|
|
def load(self, path: str) -> None:
|
|
"""
|
|
Load memory state from disk.
|
|
|
|
Args:
|
|
path: Directory path to load from
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
# Support new JSON format; fall back to legacy filename only if it exists
|
|
json_path = os.path.join(path, "agent_memory.json")
|
|
legacy_path = os.path.join(path, "agent_memory.pkl")
|
|
|
|
if os.path.exists(json_path):
|
|
file_path = json_path
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
elif os.path.exists(legacy_path):
|
|
# Refuse legacy pickle files to prevent deserialization attacks.
|
|
# Users must re-save memory in the new JSON format.
|
|
self.logger.warning(
|
|
f"Legacy pickle file found at {legacy_path}. "
|
|
"Pickle loading is disabled for security. Re-save memory to migrate."
|
|
)
|
|
return
|
|
else:
|
|
self.logger.warning(f"Memory file not found in: {path}")
|
|
return
|
|
|
|
raw_items = data.get("memory_items", {})
|
|
self.memory_items = {k: MemoryItem.from_dict(v) for k, v in raw_items.items()}
|
|
raw_index = data.get("memory_index", [])
|
|
self.memory_index = deque(raw_index, maxlen=self.max_memory_size)
|
|
self.short_term_memory = [
|
|
MemoryItem.from_dict(item) for item in data.get("short_term_memory", [])
|
|
]
|
|
self._vector_ids = {
|
|
memory_id: list(vector_ids)
|
|
for memory_id, vector_ids in data.get("vector_ids", {}).items()
|
|
if isinstance(vector_ids, list)
|
|
}
|
|
self.stats = data.get(
|
|
"stats",
|
|
{"total_items": 0, "items_by_type": {}, "last_accessed": None},
|
|
)
|
|
|
|
self.logger.info(f"Loaded agent memory from {path}")
|
|
|
|
def store(
|
|
self,
|
|
content: str,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
entities: Optional[List[EntityDict]] = None,
|
|
relationships: Optional[List[RelationshipDict]] = None,
|
|
**options,
|
|
) -> str:
|
|
"""
|
|
Store memory item (Write-Through to Short-term and Long-term).
|
|
|
|
Args:
|
|
content: Memory content
|
|
metadata: Additional metadata
|
|
entities: Related entities
|
|
relationships: Related relationships
|
|
**options: Additional options:
|
|
- memory_id: Custom memory ID
|
|
- timestamp: Custom timestamp
|
|
- skip_vector: If True, skip vector store (Short-term only)
|
|
- skip_graph: If True, keep entities local to the memory item
|
|
|
|
Returns:
|
|
Memory ID
|
|
"""
|
|
# Track memory storage
|
|
tracking_id = self.progress_tracker.start_tracking(
|
|
file=None,
|
|
module="context",
|
|
submodule="AgentMemory",
|
|
message=f"Storing memory: {content[:50]}...",
|
|
)
|
|
|
|
try:
|
|
memory_id = options.get("memory_id") or self._generate_memory_id()
|
|
timestamp = options.get("timestamp") or datetime.now()
|
|
|
|
if memory_id in self.memory_items:
|
|
replacement_options = dict(options)
|
|
replacement_options.pop("memory_id", None)
|
|
replacement_options.pop("timestamp", None)
|
|
replaced = self._replace_memory_item(
|
|
memory_id,
|
|
content,
|
|
metadata=metadata or {},
|
|
entities=entities or [],
|
|
relationships=relationships or [],
|
|
timestamp=timestamp,
|
|
**replacement_options,
|
|
)
|
|
if not replaced:
|
|
raise RuntimeError(
|
|
f"Failed to replace existing memory item: {memory_id}"
|
|
)
|
|
self.progress_tracker.stop_tracking(
|
|
tracking_id,
|
|
status="completed",
|
|
message=f"Stored memory: {memory_id}",
|
|
)
|
|
return memory_id
|
|
|
|
# Create memory item
|
|
memory_item = MemoryItem(
|
|
content=content,
|
|
timestamp=timestamp,
|
|
metadata=metadata or {},
|
|
entities=entities or [],
|
|
relationships=relationships or [],
|
|
memory_id=memory_id,
|
|
)
|
|
|
|
# 1. Update Short-Term Memory
|
|
self.short_term_memory.append(memory_item)
|
|
self._prune_short_term_memory()
|
|
|
|
# 2. Update Long-Term Memory (Vector Store)
|
|
skip_vector = options.get("skip_vector", False)
|
|
if self.vector_store and not skip_vector:
|
|
try:
|
|
vector_ids = self._store_memory_vector(
|
|
memory_item, tracking_id=tracking_id
|
|
)
|
|
if vector_ids:
|
|
self._vector_ids[memory_id] = vector_ids
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to store in vector store: {e}")
|
|
|
|
# Store in main memory dict (Persistent Layer Abstraction)
|
|
self.memory_items[memory_id] = memory_item
|
|
self.memory_index.append(memory_id)
|
|
|
|
# 3. Update Knowledge Graph
|
|
skip_graph = options.get("skip_graph", False)
|
|
if self.knowledge_graph and entities and not skip_graph:
|
|
self.progress_tracker.update_tracking(
|
|
tracking_id, message="Updating knowledge graph..."
|
|
)
|
|
self._update_knowledge_graph(entities, relationships)
|
|
|
|
# Update statistics
|
|
self.stats["total_items"] += 1
|
|
item_type = metadata.get("type", "general") if metadata else "general"
|
|
self.stats["items_by_type"][item_type] = (
|
|
self.stats["items_by_type"].get(item_type, 0) + 1
|
|
)
|
|
|
|
self.logger.debug(f"Stored memory item: {memory_id}")
|
|
|
|
# Apply retention policy
|
|
self._apply_retention_policy(skip_vector=skip_vector)
|
|
|
|
self.progress_tracker.stop_tracking(
|
|
tracking_id, status="completed", message=f"Stored memory: {memory_id}"
|
|
)
|
|
return memory_id
|
|
|
|
except Exception as e:
|
|
self.progress_tracker.stop_tracking(
|
|
tracking_id, status="failed", message=str(e)
|
|
)
|
|
raise
|
|
|
|
def forget(self, memory_id: str) -> bool:
|
|
"""Forget a memory (Implementation of MemoryManager)."""
|
|
return self.delete_memory(memory_id)
|
|
|
|
def retrieve(
|
|
self, query: str, max_results: int = 5, min_score: float = 0.0, **filters
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Retrieve relevant memories.
|
|
|
|
Args:
|
|
query: Search query
|
|
max_results: Maximum number of results
|
|
min_score: Minimum relevance score
|
|
**filters: Additional filters:
|
|
- type: Filter by memory type
|
|
- start_date: Filter by start date
|
|
- end_date: Filter by end date
|
|
|
|
Returns:
|
|
List of retrieved memory items
|
|
"""
|
|
# Track memory retrieval
|
|
tracking_id = self.progress_tracker.start_tracking(
|
|
file=None,
|
|
module="context",
|
|
submodule="AgentMemory",
|
|
message=f"Retrieving memories for: {query[:50]}...",
|
|
)
|
|
|
|
try:
|
|
results = []
|
|
seen_ids = set()
|
|
|
|
# 1. Check Short-Term Memory (Recent Context)
|
|
short_term_results = self._search_short_term(query, filters)
|
|
for res in short_term_results:
|
|
if res["memory_id"] not in seen_ids:
|
|
results.append(res)
|
|
seen_ids.add(res["memory_id"])
|
|
|
|
# 2. Vector-based retrieval (Long-Term Memory)
|
|
if self.vector_store:
|
|
self.progress_tracker.update_tracking(
|
|
tracking_id, message="Searching vector store..."
|
|
)
|
|
try:
|
|
vector_results = []
|
|
if hasattr(self.vector_store, "search_vectors"):
|
|
# Use concrete VectorStore implementation
|
|
query_vector = self._generate_embedding(query)
|
|
if isinstance(query_vector, list):
|
|
query_vector = np.array(query_vector)
|
|
|
|
raw_results = self.vector_store.search_vectors(
|
|
query_vector=query_vector, k=max_results * 2
|
|
)
|
|
# Convert dict results to objects with .id attribute
|
|
|
|
class ResultObj:
|
|
def __init__(self, d):
|
|
self.id = d.get("id")
|
|
self.score = d.get("score")
|
|
self.metadata = d.get("metadata")
|
|
|
|
vector_results = [ResultObj(r) for r in raw_results]
|
|
|
|
elif hasattr(self.vector_store, "search"):
|
|
vector_results = self.vector_store.search(
|
|
query=query, limit=max_results * 2
|
|
)
|
|
|
|
for result in vector_results:
|
|
memory_id = result.id
|
|
|
|
# Skip if already found in short-term
|
|
if memory_id in seen_ids:
|
|
continue
|
|
|
|
if memory_id in self.memory_items:
|
|
memory_item = self.memory_items[memory_id]
|
|
|
|
# Apply filters
|
|
if not self._matches_filters(memory_item, filters):
|
|
continue
|
|
|
|
results.append(
|
|
{
|
|
"memory_id": memory_id,
|
|
"content": memory_item.content,
|
|
"score": result.score,
|
|
"timestamp": memory_item.timestamp.isoformat(),
|
|
"metadata": memory_item.metadata,
|
|
"entities": memory_item.entities,
|
|
"relationships": memory_item.relationships,
|
|
}
|
|
)
|
|
seen_ids.add(memory_id)
|
|
except Exception as e:
|
|
self.logger.warning(f"Vector retrieval failed: {e}")
|
|
|
|
# 3. Fallback to keyword search if no results yet
|
|
if not results:
|
|
self.progress_tracker.update_tracking(
|
|
tracking_id, message="Performing keyword search..."
|
|
)
|
|
results = self._keyword_search(query, max_results, filters)
|
|
|
|
# Sort by score and return top results
|
|
self.progress_tracker.update_tracking(
|
|
tracking_id, message="Ranking results..."
|
|
)
|
|
results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
|
|
filtered_results = [r for r in results if r.get("score", 0.0) >= min_score]
|
|
|
|
self.stats["last_accessed"] = datetime.now().isoformat()
|
|
|
|
self.progress_tracker.stop_tracking(
|
|
tracking_id,
|
|
status="completed",
|
|
message=f"Retrieved {len(filtered_results[:max_results])} memories",
|
|
)
|
|
return filtered_results[:max_results]
|
|
|
|
except Exception as e:
|
|
self.progress_tracker.stop_tracking(
|
|
tracking_id, status="failed", message=str(e)
|
|
)
|
|
raise
|
|
|
|
def get_memory(self, memory_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get specific memory item.
|
|
|
|
Args:
|
|
memory_id: Memory identifier
|
|
|
|
Returns:
|
|
Memory item dictionary or None if not found
|
|
"""
|
|
if memory_id not in self.memory_items:
|
|
return None
|
|
|
|
memory_item = self.memory_items[memory_id]
|
|
|
|
return {
|
|
"memory_id": memory_id,
|
|
"content": memory_item.content,
|
|
"timestamp": memory_item.timestamp.isoformat(),
|
|
"metadata": memory_item.metadata,
|
|
"entities": memory_item.entities,
|
|
"relationships": memory_item.relationships,
|
|
}
|
|
|
|
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
|
|
"""
|
|
Delete memory item.
|
|
|
|
Args:
|
|
memory_id: Memory identifier
|
|
|
|
Returns:
|
|
True if deleted successfully
|
|
"""
|
|
if memory_id not in self.memory_items:
|
|
return False
|
|
|
|
# Remove from vector store unless a caller is staging an atomic local update.
|
|
if not skip_vector:
|
|
if self.vector_store:
|
|
try:
|
|
vector_ids = list(self._vector_ids.get(memory_id, [])) or [
|
|
memory_id
|
|
]
|
|
self._delete_vector_ids(vector_ids)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to delete from vector store: {e}")
|
|
self._vector_ids.pop(memory_id, None)
|
|
|
|
memory_item = self.memory_items[memory_id]
|
|
|
|
# Remove from memory
|
|
del self.memory_items[memory_id]
|
|
|
|
# Remove every occurrence in case a custom ID was stored more than once.
|
|
self.memory_index = deque(
|
|
(item_id for item_id in self.memory_index if item_id != memory_id),
|
|
maxlen=self.memory_index.maxlen,
|
|
)
|
|
|
|
self.short_term_memory = [
|
|
item for item in self.short_term_memory if item.memory_id != memory_id
|
|
]
|
|
|
|
self.stats["total_items"] = max(0, self.stats["total_items"] - 1)
|
|
item_type = memory_item.metadata.get("type", "general")
|
|
if item_type in self.stats["items_by_type"]:
|
|
self.stats["items_by_type"][item_type] = max(
|
|
0, self.stats["items_by_type"][item_type] - 1
|
|
)
|
|
if self.stats["items_by_type"][item_type] == 0:
|
|
del self.stats["items_by_type"][item_type]
|
|
|
|
self.logger.debug(f"Deleted memory item: {memory_id}")
|
|
return True
|
|
|
|
def clear_memory(self, **filters) -> int:
|
|
"""
|
|
Clear memory items matching filters.
|
|
|
|
Args:
|
|
**filters: Filter criteria
|
|
|
|
Returns:
|
|
Number of items deleted
|
|
"""
|
|
deleted_count = 0
|
|
memory_ids_to_delete = []
|
|
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if self._matches_filters(memory_item, filters):
|
|
memory_ids_to_delete.append(memory_id)
|
|
|
|
for memory_id in memory_ids_to_delete:
|
|
if self.delete_memory(memory_id):
|
|
deleted_count += 1
|
|
|
|
return deleted_count
|
|
|
|
def get_conversation_history(
|
|
self, conversation_id: Optional[str] = None, max_items: int = 100
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get conversation history.
|
|
|
|
Args:
|
|
conversation_id: Optional conversation ID filter
|
|
max_items: Maximum number of items
|
|
|
|
Returns:
|
|
List of conversation items
|
|
"""
|
|
history = []
|
|
|
|
for memory_id in list(self.memory_index)[-max_items:]:
|
|
memory_item = self.memory_items.get(memory_id)
|
|
if not memory_item:
|
|
continue
|
|
|
|
# Filter by conversation ID if provided
|
|
if conversation_id:
|
|
item_conv_id = memory_item.metadata.get("conversation_id")
|
|
if item_conv_id != conversation_id:
|
|
continue
|
|
|
|
# Check if it's a conversation item
|
|
if memory_item.metadata.get("type") == "conversation":
|
|
history.append(
|
|
{
|
|
"memory_id": memory_id,
|
|
"content": memory_item.content,
|
|
"timestamp": memory_item.timestamp.isoformat(),
|
|
"metadata": memory_item.metadata,
|
|
}
|
|
)
|
|
|
|
return history
|
|
|
|
def _search_short_term(
|
|
self, query: str, filters: Dict[str, Any]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Search short-term memory (simple keyword match)."""
|
|
results = []
|
|
query_terms = query.lower().split()
|
|
|
|
# Iterate through short-term memory (most recent first)
|
|
for item in reversed(self.short_term_memory):
|
|
if not self._matches_filters(item, filters):
|
|
continue
|
|
|
|
content_lower = item.content.lower()
|
|
|
|
# Simple scoring based on term overlap
|
|
matches = sum(1 for term in query_terms if term in content_lower)
|
|
if matches > 0:
|
|
score = matches / len(query_terms)
|
|
# Boost score for recent items (short-term)
|
|
score = min(1.0, score + 0.1)
|
|
|
|
results.append(
|
|
{
|
|
"memory_id": item.memory_id,
|
|
"content": item.content,
|
|
"score": score,
|
|
"timestamp": item.timestamp.isoformat(),
|
|
"metadata": item.metadata,
|
|
"entities": item.entities,
|
|
"relationships": item.relationships,
|
|
"source": "short_term",
|
|
}
|
|
)
|
|
|
|
return results
|
|
|
|
def _generate_memory_id(self) -> str:
|
|
"""Generate unique memory ID."""
|
|
import time
|
|
|
|
timestamp = str(time.time())
|
|
random_str = str(hash(str(self.memory_items)) % 10000)
|
|
memory_hash = hashlib.md5(f"{timestamp}_{random_str}".encode()).hexdigest()[:12] # nosec B324 - short unique ID, not security-sensitive
|
|
|
|
return f"mem_{memory_hash}"
|
|
|
|
def _prune_short_term_memory(self) -> None:
|
|
"""
|
|
Prune short-term memory based on count and token limits.
|
|
|
|
Removes oldest items until constraints are met.
|
|
"""
|
|
# 1. Prune by count
|
|
while len(self.short_term_memory) > self.short_term_limit:
|
|
self.short_term_memory.pop(0) # Remove oldest
|
|
|
|
# 2. Prune by tokens
|
|
current_tokens = sum(
|
|
self._count_tokens(item.content) for item in self.short_term_memory
|
|
)
|
|
|
|
while current_tokens > self.token_limit and self.short_term_memory:
|
|
removed_item = self.short_term_memory.pop(0) # Remove oldest
|
|
current_tokens -= self._count_tokens(removed_item.content)
|
|
|
|
def _count_tokens(self, text: str) -> int:
|
|
"""
|
|
Estimate token count (approximation).
|
|
|
|
Args:
|
|
text: Input text
|
|
|
|
Returns:
|
|
Estimated token count
|
|
"""
|
|
# Simple approximation: 1 token ≈ 4 characters
|
|
return len(text) // 4
|
|
|
|
def _generate_embedding(self, content: str) -> Any:
|
|
"""Generate embedding for content."""
|
|
# This would use an embedding model
|
|
# For now, return placeholder
|
|
if hasattr(self.vector_store, "embed"):
|
|
return self.vector_store.embed(content)
|
|
return None
|
|
|
|
def _store_memory_vector(
|
|
self, memory_item: MemoryItem, tracking_id: Optional[str] = None
|
|
) -> List[str]:
|
|
"""Store one memory embedding and return adapter-provided vector IDs."""
|
|
if not self.vector_store:
|
|
return []
|
|
|
|
if tracking_id is not None:
|
|
self.progress_tracker.update_tracking(
|
|
tracking_id, message="Generating embedding..."
|
|
)
|
|
|
|
memory_item.embedding = self._generate_embedding(memory_item.content)
|
|
stored_ids: Any = None
|
|
fallback_to_memory_id = False
|
|
|
|
if hasattr(self.vector_store, "store_vectors"):
|
|
embedding = memory_item.embedding
|
|
vectors = (
|
|
[np.array(embedding)] if isinstance(embedding, list) else [embedding]
|
|
)
|
|
stored_ids = self.vector_store.store_vectors(
|
|
vectors=vectors, metadata=[memory_item.metadata]
|
|
)
|
|
elif hasattr(self.vector_store, "add"):
|
|
stored_ids = self.vector_store.add([memory_item])
|
|
fallback_to_memory_id = True
|
|
else:
|
|
return []
|
|
|
|
if isinstance(stored_ids, str):
|
|
return [stored_ids]
|
|
if isinstance(stored_ids, (list, tuple)):
|
|
return [str(vector_id) for vector_id in stored_ids]
|
|
if fallback_to_memory_id and memory_item.memory_id:
|
|
return [memory_item.memory_id]
|
|
return []
|
|
|
|
def _delete_vector_ids(self, vector_ids: List[str]) -> None:
|
|
"""Delete adapter vector IDs using Semantica's supported interfaces."""
|
|
if not self.vector_store or not vector_ids:
|
|
return
|
|
|
|
if hasattr(self.vector_store, "delete_vectors"):
|
|
deleted = self.vector_store.delete_vectors(vector_ids)
|
|
elif hasattr(self.vector_store, "delete"):
|
|
deleted = self.vector_store.delete(vector_ids)
|
|
else:
|
|
return
|
|
|
|
if deleted is False:
|
|
raise RuntimeError(f"Vector store did not delete IDs: {vector_ids}")
|
|
|
|
def _sync_committed_vector_changes(
|
|
self, previous_state: Dict[str, Any], changed_ids: List[str]
|
|
) -> None:
|
|
"""Best-effort vector sync after in-memory changes can no longer roll back."""
|
|
if not self.vector_store:
|
|
return
|
|
|
|
previous_items = previous_state["memory_items"]
|
|
previous_vector_ids = previous_state["vector_ids"]
|
|
|
|
for memory_id in changed_ids:
|
|
memory_item = self.memory_items.get(memory_id)
|
|
if memory_item is None:
|
|
continue
|
|
|
|
old_ids = list(previous_vector_ids.get(memory_id, []))
|
|
if memory_id in previous_items and not old_ids:
|
|
old_ids = [memory_id]
|
|
|
|
try:
|
|
new_ids = self._store_memory_vector(memory_item)
|
|
if new_ids:
|
|
self._vector_ids[memory_id] = new_ids
|
|
stale_ids = [
|
|
vector_id for vector_id in old_ids if vector_id not in new_ids
|
|
]
|
|
self._delete_vector_ids(stale_ids)
|
|
except Exception as exc:
|
|
self.logger.warning(
|
|
f"Failed to synchronize vector for memory {memory_id}: {exc}"
|
|
)
|
|
|
|
removed_ids = set(previous_items).difference(self.memory_items)
|
|
for memory_id in removed_ids:
|
|
vector_ids = list(previous_vector_ids.get(memory_id, [])) or [memory_id]
|
|
try:
|
|
self._delete_vector_ids(vector_ids)
|
|
except Exception as exc:
|
|
self.logger.warning(
|
|
f"Failed to delete vector for memory {memory_id}: {exc}"
|
|
)
|
|
self._vector_ids.pop(memory_id, None)
|
|
|
|
def _update_knowledge_graph(
|
|
self,
|
|
entities: List[EntityDict],
|
|
relationships: Optional[List[RelationshipDict]],
|
|
) -> None:
|
|
"""Update knowledge graph with new entities and relationships."""
|
|
if not self.knowledge_graph:
|
|
return
|
|
|
|
# Check for GraphStore protocol (add_nodes method)
|
|
if hasattr(self.knowledge_graph, "add_nodes"):
|
|
# Convert to dicts for ContextGraph
|
|
graph_nodes = []
|
|
for entity in entities:
|
|
entity_id = entity.get("id") or entity.get("entity_id")
|
|
if entity_id:
|
|
graph_nodes.append(
|
|
{
|
|
"id": entity_id,
|
|
"type": entity.get("type", "entity"),
|
|
"properties": {
|
|
"content": (
|
|
entity.get("text")
|
|
or entity.get("label")
|
|
or entity_id
|
|
),
|
|
**entity,
|
|
},
|
|
}
|
|
)
|
|
if graph_nodes:
|
|
self.knowledge_graph.add_nodes(graph_nodes)
|
|
|
|
if relationships:
|
|
graph_edges = []
|
|
for rel in relationships:
|
|
source = rel.get("source_id")
|
|
target = rel.get("target_id")
|
|
if source and target:
|
|
graph_edges.append(
|
|
{
|
|
"source_id": source,
|
|
"target_id": target,
|
|
"type": rel.get("type", "related_to"),
|
|
"weight": rel.get("confidence", 1.0),
|
|
"properties": rel,
|
|
}
|
|
)
|
|
if graph_edges:
|
|
self.knowledge_graph.add_edges(graph_edges)
|
|
|
|
return
|
|
|
|
# Legacy dict update
|
|
# Add entities to graph
|
|
graph_entities = self.knowledge_graph.get("entities", [])
|
|
existing_ids = {e.get("id") for e in graph_entities}
|
|
|
|
for entity in entities:
|
|
entity_id = entity.get("id")
|
|
if entity_id and entity_id not in existing_ids:
|
|
graph_entities.append(entity)
|
|
|
|
self.knowledge_graph["entities"] = graph_entities
|
|
|
|
# Add relationships
|
|
if relationships:
|
|
graph_relationships = self.knowledge_graph.get("relationships", [])
|
|
graph_relationships.extend(relationships)
|
|
self.knowledge_graph["relationships"] = graph_relationships
|
|
|
|
def _matches_filters(
|
|
self, memory_item: MemoryItem, filters: Dict[str, Any]
|
|
) -> bool:
|
|
"""Check if memory item matches filters."""
|
|
# Filter by type
|
|
if "type" in filters:
|
|
item_type = memory_item.metadata.get("type")
|
|
if item_type != filters["type"]:
|
|
return False
|
|
|
|
# Filter by date range
|
|
if "start_date" in filters:
|
|
start_date = filters["start_date"]
|
|
if isinstance(start_date, str):
|
|
from dateutil.parser import parse
|
|
|
|
start_date = parse(start_date)
|
|
if self._timestamp_comparison_key(
|
|
memory_item.timestamp
|
|
) < self._timestamp_comparison_key(start_date):
|
|
return False
|
|
|
|
if "end_date" in filters:
|
|
end_date = filters["end_date"]
|
|
if isinstance(end_date, str):
|
|
from dateutil.parser import parse
|
|
|
|
end_date = parse(end_date)
|
|
if self._timestamp_comparison_key(
|
|
memory_item.timestamp
|
|
) > self._timestamp_comparison_key(end_date):
|
|
return False
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def _timestamp_comparison_key(timestamp: datetime) -> datetime:
|
|
"""Convert aware or local-naive timestamps to a comparable UTC value."""
|
|
return timestamp.astimezone(timezone.utc)
|
|
|
|
def _keyword_search(
|
|
self, query: str, max_results: int, filters: Dict[str, Any]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Fallback keyword search."""
|
|
query_lower = query.lower()
|
|
query_words = set(query_lower.split())
|
|
|
|
results = []
|
|
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if not self._matches_filters(memory_item, filters):
|
|
continue
|
|
|
|
content_lower = memory_item.content.lower()
|
|
content_words = set(content_lower.split())
|
|
|
|
# Calculate simple word overlap score
|
|
overlap = len(query_words & content_words)
|
|
if overlap > 0:
|
|
score = overlap / len(query_words)
|
|
results.append(
|
|
{
|
|
"memory_id": memory_id,
|
|
"content": memory_item.content,
|
|
"score": score,
|
|
"timestamp": memory_item.timestamp.isoformat(),
|
|
"metadata": memory_item.metadata,
|
|
"entities": memory_item.entities,
|
|
"relationships": memory_item.relationships,
|
|
}
|
|
)
|
|
|
|
return results
|
|
|
|
def _apply_retention_policy(self, *, skip_vector: bool = False) -> None:
|
|
"""Apply memory retention policy."""
|
|
if self.retention_policy == "unlimited":
|
|
return
|
|
|
|
# Parse retention policy
|
|
if isinstance(self.retention_policy, str) and "_days" in self.retention_policy:
|
|
try:
|
|
days = int(self.retention_policy.replace("_days", ""))
|
|
except ValueError:
|
|
days = 30
|
|
else:
|
|
days = 30
|
|
|
|
cutoff_date = datetime.now() - timedelta(days=days)
|
|
|
|
# Delete old items
|
|
memory_ids_to_delete = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if self._timestamp_comparison_key(
|
|
memory_item.timestamp
|
|
) < self._timestamp_comparison_key(cutoff_date):
|
|
memory_ids_to_delete.append(memory_id)
|
|
|
|
for memory_id in memory_ids_to_delete:
|
|
self.delete_memory(memory_id, skip_vector=skip_vector)
|
|
|
|
if memory_ids_to_delete:
|
|
self.logger.info(
|
|
f"Deleted {len(memory_ids_to_delete)} items based on retention policy"
|
|
)
|
|
|
|
def get_statistics(self) -> Dict[str, Any]:
|
|
"""
|
|
Get memory statistics.
|
|
|
|
Returns:
|
|
Statistics dictionary
|
|
"""
|
|
return {
|
|
**self.stats,
|
|
"current_items": len(self.memory_items),
|
|
"max_size": self.max_memory_size,
|
|
"retention_policy": self.retention_policy,
|
|
}
|
|
|
|
# Basic Operations
|
|
def exists(self, memory_id: str) -> bool:
|
|
"""
|
|
Check if memory exists.
|
|
|
|
Args:
|
|
memory_id: Memory ID to check
|
|
|
|
Returns:
|
|
True if exists, False otherwise
|
|
|
|
Example:
|
|
>>> if memory.exists("mem123"):
|
|
... print("Memory exists")
|
|
"""
|
|
return memory_id in self.memory_items
|
|
|
|
def count(self, **filters) -> int:
|
|
"""
|
|
Get count with filters.
|
|
|
|
Args:
|
|
**filters: Filter criteria
|
|
|
|
Returns:
|
|
Count of memories matching filters
|
|
|
|
Example:
|
|
>>> total = memory.count()
|
|
>>> conv_count = memory.count(conversation_id="conv1")
|
|
"""
|
|
if not filters:
|
|
return len(self.memory_items)
|
|
|
|
count = 0
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if self._matches_filters(memory_item, filters):
|
|
count += 1
|
|
return count
|
|
|
|
def get(self, memory_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get memory by ID.
|
|
|
|
Args:
|
|
memory_id: Memory ID
|
|
|
|
Returns:
|
|
Memory dict or None if not found
|
|
|
|
Example:
|
|
>>> memory = memory.get("mem123")
|
|
"""
|
|
return self.get_memory(memory_id)
|
|
|
|
def update(
|
|
self,
|
|
memory_id: str,
|
|
content: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
entities: Optional[List[EntityDict]] = None,
|
|
relationships: Optional[List[RelationshipDict]] = None,
|
|
**kwargs,
|
|
) -> bool:
|
|
"""
|
|
Update memory.
|
|
|
|
Args:
|
|
memory_id: Memory ID to update
|
|
content: New content (optional)
|
|
metadata: New metadata (optional, merged with existing)
|
|
entities: Replacement entities (optional)
|
|
relationships: Replacement relationships (optional)
|
|
**kwargs: Additional fields to update
|
|
|
|
Returns:
|
|
True if updated, False if not found
|
|
|
|
Example:
|
|
>>> memory.update("mem123", content="Updated content")
|
|
"""
|
|
if memory_id not in self.memory_items:
|
|
return False
|
|
|
|
memory_item = self.memory_items[memory_id]
|
|
current_content = content if content is not None else memory_item.content
|
|
current_metadata = memory_item.metadata.copy()
|
|
if metadata:
|
|
current_metadata.update(metadata)
|
|
current_entities = entities if entities is not None else memory_item.entities
|
|
current_relationships = (
|
|
relationships if relationships is not None else memory_item.relationships
|
|
)
|
|
|
|
return self._replace_memory_item(
|
|
memory_id,
|
|
current_content,
|
|
metadata=current_metadata,
|
|
entities=current_entities,
|
|
relationships=current_relationships,
|
|
timestamp=kwargs.pop("timestamp", memory_item.timestamp),
|
|
**kwargs,
|
|
)
|
|
|
|
def _replace_memory_item(
|
|
self,
|
|
memory_id: str,
|
|
content: str,
|
|
metadata: Dict[str, Any],
|
|
entities: List[EntityDict],
|
|
relationships: List[RelationshipDict],
|
|
timestamp: datetime,
|
|
**options,
|
|
) -> bool:
|
|
"""Replace a memory while retaining its identity and recoverable state."""
|
|
state = self._snapshot_memory_state()
|
|
replacement_options = dict(options)
|
|
sync_vector = not replacement_options.pop("skip_vector", False)
|
|
try:
|
|
self.delete_memory(memory_id, skip_vector=True)
|
|
new_id = self.store(
|
|
content,
|
|
metadata=metadata,
|
|
entities=entities,
|
|
relationships=relationships,
|
|
memory_id=memory_id,
|
|
timestamp=timestamp,
|
|
skip_vector=True,
|
|
**replacement_options,
|
|
)
|
|
except Exception:
|
|
self._restore_memory_state(state)
|
|
raise
|
|
|
|
if new_id != memory_id or not self.exists(memory_id):
|
|
self._restore_memory_state(state)
|
|
return False
|
|
if sync_vector:
|
|
self._sync_committed_vector_changes(state, [memory_id])
|
|
return True
|
|
|
|
def _snapshot_memory_state(self) -> Dict[str, Any]:
|
|
"""Capture mutable in-memory state for an update or import rollback."""
|
|
return {
|
|
"memory_items": dict(self.memory_items),
|
|
"memory_index": deque(self.memory_index, maxlen=self.memory_index.maxlen),
|
|
"short_term_memory": list(self.short_term_memory),
|
|
"vector_ids": copy.deepcopy(self._vector_ids),
|
|
"stats": copy.deepcopy(self.stats),
|
|
}
|
|
|
|
def _restore_memory_state(self, state: Dict[str, Any]) -> None:
|
|
"""Restore state captured by :meth:`_snapshot_memory_state`."""
|
|
self.memory_items = state["memory_items"]
|
|
self.memory_index = state["memory_index"]
|
|
self.short_term_memory = state["short_term_memory"]
|
|
self._vector_ids = state["vector_ids"]
|
|
self.stats = state["stats"]
|
|
|
|
def delete(self, memory_id: str) -> bool:
|
|
"""
|
|
Delete memory (alias for delete_memory).
|
|
|
|
Args:
|
|
memory_id: Memory ID to delete
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
|
|
Example:
|
|
>>> memory.delete("mem123")
|
|
"""
|
|
return self.delete_memory(memory_id)
|
|
|
|
def clear(self, **filters) -> int:
|
|
"""
|
|
Clear with filters (alias for clear_memory).
|
|
|
|
Args:
|
|
**filters: Filter criteria
|
|
|
|
Returns:
|
|
Number of memories deleted
|
|
|
|
Example:
|
|
>>> deleted = memory.clear(conversation_id="conv1")
|
|
"""
|
|
return self.clear_memory(**filters)
|
|
|
|
# Search Methods
|
|
def search(self, query: str, **filters) -> List[Dict[str, Any]]:
|
|
"""
|
|
Simple search (alias for retrieve).
|
|
|
|
Args:
|
|
query: Search query
|
|
**filters: Additional filters
|
|
|
|
Returns:
|
|
List of memory dicts
|
|
|
|
Example:
|
|
>>> results = memory.search("Python", max_results=10)
|
|
"""
|
|
return self.retrieve(query, **filters)
|
|
|
|
def find_similar(
|
|
self, content: str, limit: int = 5, **kwargs
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find similar content.
|
|
|
|
Args:
|
|
content: Content to find similar items for
|
|
limit: Maximum results (default: 5)
|
|
**kwargs: Additional options
|
|
|
|
Returns:
|
|
List of similar memory dicts
|
|
|
|
Example:
|
|
>>> similar = memory.find_similar("Python programming", limit=5)
|
|
"""
|
|
return self.retrieve(content, max_results=limit, **kwargs)
|
|
|
|
def find_by_entity(self, entity_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find by entity.
|
|
|
|
Args:
|
|
entity_id: Entity ID to search for
|
|
limit: Maximum results (default: 10)
|
|
|
|
Returns:
|
|
List of memory dicts containing the entity
|
|
|
|
Example:
|
|
>>> results = memory.find_by_entity("entity_123")
|
|
"""
|
|
results = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
for entity in memory_item.entities:
|
|
if entity.get("id") == entity_id:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
break
|
|
if len(results) >= limit:
|
|
break
|
|
return results[:limit]
|
|
|
|
def find_by_relationship(
|
|
self, relationship_type: str, limit: int = 10
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find by relationship.
|
|
|
|
Args:
|
|
relationship_type: Relationship type to search for
|
|
limit: Maximum results (default: 10)
|
|
|
|
Returns:
|
|
List of memory dicts containing the relationship
|
|
|
|
Example:
|
|
>>> results = memory.find_by_relationship("related_to")
|
|
"""
|
|
results = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
for relationship in memory_item.relationships:
|
|
if relationship.get("type") == relationship_type:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
break
|
|
if len(results) >= limit:
|
|
break
|
|
return results[:limit]
|
|
|
|
# List and Filter Methods
|
|
def list(
|
|
self,
|
|
conversation_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
**filters,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
List memories.
|
|
|
|
Args:
|
|
conversation_id: Filter by conversation ID
|
|
user_id: Filter by user ID
|
|
limit: Maximum items (default: 100)
|
|
offset: Number of items to skip (default: 0)
|
|
**filters: Additional filters
|
|
|
|
Returns:
|
|
List of memory dicts
|
|
|
|
Example:
|
|
>>> memories = memory.list(conversation_id="conv1", limit=50)
|
|
"""
|
|
all_filters = {**filters}
|
|
if conversation_id:
|
|
all_filters["conversation_id"] = conversation_id
|
|
if user_id:
|
|
all_filters["user_id"] = user_id
|
|
|
|
results = []
|
|
for memory_id in list(self.memory_items.keys())[offset : offset + limit]:
|
|
memory_item = self.memory_items[memory_id]
|
|
if not all_filters or self._matches_filters(memory_item, all_filters):
|
|
# Also check user_id and conversation_id in metadata
|
|
if user_id and memory_item.metadata.get("user_id") != user_id:
|
|
continue
|
|
if (
|
|
conversation_id
|
|
and memory_item.metadata.get("conversation_id") != conversation_id
|
|
):
|
|
continue
|
|
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
|
|
return results
|
|
|
|
def get_by_conversation(
|
|
self, conversation_id: str, limit: int = 100
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get conversation memories.
|
|
|
|
Args:
|
|
conversation_id: Conversation ID
|
|
limit: Maximum items (default: 100)
|
|
|
|
Returns:
|
|
List of memory dicts in conversation
|
|
|
|
Example:
|
|
>>> memories = memory.get_by_conversation("conv1")
|
|
"""
|
|
return self.get_conversation_history(
|
|
conversation_id=conversation_id, max_items=limit
|
|
)
|
|
|
|
def get_by_user(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get user memories.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
limit: Maximum items (default: 100)
|
|
|
|
Returns:
|
|
List of memory dicts for user
|
|
|
|
Example:
|
|
>>> memories = memory.get_by_user("user123")
|
|
"""
|
|
results = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if memory_item.metadata.get("user_id") == user_id:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
if len(results) >= limit:
|
|
break
|
|
return results
|
|
|
|
def get_recent(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get recent memories.
|
|
|
|
Args:
|
|
limit: Maximum items (default: 10)
|
|
|
|
Returns:
|
|
List of recent memory dicts
|
|
|
|
Example:
|
|
>>> recent = memory.get_recent(limit=20)
|
|
"""
|
|
results = []
|
|
sorted_items = sorted(
|
|
self.memory_items.items(),
|
|
key=lambda item: self._timestamp_comparison_key(item[1].timestamp),
|
|
reverse=True,
|
|
)
|
|
for memory_id, _ in sorted_items[:limit]:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
return results
|
|
|
|
def get_by_date(
|
|
self,
|
|
start_date: Union[str, datetime],
|
|
end_date: Union[str, datetime],
|
|
limit: int = 100,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get by date range.
|
|
|
|
Args:
|
|
start_date: Start date (ISO string or datetime)
|
|
end_date: End date (ISO string or datetime)
|
|
limit: Maximum items (default: 100)
|
|
|
|
Returns:
|
|
List of memory dicts in date range
|
|
|
|
Example:
|
|
>>> memories = memory.get_by_date("2024-01-01", "2024-12-31")
|
|
"""
|
|
if isinstance(start_date, str):
|
|
from dateutil.parser import parse
|
|
|
|
start_date = parse(start_date)
|
|
if isinstance(end_date, str):
|
|
from dateutil.parser import parse
|
|
|
|
end_date = parse(end_date)
|
|
|
|
normalized_start = self._timestamp_comparison_key(start_date)
|
|
normalized_end = self._timestamp_comparison_key(end_date)
|
|
|
|
results = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
normalized_timestamp = self._timestamp_comparison_key(memory_item.timestamp)
|
|
if normalized_start <= normalized_timestamp <= normalized_end:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
if len(results) >= limit:
|
|
break
|
|
return results
|
|
|
|
def get_by_type(self, type: str, limit: int = 100) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get by type.
|
|
|
|
Args:
|
|
type: Memory type
|
|
limit: Maximum items (default: 100)
|
|
|
|
Returns:
|
|
List of memory dicts of specified type
|
|
|
|
Example:
|
|
>>> memories = memory.get_by_type("conversation")
|
|
"""
|
|
results = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if memory_item.metadata.get("type") == type:
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
results.append(mem_dict)
|
|
if len(results) >= limit:
|
|
break
|
|
return results
|
|
|
|
# Batch Operations
|
|
def batch_store(self, items: List[Union[str, Dict[str, Any]]]) -> List[str]:
|
|
"""
|
|
Batch store.
|
|
|
|
Args:
|
|
items: List of items to store
|
|
|
|
Returns:
|
|
List of memory IDs
|
|
|
|
Example:
|
|
>>> ids = memory.batch_store(["Item 1", "Item 2"])
|
|
"""
|
|
memory_ids = []
|
|
for item in items:
|
|
if isinstance(item, str):
|
|
memory_id = self.store(item)
|
|
memory_ids.append(memory_id)
|
|
elif isinstance(item, dict):
|
|
content = item.get("content", "")
|
|
if content:
|
|
extra_fields = {
|
|
k: v
|
|
for k, v in item.items()
|
|
if k not in ["content", "metadata"]
|
|
}
|
|
memory_id = self.store(
|
|
content,
|
|
metadata=item.get("metadata"),
|
|
**extra_fields,
|
|
)
|
|
memory_ids.append(memory_id)
|
|
return memory_ids
|
|
|
|
def batch_delete(self, memory_ids: List[str]) -> int:
|
|
"""
|
|
Batch delete.
|
|
|
|
Args:
|
|
memory_ids: List of memory IDs to delete
|
|
|
|
Returns:
|
|
Number of memories deleted
|
|
|
|
Example:
|
|
>>> deleted = memory.batch_delete(["mem1", "mem2"])
|
|
"""
|
|
deleted = 0
|
|
for memory_id in memory_ids:
|
|
if self.delete_memory(memory_id):
|
|
deleted += 1
|
|
return deleted
|
|
|
|
def batch_update(self, updates: List[Dict[str, Any]]) -> int:
|
|
"""
|
|
Batch update.
|
|
|
|
Args:
|
|
updates: List of update dicts with 'memory_id' and fields
|
|
|
|
Returns:
|
|
Number of memories updated
|
|
|
|
Example:
|
|
>>> updated = memory.batch_update([{"memory_id": "mem1", "content": "New"}])
|
|
"""
|
|
updated = 0
|
|
for update in updates:
|
|
memory_id = update.get("memory_id")
|
|
update_fields = {k: v for k, v in update.items() if k != "memory_id"}
|
|
if memory_id and self.update(memory_id, **update_fields):
|
|
updated += 1
|
|
return updated
|
|
|
|
# Export/Import
|
|
def export(
|
|
self,
|
|
conversation_id: Optional[str] = None,
|
|
format: str = "json",
|
|
destination: Optional[Union[str, Path]] = None,
|
|
**filters,
|
|
) -> Union[str, Dict[str, Any]]:
|
|
"""
|
|
Export memories.
|
|
|
|
Args:
|
|
conversation_id: Export specific conversation (optional)
|
|
format: Export format ('json', 'dict', or 'markdown', default: 'json')
|
|
destination: Optional directory for one-file-per-memory Markdown export
|
|
**filters: Additional filters
|
|
|
|
Returns:
|
|
Exported data
|
|
|
|
Example:
|
|
>>> data = memory.export(conversation_id="conv1")
|
|
"""
|
|
all_filters = {**filters}
|
|
if conversation_id:
|
|
all_filters["conversation_id"] = conversation_id
|
|
|
|
memories = []
|
|
for memory_id, memory_item in self.memory_items.items():
|
|
if not all_filters or self._matches_filters(memory_item, all_filters):
|
|
mem_dict = self.get_memory(memory_id)
|
|
if mem_dict:
|
|
memories.append(mem_dict)
|
|
|
|
export_data = {
|
|
"exported_at": datetime.now().isoformat(),
|
|
"count": len(memories),
|
|
"memories": memories,
|
|
}
|
|
|
|
if format == "json":
|
|
import json
|
|
|
|
return json.dumps(export_data, indent=2, default=str)
|
|
if format == "markdown":
|
|
return self._export_markdown(memories, destination=destination)
|
|
return export_data
|
|
|
|
def import_data(
|
|
self, data: Union[str, Path, Dict[str, Any]], format: str = "json"
|
|
) -> int:
|
|
"""
|
|
Import memories.
|
|
|
|
Args:
|
|
data: Data to import
|
|
format: Data format ('json', 'dict', or 'markdown', default: 'json')
|
|
|
|
Returns:
|
|
Number of memories imported
|
|
|
|
Example:
|
|
>>> imported = memory.import_data(json_string)
|
|
"""
|
|
if format == "json":
|
|
import json
|
|
|
|
if isinstance(data, str):
|
|
data = json.loads(data)
|
|
elif format == "markdown":
|
|
memories = self._import_markdown_payload(data)
|
|
return self._import_markdown_records(memories)
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Invalid data format")
|
|
|
|
memories = data.get("memories", [])
|
|
if not memories:
|
|
return 0
|
|
|
|
imported = 0
|
|
for memory in memories:
|
|
try:
|
|
memory_id = self.store(
|
|
memory.get("content", ""),
|
|
metadata=memory.get("metadata", {}),
|
|
)
|
|
if memory_id:
|
|
imported += 1
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to import memory: {e}")
|
|
|
|
return imported
|
|
|
|
def _export_markdown(
|
|
self,
|
|
memories: List[Dict[str, Any]],
|
|
destination: Optional[Union[str, Path]] = None,
|
|
) -> str:
|
|
documents = []
|
|
filenames = set()
|
|
for memory in memories:
|
|
filename = self._memory_markdown_filename(memory.get("memory_id"))
|
|
normalized_filename = filename.casefold()
|
|
if normalized_filename in filenames:
|
|
raise ValueError(
|
|
f"Cannot export Markdown: duplicate filename {filename!r}."
|
|
)
|
|
filenames.add(normalized_filename)
|
|
documents.append((filename, self._memory_to_markdown(memory)))
|
|
|
|
if destination is None:
|
|
if not documents:
|
|
return ""
|
|
if len(documents) > 1:
|
|
raise ValueError(
|
|
"Markdown export without a destination supports one memory item. "
|
|
"Pass a destination directory to export multiple items."
|
|
)
|
|
return documents[0][1]
|
|
|
|
destination_path = Path(destination)
|
|
if destination_path.exists() and not destination_path.is_dir():
|
|
raise ValueError(
|
|
f"Markdown export destination is not a directory: {destination_path}"
|
|
)
|
|
destination_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
for filename, document in documents:
|
|
file_path = destination_path / filename
|
|
try:
|
|
self._write_markdown_file(file_path, document)
|
|
except OSError as exc:
|
|
raise OSError(
|
|
f"Failed to write Markdown memory to {file_path}"
|
|
) from exc
|
|
|
|
return str(destination_path)
|
|
|
|
@staticmethod
|
|
def _write_markdown_file(file_path: Path, document: str) -> None:
|
|
"""Atomically replace a Markdown file without following output symlinks."""
|
|
if file_path.is_symlink():
|
|
raise ValueError(
|
|
f"Refusing to overwrite Markdown symbolic link: {file_path}"
|
|
)
|
|
|
|
temporary_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=str(file_path.parent),
|
|
prefix=f".{file_path.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as temporary_file:
|
|
temporary_path = Path(temporary_file.name)
|
|
temporary_file.write(document)
|
|
temporary_file.flush()
|
|
os.fsync(temporary_file.fileno())
|
|
|
|
# os.replace swaps the directory entry itself, so a raced symlink is
|
|
# replaced rather than followed.
|
|
os.replace(temporary_path, file_path)
|
|
temporary_path = None
|
|
finally:
|
|
if temporary_path is not None:
|
|
temporary_path.unlink(missing_ok=True)
|
|
|
|
def _memory_to_markdown(self, memory: Dict[str, Any]) -> str:
|
|
memory_id = memory.get("memory_id")
|
|
source = f"memory {memory_id!r}"
|
|
if not isinstance(memory_id, str) or not memory_id.strip():
|
|
raise ValueError(f"Cannot export {source}: 'memory_id' must be a string.")
|
|
|
|
raw_metadata = memory.get("metadata")
|
|
if raw_metadata is None:
|
|
raw_metadata = {}
|
|
if not isinstance(raw_metadata, dict):
|
|
raise ValueError(f"Cannot export {source}: 'metadata' must be a mapping.")
|
|
metadata = dict(raw_metadata)
|
|
|
|
created_at = self._parse_markdown_datetime(
|
|
memory.get("timestamp"), "created_at", source
|
|
)
|
|
updated_at = self._parse_markdown_datetime(
|
|
metadata.pop("updated_at", created_at), "updated_at", source
|
|
)
|
|
memory_type = metadata.pop("type", "general")
|
|
if not isinstance(memory_type, str) or not memory_type.strip():
|
|
raise ValueError(f"Cannot export {source}: 'type' must be a string.")
|
|
|
|
frontmatter = {
|
|
"id": memory_id,
|
|
"created_at": created_at.isoformat(),
|
|
"updated_at": updated_at.isoformat(),
|
|
"type": memory_type,
|
|
}
|
|
|
|
nested_metadata = {}
|
|
if any(not isinstance(key, str) for key in metadata):
|
|
raise ValueError(f"Cannot export {source}: metadata keys must be strings.")
|
|
for key in sorted(metadata):
|
|
value = metadata[key]
|
|
if key in self._MARKDOWN_RESERVED_FIELDS:
|
|
nested_metadata[key] = value
|
|
else:
|
|
frontmatter[key] = value
|
|
|
|
if nested_metadata:
|
|
frontmatter["metadata"] = nested_metadata
|
|
entities = memory.get("entities")
|
|
relationships = memory.get("relationships")
|
|
if entities is None:
|
|
entities = []
|
|
if relationships is None:
|
|
relationships = []
|
|
self._validate_markdown_mapping_list(entities, "entities", source)
|
|
self._validate_markdown_mapping_list(relationships, "relationships", source)
|
|
if entities:
|
|
frontmatter["entities"] = entities
|
|
if relationships:
|
|
frontmatter["relationships"] = relationships
|
|
|
|
try:
|
|
yaml_text = yaml.safe_dump(
|
|
frontmatter,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
default_flow_style=False,
|
|
)
|
|
except yaml.YAMLError as exc:
|
|
raise ValueError(
|
|
f"Cannot export {source}: metadata is not YAML serializable."
|
|
) from exc
|
|
|
|
body = memory.get("content", "")
|
|
if not isinstance(body, str):
|
|
raise ValueError(f"Cannot export {source}: 'content' must be a string.")
|
|
return f"---\n{yaml_text}---\n\n{body}"
|
|
|
|
def _memory_markdown_filename(self, memory_id: Optional[str]) -> str:
|
|
if not isinstance(memory_id, str) or not memory_id.strip():
|
|
raise ValueError(
|
|
"Cannot create Markdown filename without a string memory ID."
|
|
)
|
|
|
|
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", memory_id)
|
|
slug = re.sub(r"-+", "-", slug).strip("._-")[:80].rstrip("._-")
|
|
slug = slug or "memory"
|
|
digest = hashlib.sha256(memory_id.encode("utf-8")).hexdigest()[:12]
|
|
return f"{slug}--{digest}.md"
|
|
|
|
def _import_markdown_payload(
|
|
self, data: Union[str, Path, Dict[str, Any]]
|
|
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
if isinstance(data, Path):
|
|
documents = self._read_markdown_path(data)
|
|
elif isinstance(data, str):
|
|
if not data:
|
|
return []
|
|
|
|
documents = None
|
|
if "\n" not in data and "\r" not in data:
|
|
candidate = Path(data)
|
|
try:
|
|
candidate_exists = candidate.exists()
|
|
except OSError as exc:
|
|
error_message = (
|
|
"Failed to inspect possible Markdown import "
|
|
f"path {candidate}: {exc.strerror or str(exc)}"
|
|
)
|
|
if exc.errno is None:
|
|
error = OSError(error_message)
|
|
else:
|
|
error = OSError(
|
|
exc.errno,
|
|
error_message,
|
|
exc.filename or str(candidate),
|
|
)
|
|
raise error from exc
|
|
|
|
if candidate_exists:
|
|
documents = self._read_markdown_path(candidate)
|
|
|
|
if documents is None:
|
|
documents = [("markdown document", data)]
|
|
else:
|
|
raise ValueError(
|
|
"Invalid Markdown data format. Expected Markdown text or a path."
|
|
)
|
|
|
|
memories = []
|
|
memory_sources = {}
|
|
for source, document in documents:
|
|
memory = self._markdown_to_memory_dict(document, source=source)
|
|
memory_id = memory["memory_id"]
|
|
if memory_id in memory_sources:
|
|
raise ValueError(
|
|
f"Duplicate Markdown memory ID {memory_id!r} in {source}; "
|
|
f"already defined in {memory_sources[memory_id]}."
|
|
)
|
|
memory_sources[memory_id] = source
|
|
memories.append((source, memory))
|
|
|
|
return memories
|
|
|
|
def _read_markdown_file_content(self, file_path: Path) -> str:
|
|
if file_path.is_symlink():
|
|
raise ValueError(f"Symlink Markdown import paths are rejected: {file_path}")
|
|
|
|
flags = os.O_RDONLY
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
# On POSIX, O_NOFOLLOW makes os.open() fail with ELOOP if the
|
|
# final path component is a symlink, atomically closing the TOCTOU
|
|
# window between the is_symlink() check above and the open call.
|
|
# On Windows, O_NOFOLLOW is not available; the is_symlink() pre-check
|
|
# above is the only symlink defense and remains vulnerable to a narrow
|
|
# race. The fstat()/S_ISREG guard below still rejects special files
|
|
# (FIFOs, devices) on both platforms.
|
|
flags |= os.O_NOFOLLOW
|
|
|
|
try:
|
|
fd = os.open(str(file_path), flags)
|
|
except OSError as exc:
|
|
if exc.errno == getattr(errno, "ELOOP", None):
|
|
raise ValueError(
|
|
f"Symlink Markdown import paths are rejected: {file_path}"
|
|
) from exc
|
|
raise
|
|
|
|
try:
|
|
stat_res = os.fstat(fd)
|
|
if not stat.S_ISREG(stat_res.st_mode):
|
|
raise ValueError(
|
|
f"Markdown import path is not a regular file: {file_path}"
|
|
)
|
|
with open(fd, "r", encoding="utf-8", closefd=True) as f:
|
|
return f.read()
|
|
except Exception:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
def _read_markdown_path(self, path: Path) -> List[Tuple[str, str]]:
|
|
if path.is_symlink():
|
|
raise ValueError(f"Symlink Markdown import paths are rejected: {path}")
|
|
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Markdown import path does not exist: {path}")
|
|
|
|
if path.is_dir():
|
|
file_paths = sorted(
|
|
(
|
|
file_path
|
|
for file_path in path.iterdir()
|
|
if file_path.is_file()
|
|
and not file_path.is_symlink()
|
|
and file_path.suffix.lower() in self._MARKDOWN_EXTENSIONS
|
|
),
|
|
key=lambda file_path: (file_path.name.casefold(), file_path.name),
|
|
)
|
|
elif path.is_file():
|
|
file_paths = [path]
|
|
else:
|
|
raise ValueError(f"Markdown import path is not a file or directory: {path}")
|
|
|
|
return [
|
|
(str(file_path), self._read_markdown_file_content(file_path))
|
|
for file_path in file_paths
|
|
]
|
|
|
|
def _markdown_to_memory_dict(
|
|
self, document: str, source: str = "markdown document"
|
|
) -> Dict[str, Any]:
|
|
if not document.startswith("---"):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"document must start with '---'."
|
|
)
|
|
|
|
lines = document.splitlines(keepends=True)
|
|
if not lines or lines[0].rstrip("\r\n") != "---":
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"opening delimiter is malformed."
|
|
)
|
|
|
|
closing_index = next(
|
|
(
|
|
index
|
|
for index, line in enumerate(lines[1:], start=1)
|
|
if line.rstrip("\r\n") == "---"
|
|
),
|
|
None,
|
|
)
|
|
if closing_index is None:
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: missing closing '---'."
|
|
)
|
|
|
|
yaml_text = "".join(lines[1:closing_index])
|
|
try:
|
|
loaded_frontmatter = yaml.load(yaml_text, Loader=_UniqueKeySafeLoader)
|
|
frontmatter = {} if loaded_frontmatter is None else loaded_frontmatter
|
|
except yaml.YAMLError as exc:
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: {exc}"
|
|
) from exc
|
|
|
|
if not isinstance(frontmatter, dict):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: expected a YAML mapping."
|
|
)
|
|
|
|
if any(not isinstance(key, str) for key in frontmatter):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"field names must be strings."
|
|
)
|
|
|
|
missing_fields = [
|
|
field
|
|
for field in ("id", "created_at", "updated_at")
|
|
if field not in frontmatter
|
|
]
|
|
if "type" not in frontmatter and "kind" not in frontmatter:
|
|
missing_fields.append("type or kind")
|
|
if missing_fields:
|
|
fields = ", ".join(repr(field) for field in missing_fields)
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
f"missing required field(s) {fields}."
|
|
)
|
|
|
|
memory_id = frontmatter["id"]
|
|
if not isinstance(memory_id, str) or not memory_id.strip():
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: 'id' must be a string."
|
|
)
|
|
|
|
memory_type = frontmatter.get("type", frontmatter.get("kind"))
|
|
if not isinstance(memory_type, str) or not memory_type.strip():
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"'type' or 'kind' must be a string."
|
|
)
|
|
if (
|
|
"type" in frontmatter
|
|
and "kind" in frontmatter
|
|
and frontmatter["type"] != frontmatter["kind"]
|
|
):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"'type' and 'kind' must match when both are provided."
|
|
)
|
|
|
|
created_at = self._parse_markdown_datetime(
|
|
frontmatter["created_at"], "created_at", source
|
|
)
|
|
updated_at = self._parse_markdown_datetime(
|
|
frontmatter["updated_at"], "updated_at", source
|
|
)
|
|
|
|
nested_metadata = frontmatter.get("metadata", {})
|
|
if nested_metadata is None:
|
|
nested_metadata = {}
|
|
if not isinstance(nested_metadata, dict):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"'metadata' must be a mapping."
|
|
)
|
|
if any(not isinstance(key, str) for key in nested_metadata):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
"metadata field names must be strings."
|
|
)
|
|
conflicting_metadata = {"type", "updated_at"}.intersection(nested_metadata)
|
|
if conflicting_metadata:
|
|
fields = ", ".join(sorted(conflicting_metadata))
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: nested metadata "
|
|
f"duplicates reserved field(s): {fields}."
|
|
)
|
|
|
|
entities = frontmatter.get("entities", [])
|
|
relationships = frontmatter.get("relationships", [])
|
|
self._validate_markdown_mapping_list(entities, "entities", source)
|
|
self._validate_markdown_mapping_list(relationships, "relationships", source)
|
|
|
|
metadata = dict(nested_metadata)
|
|
for key, value in frontmatter.items():
|
|
if key not in self._MARKDOWN_RESERVED_FIELDS:
|
|
if key in metadata:
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: metadata field "
|
|
f"{key!r} is defined both at the top level and in 'metadata'."
|
|
)
|
|
metadata[key] = value
|
|
metadata["type"] = memory_type
|
|
metadata["updated_at"] = updated_at.isoformat()
|
|
|
|
body = "".join(lines[closing_index + 1 :])
|
|
if body.startswith("\r\n"):
|
|
body = body[2:]
|
|
elif body.startswith("\n"):
|
|
body = body[1:]
|
|
|
|
return {
|
|
"memory_id": memory_id,
|
|
"content": body,
|
|
"timestamp": created_at,
|
|
"metadata": metadata,
|
|
"entities": entities,
|
|
"relationships": relationships,
|
|
}
|
|
|
|
def _import_markdown_records(
|
|
self, memories: List[Tuple[str, Dict[str, Any]]]
|
|
) -> int:
|
|
if not memories:
|
|
return 0
|
|
|
|
state = self._snapshot_memory_state()
|
|
imported = 0
|
|
changed_ids = []
|
|
current_source = "markdown document"
|
|
current_memory_id = "unknown"
|
|
try:
|
|
for current_source, memory in memories:
|
|
current_memory_id = memory["memory_id"]
|
|
if self._markdown_record_matches(current_memory_id, memory):
|
|
imported += 1
|
|
continue
|
|
|
|
if self.exists(current_memory_id):
|
|
success = self._replace_memory_item(
|
|
current_memory_id,
|
|
memory["content"],
|
|
metadata=memory["metadata"],
|
|
entities=memory["entities"],
|
|
relationships=memory["relationships"],
|
|
timestamp=memory["timestamp"],
|
|
skip_vector=True,
|
|
skip_graph=True,
|
|
)
|
|
else:
|
|
stored_id = self.store(
|
|
memory["content"],
|
|
metadata=memory["metadata"],
|
|
entities=memory["entities"],
|
|
relationships=memory["relationships"],
|
|
memory_id=current_memory_id,
|
|
timestamp=memory["timestamp"],
|
|
skip_vector=True,
|
|
skip_graph=True,
|
|
)
|
|
success = stored_id == current_memory_id and self.exists(
|
|
current_memory_id
|
|
)
|
|
|
|
if not success:
|
|
raise RuntimeError("memory store did not confirm the requested ID")
|
|
changed_ids.append(current_memory_id)
|
|
imported += 1
|
|
except Exception as exc:
|
|
self._restore_memory_state(state)
|
|
raise RuntimeError(
|
|
f"Failed to import Markdown memory {current_memory_id!r} "
|
|
f"from {current_source}: {exc}"
|
|
) from exc
|
|
|
|
self._sync_committed_vector_changes(state, changed_ids)
|
|
return imported
|
|
|
|
def _markdown_record_matches(self, memory_id: str, memory: Dict[str, Any]) -> bool:
|
|
existing = self.memory_items.get(memory_id)
|
|
if existing is None:
|
|
return False
|
|
|
|
return (
|
|
existing.content == memory["content"]
|
|
and self._timestamp_comparison_key(existing.timestamp)
|
|
== self._timestamp_comparison_key(memory["timestamp"])
|
|
and existing.metadata == memory["metadata"]
|
|
and existing.entities == memory["entities"]
|
|
and existing.relationships == memory["relationships"]
|
|
)
|
|
|
|
def _parse_markdown_datetime(
|
|
self, value: Any, field_name: str, source: str
|
|
) -> datetime:
|
|
if isinstance(value, datetime):
|
|
parsed = value
|
|
elif isinstance(value, date):
|
|
parsed = datetime.combine(value, datetime.min.time())
|
|
elif isinstance(value, str) and value.strip():
|
|
candidate = value.strip()
|
|
if candidate.endswith("Z"):
|
|
candidate = candidate[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(candidate)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
f"'{field_name}' must be an ISO-8601 datetime."
|
|
) from exc
|
|
else:
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
f"'{field_name}' must be an ISO-8601 datetime."
|
|
)
|
|
|
|
return parsed
|
|
|
|
def _validate_markdown_mapping_list(
|
|
self, value: Any, field_name: str, source: str
|
|
) -> None:
|
|
if not isinstance(value, list):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
f"'{field_name}' must be a list."
|
|
)
|
|
for index, item in enumerate(value):
|
|
if not isinstance(item, dict):
|
|
raise ValueError(
|
|
f"Invalid Markdown frontmatter in {source}: "
|
|
f"'{field_name}[{index}]' must be a mapping."
|
|
)
|
|
|
|
# Statistics
|
|
def stats(self, **filters) -> Dict[str, Any]:
|
|
"""
|
|
Get statistics (enhance existing).
|
|
|
|
Args:
|
|
**filters: Optional filters
|
|
|
|
Returns:
|
|
Statistics dict
|
|
|
|
Example:
|
|
>>> stats = memory.stats()
|
|
>>> conv_stats = memory.stats(conversation_id="conv1")
|
|
"""
|
|
base_stats = self.get_statistics()
|
|
if filters:
|
|
base_stats["filtered_count"] = self.count(**filters)
|
|
return base_stats
|
|
|
|
def count_by_type(self) -> Dict[str, int]:
|
|
"""
|
|
Count by type.
|
|
|
|
Returns:
|
|
Dict mapping type to count
|
|
|
|
Example:
|
|
>>> counts = memory.count_by_type()
|
|
"""
|
|
counts = {}
|
|
for memory_item in self.memory_items.values():
|
|
mem_type = memory_item.metadata.get("type", "unknown")
|
|
counts[mem_type] = counts.get(mem_type, 0) + 1
|
|
return counts
|
|
|
|
def count_by_user(self) -> Dict[str, int]:
|
|
"""
|
|
Count by user.
|
|
|
|
Returns:
|
|
Dict mapping user_id to count
|
|
|
|
Example:
|
|
>>> counts = memory.count_by_user()
|
|
"""
|
|
counts = {}
|
|
for memory_item in self.memory_items.values():
|
|
user_id = memory_item.metadata.get("user_id", "unknown")
|
|
counts[user_id] = counts.get(user_id, 0) + 1
|
|
return counts
|
|
|
|
def count_by_conversation(self) -> Dict[str, int]:
|
|
"""
|
|
Count by conversation.
|
|
|
|
Returns:
|
|
Dict mapping conversation_id to count
|
|
|
|
Example:
|
|
>>> counts = memory.count_by_conversation()
|
|
"""
|
|
counts = {}
|
|
for memory_item in self.memory_items.values():
|
|
conv_id = memory_item.metadata.get("conversation_id", "unknown")
|
|
counts[conv_id] = counts.get(conv_id, 0) + 1
|
|
return counts
|