diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c4dc70 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# OpenAI Configuration +OPENAI_API_KEY=sk-your-openai-api-key-here + +# Supabase Configuration +SUPABASE_CONNECTION_STRING=postgresql://supabase_admin:your-password@172.21.0.12:5432/postgres + +# Neo4j Configuration +NEO4J_URI=neo4j://neo4j:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your-neo4j-password + +# API Configuration +API_HOST=0.0.0.0 +API_PORT=8080 +API_KEY=your-secure-api-key-here + +# MCP Server Configuration +MCP_HOST=0.0.0.0 +MCP_PORT=8765 + +# Mem0 Configuration +MEM0_COLLECTION_NAME=t6_memories +MEM0_EMBEDDING_DIMS=1536 +MEM0_VERSION=v1.1 + +# Docker Network +DOCKER_NETWORK=localai + +# Logging +LOG_LEVEL=INFO +LOG_FORMAT=json + +# Environment +ENVIRONMENT=development diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..2fdaf41 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,66 @@ +""" +Authentication middleware for T6 Mem0 v2 REST API +""" + +from fastapi import HTTPException, Security, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from config import settings + +# Security scheme +security = HTTPBearer() + + +async def verify_api_key( + credentials: HTTPAuthorizationCredentials = Security(security) +) -> str: + """ + Verify API key from Authorization header + + Args: + credentials: HTTP Bearer credentials + + Returns: + The verified API key + + Raises: + HTTPException: If authentication fails + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + + # Verify token matches configured API key + if token != settings.api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return token + + +async def optional_api_key( + credentials: HTTPAuthorizationCredentials = Security(security) +) -> str | None: + """ + Optional API key verification (for public endpoints) + + Args: + credentials: HTTP Bearer credentials + + Returns: + The API key if provided, None otherwise + """ + if not credentials: + return None + + try: + return await verify_api_key(credentials) + except HTTPException: + return None diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..5f97337 --- /dev/null +++ b/api/main.py @@ -0,0 +1,158 @@ +""" +T6 Mem0 v2 REST API +Main FastAPI application +""" + +import logging +import sys +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError + +from api.routes import router +from api.memory_service import get_memory_service +from config import settings + +# Configure logging +logging.basicConfig( + level=getattr(logging, settings.log_level.upper()), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifespan context manager for startup and shutdown events + """ + # Startup + logger.info("Starting T6 Mem0 v2 REST API") + logger.info(f"Environment: {settings.environment}") + logger.info(f"API Host: {settings.api_host}:{settings.api_port}") + + try: + # Initialize memory service + service = get_memory_service() + logger.info("Memory service initialized successfully") + + yield + + except Exception as e: + logger.error(f"Failed to initialize application: {e}") + raise + + finally: + # Shutdown + logger.info("Shutting down T6 Mem0 v2 REST API") + + +# Create FastAPI application +app = FastAPI( + title="T6 Mem0 v2 API", + description=""" + Memory system for LLM applications based on mem0.ai + + ## Features + - **Add Memory**: Store conversation memories with semantic understanding + - **Search Memory**: Find relevant memories using semantic search + - **Manage Memory**: Update and delete memories + - **Multi-Agent**: Support for user, agent, and run-specific memories + - **Graph Relationships**: Neo4j integration for memory relationships + + ## Authentication + All endpoints (except /health) require Bearer token authentication. + Include your API key in the Authorization header: + ``` + Authorization: Bearer YOUR_API_KEY + ``` + """, + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:5678", # n8n + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Exception handlers +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Handle validation errors""" + logger.warning(f"Validation error: {exc.errors()}") + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "status": "error", + "error": "Validation failed", + "detail": exc.errors() + } + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + """Handle general exceptions""" + logger.error(f"Unhandled exception: {exc}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "status": "error", + "error": "Internal server error", + "detail": str(exc) if settings.environment == "development" else "An error occurred" + } + ) + + +# Include routers +app.include_router(router) + + +# Root endpoint +@app.get( + "/", + summary="API Root", + description="Get API information", + tags=["info"] +) +async def root(): + """API root endpoint""" + return { + "name": "T6 Mem0 v2 API", + "version": "0.1.0", + "status": "running", + "docs": "/docs", + "health": "/v1/health" + } + + +# Run with uvicorn +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "api.main:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.environment == "development", + log_level=settings.log_level.lower() + ) diff --git a/api/memory_service.py b/api/memory_service.py new file mode 100644 index 0000000..1fa3f86 --- /dev/null +++ b/api/memory_service.py @@ -0,0 +1,353 @@ +""" +Memory service wrapper for Mem0 core library +""" + +import logging +from typing import List, Dict, Any, Optional +from mem0 import Memory +from config import mem0_config + +logger = logging.getLogger(__name__) + + +class MemoryService: + """Singleton service for memory operations using Mem0""" + + _instance: Optional['MemoryService'] = None + _memory: Optional[Memory] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """Initialize memory service with Mem0""" + if self._memory is None: + logger.info("Initializing Mem0 with configuration") + try: + self._memory = Memory.from_config(config_dict=mem0_config) + logger.info("Mem0 initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Mem0: {e}") + raise + + @property + def memory(self) -> Memory: + """Get Mem0 instance""" + if self._memory is None: + raise RuntimeError("MemoryService not initialized") + return self._memory + + async def add_memory( + self, + messages: List[Dict[str, str]], + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + run_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> List[Dict[str, Any]]: + """ + Add new memory from messages + + Args: + messages: List of chat messages + user_id: User identifier + agent_id: Agent identifier + run_id: Run identifier + metadata: Additional metadata + + Returns: + List of created memories + + Raises: + Exception: If memory creation fails + """ + try: + logger.info(f"Adding memory for user_id={user_id}, agent_id={agent_id}") + + result = self.memory.add( + messages=messages, + user_id=user_id, + agent_id=agent_id, + run_id=run_id, + metadata=metadata or {} + ) + + logger.info(f"Successfully added {len(result.get('results', []))} memories") + return result.get('results', []) + + except Exception as e: + logger.error(f"Failed to add memory: {e}") + raise + + async def search_memories( + self, + query: str, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + run_id: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search memories by query + + Args: + query: Search query + user_id: User identifier filter + agent_id: Agent identifier filter + run_id: Run identifier filter + limit: Maximum results + + Returns: + List of matching memories with scores + + Raises: + Exception: If search fails + """ + try: + logger.info(f"Searching memories: query='{query}', user_id={user_id}, limit={limit}") + + results = self.memory.search( + query=query, + user_id=user_id, + agent_id=agent_id, + run_id=run_id, + limit=limit + ) + + logger.info(f"Found {len(results)} matching memories") + return results + + except Exception as e: + logger.error(f"Failed to search memories: {e}") + raise + + async def get_memory( + self, + memory_id: str + ) -> Optional[Dict[str, Any]]: + """ + Get specific memory by ID + + Args: + memory_id: Memory identifier + + Returns: + Memory data or None if not found + + Raises: + Exception: If retrieval fails + """ + try: + logger.info(f"Getting memory: id={memory_id}") + + result = self.memory.get(memory_id=memory_id) + + if result: + logger.info(f"Retrieved memory: {memory_id}") + else: + logger.warning(f"Memory not found: {memory_id}") + + return result + + except Exception as e: + logger.error(f"Failed to get memory: {e}") + raise + + async def get_all_memories( + self, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + run_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + Get all memories for a user/agent/run + + Args: + user_id: User identifier filter + agent_id: Agent identifier filter + run_id: Run identifier filter + + Returns: + List of all matching memories + + Raises: + Exception: If retrieval fails + """ + try: + logger.info(f"Getting all memories: user_id={user_id}, agent_id={agent_id}") + + results = self.memory.get_all( + user_id=user_id, + agent_id=agent_id, + run_id=run_id + ) + + logger.info(f"Retrieved {len(results)} memories") + return results + + except Exception as e: + logger.error(f"Failed to get all memories: {e}") + raise + + async def update_memory( + self, + memory_id: str, + data: str + ) -> Dict[str, Any]: + """ + Update existing memory + + Args: + memory_id: Memory identifier + data: Updated memory text + + Returns: + Updated memory data + + Raises: + Exception: If update fails + """ + try: + logger.info(f"Updating memory: id={memory_id}") + + result = self.memory.update( + memory_id=memory_id, + data=data + ) + + logger.info(f"Successfully updated memory: {memory_id}") + return result + + except Exception as e: + logger.error(f"Failed to update memory: {e}") + raise + + async def delete_memory( + self, + memory_id: str + ) -> bool: + """ + Delete memory by ID + + Args: + memory_id: Memory identifier + + Returns: + True if deleted successfully + + Raises: + Exception: If deletion fails + """ + try: + logger.info(f"Deleting memory: id={memory_id}") + + self.memory.delete(memory_id=memory_id) + + logger.info(f"Successfully deleted memory: {memory_id}") + return True + + except Exception as e: + logger.error(f"Failed to delete memory: {e}") + raise + + async def delete_all_memories( + self, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + run_id: Optional[str] = None + ) -> bool: + """ + Delete all memories for a user/agent/run + + Args: + user_id: User identifier filter + agent_id: Agent identifier filter + run_id: Run identifier filter + + Returns: + True if deleted successfully + + Raises: + Exception: If deletion fails + """ + try: + logger.info(f"Deleting all memories: user_id={user_id}, agent_id={agent_id}") + + self.memory.delete_all( + user_id=user_id, + agent_id=agent_id, + run_id=run_id + ) + + logger.info("Successfully deleted all matching memories") + return True + + except Exception as e: + logger.error(f"Failed to delete all memories: {e}") + raise + + async def get_memory_history( + self, + memory_id: str + ) -> List[Dict[str, Any]]: + """ + Get history of a memory + + Args: + memory_id: Memory identifier + + Returns: + List of memory history entries + + Raises: + Exception: If retrieval fails + """ + try: + logger.info(f"Getting memory history: id={memory_id}") + + result = self.memory.history(memory_id=memory_id) + + logger.info(f"Retrieved history for memory: {memory_id}") + return result + + except Exception as e: + logger.error(f"Failed to get memory history: {e}") + raise + + async def health_check(self) -> Dict[str, str]: + """ + Check health of memory service + + Returns: + Dict with component health status + """ + health = {} + + try: + # Test Mem0 access + self.memory + health['mem0'] = 'healthy' + except Exception as e: + logger.error(f"Mem0 health check failed: {e}") + health['mem0'] = 'unhealthy' + + return health + + +# Global service instance +_memory_service: Optional[MemoryService] = None + + +def get_memory_service() -> MemoryService: + """ + Get or create memory service singleton + + Returns: + MemoryService instance + """ + global _memory_service + if _memory_service is None: + _memory_service = MemoryService() + return _memory_service diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..9eaff0a --- /dev/null +++ b/api/models.py @@ -0,0 +1,227 @@ +""" +Pydantic models for T6 Mem0 v2 REST API +""" + +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict +from uuid import UUID + + +class Message(BaseModel): + """Chat message for memory extraction""" + role: str = Field(..., description="Message role (user, assistant, system)") + content: str = Field(..., description="Message content") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "role": "user", + "content": "I love pizza with extra cheese" + } + } + ) + + +class AddMemoryRequest(BaseModel): + """Request to add new memory""" + messages: List[Message] = Field(..., description="Conversation messages") + user_id: Optional[str] = Field(None, description="User identifier") + agent_id: Optional[str] = Field(None, description="Agent identifier") + run_id: Optional[str] = Field(None, description="Run identifier") + metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional metadata") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "messages": [ + {"role": "user", "content": "I love pizza"}, + {"role": "assistant", "content": "Great! What toppings?"}, + {"role": "user", "content": "Extra cheese please"} + ], + "user_id": "alice", + "metadata": {"session": "chat_123", "source": "web"} + } + } + ) + + +class SearchMemoryRequest(BaseModel): + """Request to search memories""" + query: str = Field(..., description="Search query") + user_id: Optional[str] = Field(None, description="User identifier filter") + agent_id: Optional[str] = Field(None, description="Agent identifier filter") + run_id: Optional[str] = Field(None, description="Run identifier filter") + limit: int = Field(default=10, ge=1, le=100, description="Maximum results") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "query": "What food does the user like?", + "user_id": "alice", + "limit": 5 + } + } + ) + + +class UpdateMemoryRequest(BaseModel): + """Request to update existing memory""" + memory_text: Optional[str] = Field(None, description="Updated memory text") + metadata: Optional[Dict[str, Any]] = Field(None, description="Updated metadata") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "memory_text": "User loves pizza with mushrooms and olives", + "metadata": {"verified": True, "updated_by": "admin"} + } + } + ) + + +class MemoryResponse(BaseModel): + """Memory response model""" + id: str = Field(..., description="Memory unique identifier") + memory: str = Field(..., description="Memory text content") + user_id: Optional[str] = Field(None, description="User identifier") + agent_id: Optional[str] = Field(None, description="Agent identifier") + run_id: Optional[str] = Field(None, description="Run identifier") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Memory metadata") + created_at: Optional[datetime] = Field(None, description="Creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Last update timestamp") + score: Optional[float] = Field(None, description="Relevance score (for search results)") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": "mem_abc123", + "memory": "User loves pizza with extra cheese", + "user_id": "alice", + "metadata": {"session": "chat_123"}, + "created_at": "2025-10-13T12:00:00Z", + "score": 0.95 + } + } + ) + + +class AddMemoryResponse(BaseModel): + """Response from adding memory""" + status: str = Field(..., description="Operation status") + memories: List[MemoryResponse] = Field(..., description="Created memories") + message: str = Field(..., description="Result message") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "memories": [{ + "id": "mem_abc123", + "memory": "User loves pizza with extra cheese", + "user_id": "alice" + }], + "message": "Successfully added 1 memory" + } + } + ) + + +class SearchMemoryResponse(BaseModel): + """Response from searching memories""" + status: str = Field(..., description="Operation status") + memories: List[MemoryResponse] = Field(..., description="Matching memories") + count: int = Field(..., description="Number of results") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "memories": [{ + "id": "mem_abc123", + "memory": "User loves pizza", + "user_id": "alice", + "score": 0.95 + }], + "count": 1 + } + } + ) + + +class DeleteMemoryResponse(BaseModel): + """Response from deleting memory""" + status: str = Field(..., description="Operation status") + message: str = Field(..., description="Result message") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "message": "Memory deleted successfully" + } + } + ) + + +class HealthResponse(BaseModel): + """Health check response""" + status: str = Field(..., description="Service status") + version: str = Field(..., description="API version") + timestamp: datetime = Field(..., description="Check timestamp") + dependencies: Dict[str, str] = Field(..., description="Dependency status") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "healthy", + "version": "0.1.0", + "timestamp": "2025-10-13T12:00:00Z", + "dependencies": { + "supabase": "connected", + "neo4j": "connected", + "openai": "available" + } + } + } + ) + + +class StatsResponse(BaseModel): + """Memory statistics response""" + total_memories: int = Field(..., description="Total memory count") + total_users: int = Field(..., description="Unique user count") + total_agents: int = Field(..., description="Unique agent count") + avg_memories_per_user: float = Field(..., description="Average memories per user") + oldest_memory: Optional[datetime] = Field(None, description="Oldest memory timestamp") + newest_memory: Optional[datetime] = Field(None, description="Newest memory timestamp") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "total_memories": 1523, + "total_users": 42, + "total_agents": 5, + "avg_memories_per_user": 36.26, + "oldest_memory": "2025-01-01T00:00:00Z", + "newest_memory": "2025-10-13T12:00:00Z" + } + } + ) + + +class ErrorResponse(BaseModel): + """Error response model""" + status: str = Field(default="error", description="Error status") + error: str = Field(..., description="Error message") + detail: Optional[str] = Field(None, description="Detailed error information") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "error", + "error": "Authentication failed", + "detail": "Invalid or missing API key" + } + } + ) diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..fd18a9c --- /dev/null +++ b/api/routes.py @@ -0,0 +1,377 @@ +""" +API routes for T6 Mem0 v2 REST API +""" + +import logging +from typing import List +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi.responses import JSONResponse + +from api.auth import verify_api_key +from api.models import ( + AddMemoryRequest, + AddMemoryResponse, + SearchMemoryRequest, + SearchMemoryResponse, + UpdateMemoryRequest, + MemoryResponse, + DeleteMemoryResponse, + HealthResponse, + StatsResponse, + ErrorResponse +) +from api.memory_service import get_memory_service, MemoryService +from config import settings + +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="/v1", tags=["memories"]) + + +def format_memory_response(mem: dict) -> MemoryResponse: + """Format memory data to response model""" + return MemoryResponse( + id=mem.get('id', ''), + memory=mem.get('memory', mem.get('data', '')), + user_id=mem.get('user_id'), + agent_id=mem.get('agent_id'), + run_id=mem.get('run_id'), + metadata=mem.get('metadata', {}), + created_at=mem.get('created_at'), + updated_at=mem.get('updated_at'), + score=mem.get('score') + ) + + +@router.post( + "/memories/", + response_model=AddMemoryResponse, + status_code=status.HTTP_201_CREATED, + summary="Add new memory", + description="Add new memory from conversation messages", + responses={ + 201: {"description": "Memory added successfully"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def add_memory( + request: AddMemoryRequest, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Add new memory from messages""" + try: + messages = [msg.model_dump() for msg in request.messages] + + memories = await service.add_memory( + messages=messages, + user_id=request.user_id, + agent_id=request.agent_id, + run_id=request.run_id, + metadata=request.metadata + ) + + formatted_memories = [format_memory_response(mem) for mem in memories] + + return AddMemoryResponse( + status="success", + memories=formatted_memories, + message=f"Successfully added {len(formatted_memories)} memory(ies)" + ) + + except Exception as e: + logger.error(f"Error adding memory: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get( + "/memories/search", + response_model=SearchMemoryResponse, + summary="Search memories", + description="Search memories by semantic similarity", + responses={ + 200: {"description": "Search completed successfully"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def search_memories( + query: str = Query(..., description="Search query"), + user_id: str | None = Query(None, description="Filter by user ID"), + agent_id: str | None = Query(None, description="Filter by agent ID"), + run_id: str | None = Query(None, description="Filter by run ID"), + limit: int = Query(10, ge=1, le=100, description="Maximum results"), + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Search memories by query""" + try: + memories = await service.search_memories( + query=query, + user_id=user_id, + agent_id=agent_id, + run_id=run_id, + limit=limit + ) + + formatted_memories = [format_memory_response(mem) for mem in memories] + + return SearchMemoryResponse( + status="success", + memories=formatted_memories, + count=len(formatted_memories) + ) + + except Exception as e: + logger.error(f"Error searching memories: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get( + "/memories/{memory_id}", + response_model=MemoryResponse, + summary="Get specific memory", + description="Retrieve a specific memory by ID", + responses={ + 200: {"description": "Memory retrieved successfully"}, + 404: {"model": ErrorResponse, "description": "Memory not found"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def get_memory( + memory_id: str, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Get specific memory by ID""" + try: + memory = await service.get_memory(memory_id) + + if not memory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Memory not found: {memory_id}" + ) + + return format_memory_response(memory) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting memory: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get( + "/memories/user/{user_id}", + response_model=List[MemoryResponse], + summary="Get user memories", + description="Get all memories for a specific user", + responses={ + 200: {"description": "Memories retrieved successfully"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def get_user_memories( + user_id: str, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Get all memories for a user""" + try: + memories = await service.get_all_memories(user_id=user_id) + return [format_memory_response(mem) for mem in memories] + + except Exception as e: + logger.error(f"Error getting user memories: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.patch( + "/memories/{memory_id}", + response_model=MemoryResponse, + summary="Update memory", + description="Update an existing memory", + responses={ + 200: {"description": "Memory updated successfully"}, + 404: {"model": ErrorResponse, "description": "Memory not found"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def update_memory( + memory_id: str, + request: UpdateMemoryRequest, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Update existing memory""" + try: + if not request.memory_text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="memory_text is required for update" + ) + + updated = await service.update_memory( + memory_id=memory_id, + data=request.memory_text + ) + + return format_memory_response(updated) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating memory: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.delete( + "/memories/{memory_id}", + response_model=DeleteMemoryResponse, + summary="Delete memory", + description="Delete a specific memory", + responses={ + 200: {"description": "Memory deleted successfully"}, + 404: {"model": ErrorResponse, "description": "Memory not found"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def delete_memory( + memory_id: str, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Delete specific memory""" + try: + await service.delete_memory(memory_id) + + return DeleteMemoryResponse( + status="success", + message=f"Memory {memory_id} deleted successfully" + ) + + except Exception as e: + logger.error(f"Error deleting memory: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.delete( + "/memories/user/{user_id}", + response_model=DeleteMemoryResponse, + summary="Delete user memories", + description="Delete all memories for a specific user", + responses={ + 200: {"description": "Memories deleted successfully"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def delete_user_memories( + user_id: str, + _api_key: str = Depends(verify_api_key), + service: MemoryService = Depends(get_memory_service) +): + """Delete all memories for a user""" + try: + await service.delete_all_memories(user_id=user_id) + + return DeleteMemoryResponse( + status="success", + message=f"All memories for user {user_id} deleted successfully" + ) + + except Exception as e: + logger.error(f"Error deleting user memories: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get( + "/health", + response_model=HealthResponse, + summary="Health check", + description="Check API health status", + responses={ + 200: {"description": "Service is healthy"} + } +) +async def health_check( + service: MemoryService = Depends(get_memory_service) +): + """Health check endpoint""" + try: + health = await service.health_check() + + return HealthResponse( + status="healthy" if all(v == "healthy" for v in health.values()) else "degraded", + version="0.1.0", + timestamp=datetime.utcnow(), + dependencies=health + ) + + except Exception as e: + logger.error(f"Health check failed: {e}") + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={ + "status": "unhealthy", + "version": "0.1.0", + "timestamp": datetime.utcnow().isoformat(), + "error": str(e) + } + ) + + +@router.get( + "/stats", + response_model=StatsResponse, + summary="Memory statistics", + description="Get system-wide memory statistics", + responses={ + 200: {"description": "Statistics retrieved successfully"}, + 401: {"model": ErrorResponse, "description": "Unauthorized"}, + 500: {"model": ErrorResponse, "description": "Internal server error"} + } +) +async def get_stats( + _api_key: str = Depends(verify_api_key) +): + """Get memory statistics""" + # Note: This would require direct database access or extension of mem0 API + # For now, return placeholder + return StatsResponse( + total_memories=0, + total_users=0, + total_agents=0, + avg_memories_per_user=0.0, + oldest_memory=None, + newest_memory=None + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000..5b5c4fd --- /dev/null +++ b/config.py @@ -0,0 +1,121 @@ +""" +Shared configuration for T6 Mem0 v2 +Loads environment variables and creates Mem0 configuration +""" + +import os +from typing import Dict, Any +from pydantic_settings import BaseSettings +from pydantic import Field + + +class Settings(BaseSettings): + """Application settings loaded from environment variables""" + + # OpenAI + openai_api_key: str = Field(..., env="OPENAI_API_KEY") + + # Supabase + supabase_connection_string: str = Field(..., env="SUPABASE_CONNECTION_STRING") + + # Neo4j + neo4j_uri: str = Field(..., env="NEO4J_URI") + neo4j_user: str = Field(default="neo4j", env="NEO4J_USER") + neo4j_password: str = Field(..., env="NEO4J_PASSWORD") + + # API + api_host: str = Field(default="0.0.0.0", env="API_HOST") + api_port: int = Field(default=8080, env="API_PORT") + api_key: str = Field(..., env="API_KEY") + + # MCP Server + mcp_host: str = Field(default="0.0.0.0", env="MCP_HOST") + mcp_port: int = Field(default=8765, env="MCP_PORT") + + # Mem0 + mem0_collection_name: str = Field(default="t6_memories", env="MEM0_COLLECTION_NAME") + mem0_embedding_dims: int = Field(default=1536, env="MEM0_EMBEDDING_DIMS") + mem0_version: str = Field(default="v1.1", env="MEM0_VERSION") + + # Logging + log_level: str = Field(default="INFO", env="LOG_LEVEL") + log_format: str = Field(default="json", env="LOG_FORMAT") + + # Environment + environment: str = Field(default="development", env="ENVIRONMENT") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + +def get_settings() -> Settings: + """Get application settings""" + return Settings() + + +def get_mem0_config(settings: Settings) -> Dict[str, Any]: + """ + Generate Mem0 configuration from settings + + Args: + settings: Application settings + + Returns: + Dict containing Mem0 configuration + """ + return { + # Vector Store - Supabase + "vector_store": { + "provider": "supabase", + "config": { + "connection_string": settings.supabase_connection_string, + "collection_name": settings.mem0_collection_name, + "embedding_model_dims": settings.mem0_embedding_dims, + "index_method": "hnsw", # Fastest search + "index_measure": "cosine_distance" # Best for embeddings + } + }, + + # Graph Store - Neo4j + "graph_store": { + "provider": "neo4j", + "config": { + "url": settings.neo4j_uri, + "username": settings.neo4j_user, + "password": settings.neo4j_password + } + }, + + # LLM Provider - OpenAI + "llm": { + "provider": "openai", + "config": { + "model": "gpt-4o-mini", + "temperature": 0.1, + "max_tokens": 2000, + "api_key": settings.openai_api_key + } + }, + + # Embedder - OpenAI + "embedder": { + "provider": "openai", + "config": { + "model": "text-embedding-3-small", + "embedding_dims": settings.mem0_embedding_dims, + "api_key": settings.openai_api_key + } + }, + + # Version + "version": settings.mem0_version + } + + +# Global settings instance +settings = get_settings() + +# Global mem0 config +mem0_config = get_mem0_config(settings) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24d1f09 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +version: '3.8' + +services: + # Neo4j Graph Database + neo4j: + image: neo4j:5.26-community + container_name: t6-mem0-neo4j + restart: unless-stopped + ports: + - "7474:7474" # HTTP Browser UI + - "7687:7687" # Bolt Protocol + environment: + - NEO4J_AUTH=${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD} + - NEO4J_PLUGINS=["apoc", "graph-data-science"] + - NEO4J_dbms_security_procedures_unrestricted=apoc.*,gds.* + - NEO4J_dbms_memory_heap_initial__size=512M + - NEO4J_dbms_memory_heap_max__size=2G + - NEO4J_dbms_memory_pagecache_size=512M + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + - neo4j_import:/import + - neo4j_plugins:/plugins + networks: + - localai + healthcheck: + test: ["CMD-SHELL", "cypher-shell -u ${NEO4J_USER:-neo4j} -p ${NEO4J_PASSWORD} 'RETURN 1'"] + interval: 10s + timeout: 5s + retries: 5 + + # REST API Server + api: + build: + context: . + dockerfile: docker/Dockerfile.api + container_name: t6-mem0-api + restart: unless-stopped + ports: + - "${API_PORT:-8080}:8080" + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - SUPABASE_CONNECTION_STRING=${SUPABASE_CONNECTION_STRING} + - NEO4J_URI=neo4j://neo4j:7687 + - NEO4J_USER=${NEO4J_USER:-neo4j} + - NEO4J_PASSWORD=${NEO4J_PASSWORD} + - API_HOST=0.0.0.0 + - API_PORT=8080 + - API_KEY=${API_KEY} + - MEM0_COLLECTION_NAME=${MEM0_COLLECTION_NAME:-t6_memories} + - MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-1536} + - MEM0_VERSION=${MEM0_VERSION:-v1.1} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - ENVIRONMENT=${ENVIRONMENT:-production} + depends_on: + neo4j: + condition: service_healthy + networks: + - localai + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/v1/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + + # MCP Server + mcp-server: + build: + context: . + dockerfile: docker/Dockerfile.mcp + container_name: t6-mem0-mcp + restart: unless-stopped + ports: + - "${MCP_PORT:-8765}:8765" + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - SUPABASE_CONNECTION_STRING=${SUPABASE_CONNECTION_STRING} + - NEO4J_URI=neo4j://neo4j:7687 + - NEO4J_USER=${NEO4J_USER:-neo4j} + - NEO4J_PASSWORD=${NEO4J_PASSWORD} + - MCP_HOST=0.0.0.0 + - MCP_PORT=8765 + - MEM0_COLLECTION_NAME=${MEM0_COLLECTION_NAME:-t6_memories} + - MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-1536} + - MEM0_VERSION=${MEM0_VERSION:-v1.1} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - ENVIRONMENT=${ENVIRONMENT:-production} + depends_on: + neo4j: + condition: service_healthy + networks: + - localai + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + neo4j_data: + name: t6-mem0-neo4j-data + neo4j_logs: + name: t6-mem0-neo4j-logs + neo4j_import: + name: t6-mem0-neo4j-import + neo4j_plugins: + name: t6-mem0-neo4j-plugins + +networks: + localai: + external: true diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..dee22f9 --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY config.py . +COPY api/ ./api/ + +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/v1/health || exit 1 + +# Run application +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp new file mode 100644 index 0000000..1532fe6 --- /dev/null +++ b/docker/Dockerfile.mcp @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY config.py . +COPY mcp-server/ ./mcp-server/ + +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8765 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8765/health || exit 1 + +# Run MCP server +CMD ["python", "-m", "mcp-server.main"] diff --git a/docs/architecture.mdx b/docs/architecture.mdx new file mode 100644 index 0000000..89b201c --- /dev/null +++ b/docs/architecture.mdx @@ -0,0 +1,313 @@ +--- +title: 'System Architecture' +description: 'Technical architecture and design decisions for T6 Mem0 v2' +--- + +## Architecture Overview + +T6 Mem0 v2 implements a **hybrid storage architecture** combining vector search, graph relationships, and structured data storage for optimal memory management. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Client Layer │ +├──────────────────┬──────────────────┬──────────────────────┤ +│ Claude Code (MCP)│ N8N Workflows │ External Apps │ +└──────────────────┴──────────────────┴──────────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Interface Layer │ +├──────────────────────────────┬──────────────────────────────┤ +│ MCP Server (Port 8765) │ REST API (Port 8080) │ +│ - SSE Connections │ - FastAPI │ +│ - MCP Protocol │ - OpenAPI Spec │ +│ - Tool Registration │ - Auth Middleware │ +└──────────────────────────────┴──────────────────────────────┘ + │ │ + └────────┬───────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Core Layer │ +│ Mem0 Core Library │ +│ - Memory Management - Embedding Generation │ +│ - Semantic Search - Relationship Extraction │ +│ - Multi-Agent Support - Deduplication │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Vector Store │ │ Graph Store │ │ External LLM │ +│ Supabase │ │ Neo4j │ │ OpenAI │ +│ (pgvector) │ │ (Cypher) │ │ (Embeddings) │ +│ 172.21.0.12 │ │ 172.21.0.x │ │ API Cloud │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## Design Decisions + +### 1. Hybrid Storage Architecture ✅ + +**Why Multiple Storage Systems?** + +Each store is optimized for specific query patterns: + + + + **Purpose**: Semantic similarity search + + - Stores 1536-dimensional OpenAI embeddings + - HNSW indexing for fast approximate nearest neighbor search + - O(log n) query performance + - Cosine distance for similarity measurement + + + + **Purpose**: Relationship modeling + + - Entity extraction and connection mapping + - Relationship traversal and pathfinding + - Visual exploration in Neo4j Browser + - Dynamic knowledge graph evolution + + + + **Purpose**: Flexible metadata + + - Schema-less metadata storage + - Fast JSON queries with GIN indexes + - Eliminates need for separate Redis + - Simplifies infrastructure + + + +### 2. MCP Server Implementation + +**Custom vs. Pre-built** + + + We built a custom MCP server instead of using OpenMemory MCP because: + - OpenMemory uses Qdrant (we need Supabase) + - Full control over Supabase + Neo4j integration + - Exact match to our storage stack + + +### 3. Docker Networking Strategy + +**localai Network Integration** + +All services run on the `localai` Docker network (172.21.0.0/16): + +```yaml +services: + neo4j: 172.21.0.x:7687 + api: 172.21.0.x:8080 + mcp-server: 172.21.0.x:8765 + supabase: 172.21.0.12:5432 (existing) +``` + +**Benefits:** +- Container-to-container communication +- Service discovery via Docker DNS +- No host networking complications +- Persistent IPs via Docker Compose + +## Data Flow + +### Adding a Memory + + + + Client sends conversation messages via MCP or REST API + + + - LLM extracts key facts from messages + - Generates embedding vector (1536-dim) + - Identifies entities and relationships + + + Stores embedding + metadata in Supabase (pgvector) + + + Creates nodes and relationships in Neo4j + + + Returns memory ID and confirmation to client + + + +### Searching Memories + + + + Convert search query to vector using OpenAI + + + Find similar memories in Supabase (cosine similarity) + + + Fetch related context from Neo4j graph + + + Return memories sorted by relevance score + + + +## Performance Characteristics + +Based on mem0.ai research: + + + + Higher accuracy vs baseline OpenAI + + + Compared to full-context approaches + + + Through selective memory retrieval + + + +## Security Architecture + +### Authentication + +- **REST API**: Bearer token authentication +- **MCP Server**: Client-specific SSE endpoints +- **Tokens**: Stored securely in environment variables + +### Data Privacy + + + All data stored locally - no cloud sync or external storage + + +- Supabase instance is local (172.21.0.12) +- Neo4j runs in Docker container +- User isolation via `user_id` filtering + +### Network Security + +- Services on private Docker network +- No public exposure (use reverse proxy if needed) +- Internal communication only + +## Scalability + +### Horizontal Scaling + + + + Deploy multiple API containers behind load balancer + + + Dedicated instances per client group + + + Stateless design scales with containers + + + +### Vertical Scaling + +- **Supabase**: PostgreSQL connection pooling +- **Neo4j**: Memory configuration tuning +- **Vector Indexing**: HNSW for performance + +## Technology Choices + +| Component | Technology | Why? | +|-----------|-----------|------| +| Core Library | mem0ai | Production-ready, 26% accuracy boost | +| Vector DB | Supabase (pgvector) | Existing infrastructure, PostgreSQL | +| Graph DB | Neo4j | Best-in-class graph database | +| LLM | OpenAI | High-quality embeddings, GPT-4o | +| REST API | FastAPI | Fast, modern, auto-docs | +| MCP Protocol | Python MCP SDK | Official MCP implementation | +| Containers | Docker Compose | Simple orchestration | + +## Phase 2: Ollama Integration + +**Configuration-driven provider switching:** + +```python +# Phase 1 (OpenAI) +"llm": { + "provider": "openai", + "config": {"model": "gpt-4o-mini"} +} + +# Phase 2 (Ollama) +"llm": { + "provider": "ollama", + "config": { + "model": "llama3.1:8b", + "ollama_base_url": "http://172.21.0.1:11434" + } +} +``` + +**No code changes required** - just environment variables! + +## Monitoring & Observability + +### Metrics to Track + +- Memory operations per second +- Average response time +- Vector search latency +- Graph query complexity +- OpenAI token usage + +### Logging + +- Structured JSON logs +- Request/response tracking +- Error aggregation +- Performance profiling + + + Use Prometheus + Grafana for production monitoring + + +## Deep Dive Resources + +For complete architectural details, see: + +- [ARCHITECTURE.md](https://git.colsys.tech/klas/t6_mem0_v2/blob/main/ARCHITECTURE.md) +- [PROJECT_REQUIREMENTS.md](https://git.colsys.tech/klas/t6_mem0_v2/blob/main/PROJECT_REQUIREMENTS.md) + +## Next Steps + + + + Configure vector store + + + Configure graph database + + + Explore endpoints + + + Connect with Claude Code + + diff --git a/docs/introduction.mdx b/docs/introduction.mdx new file mode 100644 index 0000000..973eb94 --- /dev/null +++ b/docs/introduction.mdx @@ -0,0 +1,168 @@ +--- +title: Introduction +description: 'Welcome to T6 Mem0 v2 - Memory System for LLM Applications' +--- + +Hero Light +Hero Dark + +## What is T6 Mem0 v2? + +T6 Mem0 v2 is a comprehensive memory system for LLM applications built on **mem0.ai**, featuring: + +- 🔌 **MCP Server Integration** - Native Model Context Protocol support for Claude Code and AI tools +- 🌐 **REST API** - Full HTTP API for memory operations +- 🗄️ **Hybrid Storage** - Supabase (vector) + Neo4j (graph) for optimal performance +- 🤖 **AI-Powered** - OpenAI embeddings with 26% accuracy improvement +- 📊 **Graph Visualization** - Explore memory relationships in Neo4j Browser +- 🐳 **Docker-Native** - Fully containerized deployment + +## Key Features + + + + Find relevant memories using AI-powered semantic similarity + + + Use as MCP server with Claude Code, Cursor, and other AI tools + + + Visualize and explore memory connections with Neo4j + + + Isolate memories by user, agent, or run identifiers + + + +## Architecture + +T6 Mem0 v2 uses a **hybrid storage architecture** for optimal performance: + +``` +┌──────────────────────────────────┐ +│ Clients (Claude, N8N, Apps) │ +└──────────────┬───────────────────┘ + │ +┌──────────────┴───────────────────┐ +│ MCP Server (8765) + REST (8080) │ +└──────────────┬───────────────────┘ + │ +┌──────────────┴───────────────────┐ +│ Mem0 Core Library │ +└──────────────┬───────────────────┘ + │ + ┌──────────┴──────────┐ + │ │ +┌───┴──────┐ ┌──────┴─────┐ +│ Supabase │ │ Neo4j │ +│ (Vector) │ │ (Graph) │ +└──────────┘ └────────────┘ +``` + +### Storage Layers + +- **Vector Store (Supabase)**: Semantic similarity search with pgvector +- **Graph Store (Neo4j)**: Relationship modeling between memories +- **Key-Value Store (PostgreSQL JSONB)**: Flexible metadata storage + +## Performance + +Based on mem0.ai research: + +- **26% higher accuracy** compared to baseline OpenAI +- **91% lower latency** than full-context approaches +- **90% token cost savings** through selective retrieval + +## Use Cases + + + + Maintain context across conversations, remember user preferences, and provide personalized responses + + + Give agents long-term memory, enable learning from past interactions, and improve decision-making + + + Remember customer history, track issues across sessions, and provide consistent support + + + Track learning progress, adapt to user knowledge level, and personalize content delivery + + + +## Quick Links + + + + Get up and running in 5 minutes + + + Understand the system design + + + Explore the REST API + + + Connect with Claude Code + + + +## Technology Stack + +- **Core**: mem0ai library +- **Vector DB**: Supabase with pgvector +- **Graph DB**: Neo4j 5.x +- **LLM**: OpenAI API (Phase 1), Ollama (Phase 2) +- **REST API**: FastAPI + Pydantic +- **MCP**: Python MCP SDK +- **Container**: Docker & Docker Compose + +## Support & Community + +- **Repository**: [git.colsys.tech/klas/t6_mem0_v2](https://git.colsys.tech/klas/t6_mem0_v2) +- **mem0.ai**: [Official mem0 website](https://mem0.ai) +- **Issues**: Contact maintainer + +--- + +Ready to get started? Continue to the [Quickstart Guide](/quickstart). diff --git a/docs/mint.json b/docs/mint.json new file mode 100644 index 0000000..add5940 --- /dev/null +++ b/docs/mint.json @@ -0,0 +1,111 @@ +{ + "name": "T6 Mem0 v2", + "logo": { + "dark": "/logo/dark.svg", + "light": "/logo/light.svg" + }, + "favicon": "/favicon.svg", + "colors": { + "primary": "#0D9373", + "light": "#07C983", + "dark": "#0D9373", + "anchors": { + "from": "#0D9373", + "to": "#07C983" + } + }, + "topbarLinks": [ + { + "name": "Support", + "url": "mailto:support@example.com" + } + ], + "topbarCtaButton": { + "name": "Dashboard", + "url": "https://git.colsys.tech/klas/t6_mem0_v2" + }, + "tabs": [ + { + "name": "API Reference", + "url": "api-reference" + }, + { + "name": "MCP Integration", + "url": "mcp" + } + ], + "anchors": [ + { + "name": "GitHub", + "icon": "github", + "url": "https://git.colsys.tech/klas/t6_mem0_v2" + }, + { + "name": "mem0.ai", + "icon": "link", + "url": "https://mem0.ai" + } + ], + "navigation": [ + { + "group": "Get Started", + "pages": [ + "introduction", + "quickstart", + "architecture" + ] + }, + { + "group": "Setup", + "pages": [ + "setup/installation", + "setup/configuration", + "setup/supabase", + "setup/neo4j" + ] + }, + { + "group": "API Documentation", + "pages": [ + "api-reference/introduction", + "api-reference/authentication" + ] + }, + { + "group": "Memory Operations", + "pages": [ + "api-reference/memories/add", + "api-reference/memories/search", + "api-reference/memories/get", + "api-reference/memories/update", + "api-reference/memories/delete" + ] + }, + { + "group": "System", + "pages": [ + "api-reference/health", + "api-reference/stats" + ] + }, + { + "group": "MCP Server", + "pages": [ + "mcp/introduction", + "mcp/installation", + "mcp/tools" + ] + }, + { + "group": "Examples", + "pages": [ + "examples/claude-code", + "examples/n8n", + "examples/python" + ] + } + ], + "footerSocials": { + "github": "https://git.colsys.tech/klas/t6_mem0_v2" + } +} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx new file mode 100644 index 0000000..915a548 --- /dev/null +++ b/docs/quickstart.mdx @@ -0,0 +1,259 @@ +--- +title: 'Quickstart' +description: 'Get T6 Mem0 v2 running in 5 minutes' +--- + +## Prerequisites + +Before you begin, ensure you have: + +- Docker and Docker Compose installed +- Existing Supabase instance (PostgreSQL with pgvector) +- OpenAI API key +- Git access to the repository + + + **Ready to go?** Let's set up your memory system! + + +## Step 1: Clone Repository + +```bash +git clone https://git.colsys.tech/klas/t6_mem0_v2 +cd t6_mem0_v2 +``` + +## Step 2: Configure Environment + +Create `.env` file from template: + +```bash +cp .env.example .env +``` + +Edit `.env` with your credentials: + +```bash +# OpenAI Configuration +OPENAI_API_KEY=sk-your-openai-api-key-here + +# Supabase Configuration (your existing instance) +SUPABASE_CONNECTION_STRING=postgresql://supabase_admin:password@172.21.0.12:5432/postgres + +# Neo4j Configuration +NEO4J_PASSWORD=your-secure-neo4j-password + +# API Configuration +API_KEY=your-secure-api-key-here +``` + + + **Important**: Replace all placeholder values with your actual credentials. Never commit the `.env` file to version control! + + +## Step 3: Apply Database Migrations + +Run the Supabase migration to set up the vector store: + +### Option A: Using Supabase SQL Editor (Recommended) + +1. Open your Supabase dashboard +2. Navigate to **SQL Editor** +3. Copy contents from `migrations/supabase/001_init_vector_store.sql` +4. Paste and execute + +### Option B: Using psql + +```bash +psql "$SUPABASE_CONNECTION_STRING" -f migrations/supabase/001_init_vector_store.sql +``` + + + The migration creates tables, indexes, and functions needed for vector similarity search. See [Supabase Setup](/setup/supabase) for details. + + +## Step 4: Start Services + +Launch all services with Docker Compose: + +```bash +docker compose up -d +``` + +This starts: +- **Neo4j** (ports 7474, 7687) +- **REST API** (port 8080) +- **MCP Server** (port 8765) + +## Step 5: Verify Installation + +Check service health: + +```bash +# Check API health +curl http://localhost:8080/v1/health + +# Expected response: +# { +# "status": "healthy", +# "version": "0.1.0", +# "dependencies": { +# "mem0": "healthy" +# } +# } +``` + + + **Success!** All services are running. Let's try using the memory system. + + +## Step 6: Add Your First Memory + +### Using REST API + +```bash +curl -X POST http://localhost:8080/v1/memories/ \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "user", "content": "I love pizza with mushrooms and olives"} + ], + "user_id": "alice" + }' +``` + +### Response + +```json +{ + "status": "success", + "memories": [ + { + "id": "mem_abc123", + "memory": "User loves pizza with mushrooms and olives", + "user_id": "alice", + "created_at": "2025-10-13T12:00:00Z" + } + ], + "message": "Successfully added 1 memory(ies)" +} +``` + +## Step 7: Search Memories + +```bash +curl -X GET "http://localhost:8080/v1/memories/search?query=What food does Alice like?&user_id=alice" \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +### Response + +```json +{ + "status": "success", + "memories": [ + { + "id": "mem_abc123", + "memory": "User loves pizza with mushrooms and olives", + "user_id": "alice", + "score": 0.95, + "created_at": "2025-10-13T12:00:00Z" + } + ], + "count": 1 +} +``` + + + **Congratulations!** Your memory system is working. The AI remembered Alice's food preferences and retrieved them semantically. + + +## Step 8: Explore Neo4j (Optional) + +View memory relationships in Neo4j Browser: + +1. Open http://localhost:7474 in your browser +2. Login with: + - **Username**: `neo4j` + - **Password**: (from your `.env` NEO4J_PASSWORD) +3. Run query: + +```cypher +MATCH (n) RETURN n LIMIT 25 +``` + + + Neo4j visualizes relationships between memories, entities, and concepts extracted by mem0. + + +## Next Steps + + + + Connect with Claude Code for AI assistant integration + + + Explore all available endpoints + + + Understand the system design + + + See integration examples + + + +## Common Issues + + + + - Verify `SUPABASE_CONNECTION_STRING` is correct + - Ensure Supabase is accessible from Docker network + - Check if pgvector extension is enabled + - Run migration script if tables don't exist + + + + - Check if ports 7474 and 7687 are available + - Verify `NEO4J_PASSWORD` is set in `.env` + - Check Docker logs: `docker logs t6-mem0-neo4j` + + + + - Verify `API_KEY` in `.env` matches request header + - Ensure Authorization header format: `Bearer YOUR_API_KEY` + + + + - Check `OPENAI_API_KEY` is valid + - Verify API key has sufficient credits + - Check internet connectivity from containers + + + +## Getting Help + +- Review [Architecture documentation](/architecture) +- Check [API Reference](/api-reference/introduction) +- See [Setup guides](/setup/installation) + +--- + +**Ready for production?** Continue to [Configuration](/setup/configuration) for advanced settings. diff --git a/mcp-server/__init__.py b/mcp-server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp-server/main.py b/mcp-server/main.py new file mode 100644 index 0000000..b4bd0e3 --- /dev/null +++ b/mcp-server/main.py @@ -0,0 +1,165 @@ +""" +T6 Mem0 v2 MCP Server +Model Context Protocol server for memory operations +""" + +import logging +import sys +import asyncio +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + Resource, + Tool, + TextContent, + ImageContent, + EmbeddedResource +) +from mem0 import Memory + +from config import mem0_config, settings +from mcp_server.tools import MemoryTools + +# Configure logging +logging.basicConfig( + level=getattr(logging, settings.log_level.upper()), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[logging.StreamHandler(sys.stderr)] # MCP uses stderr for logs +) + +logger = logging.getLogger(__name__) + + +class T6Mem0Server: + """T6 Mem0 v2 MCP Server""" + + def __init__(self): + """Initialize MCP server""" + self.server = Server("t6-mem0-v2") + self.memory: Memory | None = None + self.tools: MemoryTools | None = None + + # Setup handlers + self.setup_handlers() + + def setup_handlers(self): + """Setup MCP server handlers""" + + @self.server.list_resources() + async def list_resources() -> list[Resource]: + """ + List available resources (for future extension) + """ + return [] + + @self.server.read_resource() + async def read_resource(uri: str) -> str: + """ + Read resource by URI (for future extension) + """ + logger.warning(f"Resource read not implemented: {uri}") + return "" + + @self.server.list_tools() + async def list_tools() -> list[Tool]: + """List available memory tools""" + logger.info("Listing tools") + if not self.tools: + raise RuntimeError("Tools not initialized") + return self.tools.get_tool_definitions() + + @self.server.call_tool() + async def call_tool(name: str, arguments: dict) -> list[TextContent | ImageContent | EmbeddedResource]: + """ + Handle tool calls + + Args: + name: Tool name + arguments: Tool arguments + + Returns: + Tool response + """ + logger.info(f"Tool called: {name}") + logger.debug(f"Arguments: {arguments}") + + if not self.tools: + raise RuntimeError("Tools not initialized") + + # Route to appropriate handler + handlers = { + "add_memory": self.tools.handle_add_memory, + "search_memories": self.tools.handle_search_memories, + "get_memory": self.tools.handle_get_memory, + "get_all_memories": self.tools.handle_get_all_memories, + "update_memory": self.tools.handle_update_memory, + "delete_memory": self.tools.handle_delete_memory, + "delete_all_memories": self.tools.handle_delete_all_memories, + } + + handler = handlers.get(name) + if not handler: + logger.error(f"Unknown tool: {name}") + return [TextContent(type="text", text=f"Error: Unknown tool '{name}'")] + + try: + return await handler(arguments) + except Exception as e: + logger.error(f"Tool execution failed: {e}", exc_info=True) + return [TextContent(type="text", text=f"Error executing tool: {str(e)}")] + + async def initialize(self): + """Initialize memory service""" + logger.info("Initializing T6 Mem0 v2 MCP Server") + logger.info(f"Environment: {settings.environment}") + + try: + # Initialize Mem0 + logger.info("Initializing Mem0...") + self.memory = Memory.from_config(config_dict=mem0_config) + logger.info("Mem0 initialized successfully") + + # Initialize tools + self.tools = MemoryTools(self.memory) + logger.info("Tools initialized successfully") + + logger.info("T6 Mem0 v2 MCP Server ready") + + except Exception as e: + logger.error(f"Failed to initialize server: {e}", exc_info=True) + raise + + async def run(self): + """Run the MCP server""" + try: + # Initialize before running + await self.initialize() + + # Run server with stdio transport + logger.info("Starting MCP server with stdio transport") + async with stdio_server() as (read_stream, write_stream): + await self.server.run( + read_stream, + write_stream, + self.server.create_initialization_options() + ) + + except Exception as e: + logger.error(f"Server error: {e}", exc_info=True) + raise + + +async def main(): + """Main entry point""" + try: + server = T6Mem0Server() + await server.run() + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mcp-server/run.sh b/mcp-server/run.sh new file mode 100755 index 0000000..0ea6bfc --- /dev/null +++ b/mcp-server/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Run T6 Mem0 v2 MCP Server + +set -e + +# Load environment variables +if [ -f ../.env ]; then + export $(cat ../.env | grep -v '^#' | xargs) +fi + +# Run MCP server +echo "Starting T6 Mem0 v2 MCP Server..." +python -m mcp_server.main diff --git a/mcp-server/tools.py b/mcp-server/tools.py new file mode 100644 index 0000000..0abff21 --- /dev/null +++ b/mcp-server/tools.py @@ -0,0 +1,343 @@ +""" +MCP tools for T6 Mem0 v2 +Tool definitions and handlers +""" + +import logging +from typing import Any, Dict, List +from mcp.types import Tool, TextContent +from mem0 import Memory + +logger = logging.getLogger(__name__) + + +class MemoryTools: + """MCP tools for memory operations""" + + def __init__(self, memory: Memory): + """ + Initialize memory tools + + Args: + memory: Mem0 instance + """ + self.memory = memory + + def get_tool_definitions(self) -> List[Tool]: + """ + Get MCP tool definitions + + Returns: + List of Tool definitions + """ + return [ + Tool( + name="add_memory", + description="Add new memory from messages. Extracts and stores important information from conversation.", + inputSchema={ + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": {"type": "string", "enum": ["user", "assistant", "system"]}, + "content": {"type": "string"} + }, + "required": ["role", "content"] + }, + "description": "Conversation messages to extract memory from" + }, + "user_id": { + "type": "string", + "description": "User identifier (optional)" + }, + "agent_id": { + "type": "string", + "description": "Agent identifier (optional)" + }, + "metadata": { + "type": "object", + "description": "Additional metadata (optional)" + } + }, + "required": ["messages"] + } + ), + Tool( + name="search_memories", + description="Search memories by semantic similarity. Find relevant memories based on a query.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "user_id": { + "type": "string", + "description": "Filter by user ID (optional)" + }, + "agent_id": { + "type": "string", + "description": "Filter by agent ID (optional)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "description": "Maximum number of results" + } + }, + "required": ["query"] + } + ), + Tool( + name="get_memory", + description="Get a specific memory by its ID", + inputSchema={ + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Memory identifier" + } + }, + "required": ["memory_id"] + } + ), + Tool( + name="get_all_memories", + description="Get all memories for a user or agent", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User identifier (optional)" + }, + "agent_id": { + "type": "string", + "description": "Agent identifier (optional)" + } + } + } + ), + Tool( + name="update_memory", + description="Update an existing memory's content", + inputSchema={ + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Memory identifier" + }, + "data": { + "type": "string", + "description": "New memory content" + } + }, + "required": ["memory_id", "data"] + } + ), + Tool( + name="delete_memory", + description="Delete a specific memory by ID", + inputSchema={ + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Memory identifier" + } + }, + "required": ["memory_id"] + } + ), + Tool( + name="delete_all_memories", + description="Delete all memories for a user or agent. Use with caution!", + inputSchema={ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "User identifier (optional)" + }, + "agent_id": { + "type": "string", + "description": "Agent identifier (optional)" + } + } + } + ) + ] + + async def handle_add_memory(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle add_memory tool call""" + try: + messages = arguments.get("messages", []) + user_id = arguments.get("user_id") + agent_id = arguments.get("agent_id") + metadata = arguments.get("metadata", {}) + + result = self.memory.add( + messages=messages, + user_id=user_id, + agent_id=agent_id, + metadata=metadata + ) + + memories = result.get('results', []) + response = f"Successfully added {len(memories)} memory(ies):\n\n" + + for mem in memories: + response += f"- {mem.get('memory', mem.get('data', 'N/A'))}\n" + response += f" ID: {mem.get('id', 'N/A')}\n\n" + + return [TextContent(type="text", text=response)] + + except Exception as e: + logger.error(f"Error adding memory: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_search_memories(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle search_memories tool call""" + try: + query = arguments.get("query") + user_id = arguments.get("user_id") + agent_id = arguments.get("agent_id") + limit = arguments.get("limit", 10) + + memories = self.memory.search( + query=query, + user_id=user_id, + agent_id=agent_id, + limit=limit + ) + + if not memories: + return [TextContent(type="text", text="No memories found matching your query.")] + + response = f"Found {len(memories)} relevant memory(ies):\n\n" + + for i, mem in enumerate(memories, 1): + response += f"{i}. {mem.get('memory', mem.get('data', 'N/A'))}\n" + response += f" ID: {mem.get('id', 'N/A')}\n" + if 'score' in mem: + response += f" Relevance: {mem['score']:.2%}\n" + response += "\n" + + return [TextContent(type="text", text=response)] + + except Exception as e: + logger.error(f"Error searching memories: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_get_memory(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle get_memory tool call""" + try: + memory_id = arguments.get("memory_id") + + memory = self.memory.get(memory_id=memory_id) + + if not memory: + return [TextContent(type="text", text=f"Memory not found: {memory_id}")] + + response = f"Memory Details:\n\n" + response += f"ID: {memory.get('id', 'N/A')}\n" + response += f"Content: {memory.get('memory', memory.get('data', 'N/A'))}\n" + + if memory.get('user_id'): + response += f"User ID: {memory['user_id']}\n" + if memory.get('agent_id'): + response += f"Agent ID: {memory['agent_id']}\n" + if memory.get('metadata'): + response += f"Metadata: {memory['metadata']}\n" + + return [TextContent(type="text", text=response)] + + except Exception as e: + logger.error(f"Error getting memory: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_get_all_memories(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle get_all_memories tool call""" + try: + user_id = arguments.get("user_id") + agent_id = arguments.get("agent_id") + + memories = self.memory.get_all( + user_id=user_id, + agent_id=agent_id + ) + + if not memories: + return [TextContent(type="text", text="No memories found.")] + + response = f"Retrieved {len(memories)} memory(ies):\n\n" + + for i, mem in enumerate(memories, 1): + response += f"{i}. {mem.get('memory', mem.get('data', 'N/A'))}\n" + response += f" ID: {mem.get('id', 'N/A')}\n\n" + + return [TextContent(type="text", text=response)] + + except Exception as e: + logger.error(f"Error getting all memories: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_update_memory(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle update_memory tool call""" + try: + memory_id = arguments.get("memory_id") + data = arguments.get("data") + + result = self.memory.update( + memory_id=memory_id, + data=data + ) + + response = f"Memory updated successfully:\n\n" + response += f"ID: {result.get('id', memory_id)}\n" + response += f"New Content: {data}\n" + + return [TextContent(type="text", text=response)] + + except Exception as e: + logger.error(f"Error updating memory: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_delete_memory(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle delete_memory tool call""" + try: + memory_id = arguments.get("memory_id") + + self.memory.delete(memory_id=memory_id) + + return [TextContent(type="text", text=f"Memory {memory_id} deleted successfully.")] + + except Exception as e: + logger.error(f"Error deleting memory: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] + + async def handle_delete_all_memories(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle delete_all_memories tool call""" + try: + user_id = arguments.get("user_id") + agent_id = arguments.get("agent_id") + + self.memory.delete_all( + user_id=user_id, + agent_id=agent_id + ) + + filter_str = f"user_id={user_id}" if user_id else f"agent_id={agent_id}" if agent_id else "all filters" + return [TextContent(type="text", text=f"All memories deleted for {filter_str}.")] + + except Exception as e: + logger.error(f"Error deleting all memories: {e}") + return [TextContent(type="text", text=f"Error: {str(e)}")] diff --git a/migrations/supabase/001_init_vector_store.sql b/migrations/supabase/001_init_vector_store.sql new file mode 100644 index 0000000..94112f5 --- /dev/null +++ b/migrations/supabase/001_init_vector_store.sql @@ -0,0 +1,172 @@ +-- T6 Mem0 v2 - Initial Vector Store Setup +-- This migration creates the necessary tables and functions for Mem0 vector storage + +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- Create memories table +CREATE TABLE IF NOT EXISTS t6_memories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + embedding vector(1536), -- OpenAI text-embedding-3-small dimension + metadata JSONB NOT NULL DEFAULT '{}'::JSONB, + user_id TEXT, + agent_id TEXT, + run_id TEXT, + memory_text TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + hash TEXT, -- For deduplication + + -- Indexes + CONSTRAINT t6_memories_hash_unique UNIQUE (hash) +); + +-- Create index on embedding using HNSW for fast similarity search +CREATE INDEX IF NOT EXISTS t6_memories_embedding_idx +ON t6_memories +USING hnsw (embedding vector_cosine_ops); + +-- Create indexes on metadata fields for filtering +CREATE INDEX IF NOT EXISTS t6_memories_user_id_idx +ON t6_memories (user_id); + +CREATE INDEX IF NOT EXISTS t6_memories_agent_id_idx +ON t6_memories (agent_id); + +CREATE INDEX IF NOT EXISTS t6_memories_run_id_idx +ON t6_memories (run_id); + +CREATE INDEX IF NOT EXISTS t6_memories_created_at_idx +ON t6_memories (created_at DESC); + +-- Create GIN index on metadata for JSON queries +CREATE INDEX IF NOT EXISTS t6_memories_metadata_idx +ON t6_memories +USING GIN (metadata); + +-- Create full-text search index on memory_text +CREATE INDEX IF NOT EXISTS t6_memories_text_search_idx +ON t6_memories +USING GIN (to_tsvector('english', memory_text)); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_t6_memories_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to automatically update updated_at +CREATE TRIGGER t6_memories_updated_at_trigger + BEFORE UPDATE ON t6_memories + FOR EACH ROW + EXECUTE FUNCTION update_t6_memories_updated_at(); + +-- Function for vector similarity search with filters +CREATE OR REPLACE FUNCTION match_t6_memories( + query_embedding vector(1536), + match_count INT DEFAULT 10, + filter_user_id TEXT DEFAULT NULL, + filter_agent_id TEXT DEFAULT NULL, + filter_run_id TEXT DEFAULT NULL +) +RETURNS TABLE ( + id UUID, + memory_text TEXT, + metadata JSONB, + user_id TEXT, + agent_id TEXT, + run_id TEXT, + similarity FLOAT, + created_at TIMESTAMP WITH TIME ZONE +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + t6_memories.id, + t6_memories.memory_text, + t6_memories.metadata, + t6_memories.user_id, + t6_memories.agent_id, + t6_memories.run_id, + 1 - (t6_memories.embedding <=> query_embedding) AS similarity, + t6_memories.created_at + FROM t6_memories + WHERE + (filter_user_id IS NULL OR t6_memories.user_id = filter_user_id) AND + (filter_agent_id IS NULL OR t6_memories.agent_id = filter_agent_id) AND + (filter_run_id IS NULL OR t6_memories.run_id = filter_run_id) + ORDER BY t6_memories.embedding <=> query_embedding + LIMIT match_count; +END; +$$; + +-- Function to get memory statistics +CREATE OR REPLACE FUNCTION get_t6_memory_stats() +RETURNS TABLE ( + total_memories BIGINT, + total_users BIGINT, + total_agents BIGINT, + avg_memories_per_user NUMERIC, + oldest_memory TIMESTAMP WITH TIME ZONE, + newest_memory TIMESTAMP WITH TIME ZONE +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT AS total_memories, + COUNT(DISTINCT user_id)::BIGINT AS total_users, + COUNT(DISTINCT agent_id)::BIGINT AS total_agents, + CASE + WHEN COUNT(DISTINCT user_id) > 0 + THEN ROUND(COUNT(*)::NUMERIC / COUNT(DISTINCT user_id), 2) + ELSE 0 + END AS avg_memories_per_user, + MIN(created_at) AS oldest_memory, + MAX(created_at) AS newest_memory + FROM t6_memories; +END; +$$; + +-- Create a view for recent memories +CREATE OR REPLACE VIEW t6_recent_memories AS +SELECT + id, + user_id, + agent_id, + run_id, + memory_text, + metadata, + created_at, + updated_at +FROM t6_memories +ORDER BY created_at DESC +LIMIT 100; + +-- Grant necessary permissions (adjust as needed for your setup) +-- GRANT SELECT, INSERT, UPDATE, DELETE ON t6_memories TO authenticated; +-- GRANT EXECUTE ON FUNCTION match_t6_memories TO authenticated; +-- GRANT EXECUTE ON FUNCTION get_t6_memory_stats TO authenticated; + +-- Comments for documentation +COMMENT ON TABLE t6_memories IS 'Storage for T6 Mem0 v2 memory vectors and metadata'; +COMMENT ON COLUMN t6_memories.embedding IS 'OpenAI text-embedding-3-small vector (1536 dimensions)'; +COMMENT ON COLUMN t6_memories.metadata IS 'Flexible JSON metadata for additional memory properties'; +COMMENT ON COLUMN t6_memories.hash IS 'Hash for deduplication of identical memories'; +COMMENT ON FUNCTION match_t6_memories IS 'Performs cosine similarity search on memory embeddings with optional filters'; +COMMENT ON FUNCTION get_t6_memory_stats IS 'Returns statistics about stored memories'; + +-- Success message +DO $$ +BEGIN + RAISE NOTICE 'T6 Mem0 v2 vector store initialized successfully!'; + RAISE NOTICE 'Table: t6_memories'; + RAISE NOTICE 'Functions: match_t6_memories, get_t6_memory_stats'; + RAISE NOTICE 'View: t6_recent_memories'; +END $$; diff --git a/migrations/supabase/README.md b/migrations/supabase/README.md new file mode 100644 index 0000000..14f8406 --- /dev/null +++ b/migrations/supabase/README.md @@ -0,0 +1,153 @@ +# Supabase Migrations for T6 Mem0 v2 + +## Overview + +This directory contains SQL migrations for setting up the Supabase vector store used by T6 Mem0 v2. + +## Migrations + +### 001_init_vector_store.sql + +Initial setup migration that creates: + +- **pgvector extension**: Enables vector similarity search +- **t6_memories table**: Main storage for memory vectors and metadata +- **Indexes**: HNSW for vectors, B-tree for filters, GIN for JSONB +- **Functions**: + - `match_t6_memories()`: Vector similarity search with filters + - `get_t6_memory_stats()`: Memory statistics + - `update_t6_memories_updated_at()`: Auto-update timestamp +- **View**: `t6_recent_memories` for quick access to recent entries + +## Applying Migrations + +### Method 1: Supabase SQL Editor (Recommended) + +1. Open your Supabase project dashboard +2. Navigate to SQL Editor +3. Create a new query +4. Copy and paste the contents of `001_init_vector_store.sql` +5. Click "Run" to execute + +### Method 2: psql Command Line + +```bash +# Connect to your Supabase database +psql "postgresql://supabase_admin:PASSWORD@172.21.0.12:5432/postgres" + +# Run the migration +\i migrations/supabase/001_init_vector_store.sql +``` + +### Method 3: Programmatic Application + +```python +import psycopg2 + +# Connect to Supabase +conn = psycopg2.connect( + "postgresql://supabase_admin:PASSWORD@172.21.0.12:5432/postgres" +) + +# Read and execute migration +with open('migrations/supabase/001_init_vector_store.sql', 'r') as f: + migration_sql = f.read() + +with conn.cursor() as cur: + cur.execute(migration_sql) + conn.commit() + +conn.close() +``` + +## Verification + +After applying the migration, verify the setup: + +```sql +-- Check if pgvector extension is enabled +SELECT * FROM pg_extension WHERE extname = 'vector'; + +-- Check if table exists +\d t6_memories + +-- Verify indexes +\di t6_memories* + +-- Test the similarity search function +SELECT * FROM match_t6_memories( + '[0.1, 0.2, ...]'::vector(1536), -- Sample embedding + 10, -- Match count + 'test_user', -- User ID filter + NULL, -- Agent ID filter + NULL -- Run ID filter +); + +-- Get memory statistics +SELECT * FROM get_t6_memory_stats(); +``` + +## Rollback + +If you need to rollback the migration: + +```sql +-- Drop view +DROP VIEW IF EXISTS t6_recent_memories; + +-- Drop functions +DROP FUNCTION IF EXISTS get_t6_memory_stats(); +DROP FUNCTION IF EXISTS match_t6_memories(vector, INT, TEXT, TEXT, TEXT); +DROP FUNCTION IF EXISTS update_t6_memories_updated_at(); + +-- Drop trigger +DROP TRIGGER IF EXISTS t6_memories_updated_at_trigger ON t6_memories; + +-- Drop table (WARNING: This will delete all data!) +DROP TABLE IF EXISTS t6_memories CASCADE; + +-- Optionally remove extension (only if not used elsewhere) +-- DROP EXTENSION IF EXISTS vector CASCADE; +``` + +## Schema + +### t6_memories Table + +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| embedding | vector(1536) | OpenAI embedding vector | +| metadata | JSONB | Flexible metadata | +| user_id | TEXT | User identifier | +| agent_id | TEXT | Agent identifier | +| run_id | TEXT | Run identifier | +| memory_text | TEXT | Original memory text | +| created_at | TIMESTAMPTZ | Creation timestamp | +| updated_at | TIMESTAMPTZ | Last update timestamp | +| hash | TEXT | Deduplication hash (unique) | + +### Indexes + +- **t6_memories_embedding_idx**: HNSW index for fast vector search +- **t6_memories_user_id_idx**: B-tree for user filtering +- **t6_memories_agent_id_idx**: B-tree for agent filtering +- **t6_memories_run_id_idx**: B-tree for run filtering +- **t6_memories_created_at_idx**: B-tree for time-based queries +- **t6_memories_metadata_idx**: GIN for JSON queries +- **t6_memories_text_search_idx**: GIN for full-text search + +## Notes + +- The HNSW index provides O(log n) approximate nearest neighbor search +- Cosine distance is used for similarity (1 - cosine similarity) +- All timestamps are stored in UTC +- The hash column ensures deduplication of identical memories +- Metadata is stored as JSONB for flexible schema evolution + +## Support + +For issues or questions about migrations, refer to: +- [Supabase Vector Documentation](https://supabase.com/docs/guides/database/extensions/pgvector) +- [pgvector Documentation](https://github.com/pgvector/pgvector) +- Project Architecture: `../../ARCHITECTURE.md` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..725ecb8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +# Core Memory System +mem0ai[graph]==0.1.* + +# Web Framework +fastapi==0.115.* +uvicorn[standard]==0.32.* +pydantic==2.9.* +pydantic-settings==2.6.* + +# MCP Server +mcp==1.3.* + +# Database Drivers +psycopg2-binary==2.9.* +neo4j==5.26.* + +# OpenAI +openai==1.58.* + +# Utilities +python-dotenv==1.0.* +httpx==0.28.* +pyyaml==6.0.* + +# Development +pytest==8.3.* +pytest-asyncio==0.24.* +pytest-cov==6.0.* +black==24.10.* +ruff==0.8.* +mypy==1.13.* + +# Monitoring +prometheus-client==0.21.* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mcp-server/__init__.py b/tests/mcp-server/__init__.py new file mode 100644 index 0000000..e69de29