Compare commits

..
10 Commits
196 changed files with 18445 additions and 165 deletions
+2
View File
@@ -15,6 +15,8 @@
<a href="https://mem0.dev/DiG">Join Discord</a>
·
<a href="https://mem0.dev/demo">Demo</a>
·
<a href="https://mem0.dev/openmemory">OpenMemory</a>
</p>
<p align="center">
+5
View File
@@ -487,6 +487,11 @@ mode: "wide"
<Tab title="Vercel AI SDK">
<Update label="2025-05-10" description="v1.0.4">
**New Features:**
- **Vercel AI SDK:** Added support for new param `output_format`.
</Update>
<Update label="2025-05-08" description="v1.0.3">
**Improvements:**
- **Vercel AI SDK:** Added support for graceful failure in cases services are down.
@@ -25,7 +25,7 @@ from mem0 import Memory
os.environ["OPENAI_API_KEY"] = "your-openai-api-key"
# AWS credentials
os.environ["AWS_REGION"] = "us-east-1"
os.environ["AWS_REGION"] = "us-west-2"
os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key"
os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-key"
@@ -33,7 +33,7 @@ config = {
"embedder": {
"provider": "aws_bedrock",
"config": {
"model": "amazon.titan-embed-text-v1"
"model": "amazon.titan-embed-text-v2:0"
}
}
}
+3 -4
View File
@@ -15,16 +15,15 @@ title: AWS Bedrock
import os
from mem0 import Memory
os.environ["OPENAI_API_KEY"] = "your-api-key" # used for embedding model
os.environ['AWS_REGION'] = 'us-east-1'
os.environ["AWS_ACCESS_KEY"] = "xx"
os.environ['AWS_REGION'] = 'us-west-2'
os.environ["AWS_ACCESS_KEY_ID"] = "xx"
os.environ["AWS_SECRET_ACCESS_KEY"] = "xx"
config = {
"llm": {
"provider": "aws_bedrock",
"config": {
"model": "arn:aws:bedrock:us-east-1:123456789012:model/your-model-name",
"model": "anthropic.claude-3-5-haiku-20241022-v1:0",
"temperature": 0.2,
"max_tokens": 2000,
}
@@ -18,6 +18,8 @@ To use Azure OpenAI models, you have to set the `LLM_AZURE_OPENAI_API_KEY`, `LLM
import os
from mem0 import Memory
os.environ["OPENAI_API_KEY"] = "your-api-key" # used for embedding model
os.environ["LLM_AZURE_OPENAI_API_KEY"] = "your-api-key"
os.environ["LLM_AZURE_DEPLOYMENT"] = "your-deployment-name"
os.environ["LLM_AZURE_ENDPOINT"] = "your-api-base-url"
+38 -22
View File
@@ -1,59 +1,75 @@
[OpenSearch](https://opensearch.org/) is an open-source, enterprise-grade search and observability suite that brings order to unstructured data at scale. OpenSearch supports k-NN (k-Nearest Neighbors) and allows you to store and retrieve high-dimensional vector embeddings efficiently.
[OpenSearch](https://opensearch.org/) is an enterprise-grade search and observability suite that brings order to unstructured data at scale. OpenSearch supports k-NN (k-Nearest Neighbors) and allows you to store and retrieve high-dimensional vector embeddings efficiently.
### Installation
OpenSearch support requires additional dependencies. Install them with:
```bash
pip install opensearch>=2.8.0
pip install opensearch-py
```
### Prerequisites
Before using OpenSearch with Mem0, you need to set up a collection in AWS OpenSearch Service.
#### AWS OpenSearch Service
You can create a collection through the AWS Console:
- Navigate to [OpenSearch Service Console](https://console.aws.amazon.com/aos/home)
- Click "Create collection"
- Select "Serverless collection" and then enable "Vector search" capabilities
- Once created, note the endpoint URL (host) for your configuration
### Usage
```python
import os
from mem0 import Memory
import boto3
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
os.environ["OPENAI_API_KEY"] = "sk-xx"
# For AWS OpenSearch Service with IAM authentication
region = 'us-west-2'
service = 'aoss'
credentials = boto3.Session().get_credentials()
auth = AWSV4SignerAuth(credentials, region, service)
config = {
"vector_store": {
"provider": "opensearch",
"config": {
"collection_name": "mem0",
"host": "localhost",
"port": 9200,
"embedding_model_dims": 1536
"host": "your-domain.us-west-2.aoss.amazonaws.com",
"port": 443,
"http_auth": auth,
"embedding_model_dims": 1024,
"connection_class": RequestsHttpConnection,
"pool_maxsize": 20,
"use_ssl": True,
"verify_certs": True
}
}
}
```
### Add Memories
```python
m = Memory.from_config(config)
messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
{"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."},
{"role": "user", "content": "I’m not a big fan of thriller movies but I love sci-fi movies."},
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}
]
m.add(messages, user_id="alice", metadata={"category": "movies"})
```
### Config
### Search Memories
Let's see the available parameters for the `opensearch` config:
| Parameter | Description | Default Value |
| ---------------------- | -------------------------------------------------- | ------------- |
| `collection_name` | The name of the index to store the vectors | `mem0` |
| `embedding_model_dims` | Dimensions of the embedding model | `1536` |
| `host` | The host where the OpenSearch server is running | `localhost` |
| `port` | The port where the OpenSearch server is running | `9200` |
| `api_key` | API key for authentication | `None` |
| `user` | Username for basic authentication | `None` |
| `password` | Password for basic authentication | `None` |
| `verify_certs` | Whether to verify SSL certificates | `False` |
| `auto_create_index` | Whether to automatically create the index | `True` |
| `use_ssl` | Whether to use SSL for connection | `False` |
```python
results = m.search("What kind of movies does Alice like?", user_id="alice")
```
### Features
+10
View File
@@ -184,6 +184,14 @@
}
]
},
{
"tab": "OpenMemory",
"icon": "square-terminal",
"pages": [
"openmemory/overview",
"openmemory/quickstart"
]
},
{
"tab": "Examples",
"groups": [
@@ -192,8 +200,10 @@
"icon": "lightbulb",
"pages": [
"examples",
"examples/aws_example",
"examples/mem0-demo",
"examples/ai_companion_js",
"examples/eliza_os",
"examples/mem0-mastra",
"examples/mem0-with-ollama",
"examples/personal-ai-tutor",
+120
View File
@@ -0,0 +1,120 @@
---
title: AWS Bedrock and AOSS
---
<Snippet file="paper-release.mdx" />
This example demonstrates how to configure and use the `mem0ai` SDK with **AWS Bedrock** and **OpenSearch Service (AOSS)** for persistent memory capabilities in Python.
## Installation
Install the required dependencies:
```bash
pip install mem0ai boto3 opensearch-py
```
## Environment Setup
Set your AWS environment variables:
```python
import os
# Set these in your environment or notebook
os.environ['AWS_REGION'] = 'us-west-2'
os.environ['AWS_ACCESS_KEY_ID'] = 'AK00000000000000000'
os.environ['AWS_SECRET_ACCESS_KEY'] = 'AS00000000000000000'
# Confirm they are set
print(os.environ['AWS_REGION'])
print(os.environ['AWS_ACCESS_KEY_ID'])
print(os.environ['AWS_SECRET_ACCESS_KEY'])
```
## Configuration and Usage
This sets up Mem0 with AWS Bedrock for embeddings and LLM, and OpenSearch as the vector store.
```python
import boto3
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
from mem0.memory.main import Memory
region = 'us-west-2'
service = 'aoss'
credentials = boto3.Session().get_credentials()
auth = AWSV4SignerAuth(credentials, region, service)
config = {
"embedder": {
"provider": "aws_bedrock",
"config": {
"model": "amazon.titan-embed-text-v2:0"
}
},
"llm": {
"provider": "aws_bedrock",
"config": {
"model": "anthropic.claude-3-5-haiku-20241022-v1:0",
"temperature": 0.1,
"max_tokens": 2000
}
},
"vector_store": {
"provider": "opensearch",
"config": {
"collection_name": "mem0",
"host": "your-opensearch-domain.us-west-2.es.amazonaws.com",
"port": 443,
"http_auth": auth,
"embedding_model_dims": 1024,
"connection_class": RequestsHttpConnection,
"pool_maxsize": 20,
"use_ssl": True,
"verify_certs": True
}
}
}
# Initialize memory system
m = Memory.from_config(config)
```
## Usage
#### Add a memory:
```python
messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
{"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."},
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}
]
# Store inferred memories (default behavior)
result = m.add(messages, user_id="alice", metadata={"category": "movie_recommendations"})
```
#### Search a memory:
```python
relevant_memories = m.search(query, user_id="alice")
```
#### Get all memories:
```python
all_memories = m.get_all(user_id="alice")
```
#### Get a specific memory:
```python
memory = m.get(memory_id)
```
---
## Conclusion
With Mem0 and AWS services like Bedrock and OpenSearch, you can build intelligent AI companions that remember, adapt, and personalize their responses over time. This makes them ideal for long-term assistants, tutors, or support bots with persistent memory and natural conversation abilities.
+75
View File
@@ -0,0 +1,75 @@
---
title: Eliza OS Character
---
<Snippet file="paper-release.mdx" />
You can create a personalised Eliza OS Character using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started.
## Overview
ElizaOS is a powerful AI agent framework for autonomy & personality. It is a collection of tools that help you create a personalised AI agent.
## Setup
You can start by cloning the eliza-os repository:
```bash
git clone https://github.com/elizaOS/eliza.git
```
Change the directory to the eliza-os repository:
```bash
cd eliza
```
Install the dependencies:
```bash
pnpm install
```
Build the project:
```bash
pnpm build
```
## Setup ENVs
Create a `.env` file in the root of the project and add the following ( You can use the `.env.example` file as a reference):
```bash
# Mem0 Configuration
MEM0_API_KEY= # Mem0 API Key ( Get from https://app.mem0.ai/dashboard/api-keys )
MEM0_USER_ID= # Default: eliza-os-user
MEM0_PROVIDER= # Default: openai
MEM0_PROVIDER_API_KEY= # API Key for the provider (openai, anthropic, etc.)
SMALL_MEM0_MODEL= # Default: gpt-4o-mini
MEDIUM_MEM0_MODEL= # Default: gpt-4o
LARGE_MEM0_MODEL= # Default: gpt-4o
```
## Make the default character use Mem0
By default, there is a character called `eliza` that uses the `ollama` model. You can make this character use Mem0 by changing the config in the `agent/src/defaultCharacter.ts` file.
```ts
modelProvider: ModelProviderName.MEM0,
```
This will make the character use Mem0 to generate responses.
## Run the project
```bash
pnpm start
```
## Conclusion
You have now created a personalised Eliza OS Character using Mem0. You can now start interacting with the character by running the project and talking to the character.
This is a simple example of how to use Mem0 to create a personalised AI agent. You can use this as a starting point to create your own AI agent.
+1 -1
View File
@@ -16,7 +16,7 @@ You can create a personalized AI Companion using Mem0. This guide will walk you
src="https://github.com/user-attachments/assets/cebc4f8e-bdb9-4837-868d-13c5ab7bb433"
></video>
You can try the [Mem0 Demo](https://mem0.dev/demo) live here.
You can try the [Mem0 Demo](https://mem0-4vmi.vercel.app) live here.
## Overview
+81
View File
@@ -0,0 +1,81 @@
---
title: Overview
icon: "info"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
OpenMemory is a local memory infrastructure powered by Mem0 that lets you carry your memory accross any AI app. It provides a unified memory layer that stays with you, enabling agents and assistants to remember what matters across applications.
<img src="https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b" alt="OpenMemory UI" />
## What is the OpenMemory MCP Server
The OpenMemory MCP Server is a private, local-first memory server that creates a shared, persistent memory layer for your MCP-compatible tools. This runs entirely on your machine, enabling seamless context handoff across tools. Whether you're switching between development, planning, or debugging environments, your AI assistants can access relevant memory without needing repeated instructions.
The OpenMemory MCP Server ensures all memory stays local, structured, and under your control with no cloud sync or external storage.
## How the OpenMemory MCP Server Works
Built around the Model Context Protocol (MCP), the OpenMemory MCP Server exposes a standardized set of memory tools:
- `add_memories`: Store new memory objects
- `search_memory`: Retrieve relevant memories
- `list_memories`: View all stored memory
- `delete_all_memories`: Clear memory entirely
Any MCP-compatible tool can connect to the server and use these APIs to persist and access memory.
## What It Enables
### Cross-Client Memory Access
Store context in Cursor and retrieve it later in Claude or Windsurf without repeating yourself.
### Fully Local Memory Store
All memory is stored on your machine. Nothing goes to the cloud. You maintain full ownership and control.
### Unified Memory UI
The built-in OpenMemory dashboard provides a central view of everything stored. Add, browse, delete and control memory access to clients directly from the dashboard.
## Supported Clients
The OpenMemory MCP Server is compatible with any client that supports the Model Context Protocol. This includes:
- Cursor
- Claude Desktop
- Windsurf
- Cline, and more.
As more AI systems adopt MCP, your private memory becomes more valuable.
## Real-World Examples
### Scenario 1: Cross-Tool Project Flow
Define technical requirements of a project in Claude Desktop. Build in Cursor. Debug issues in Windsurf - all with shared context passed through OpenMemory.
### Scenario 2: Preferences That Persist
Set your preferred code style or tone in one tool. When you switch to another MCP client, it can access those same preferences without redefining them.
### Scenario 3: Project Knowledge
Save important project details once, then access them from any compatible AI tool, no more repetitive explanations.
## Conclusion
The OpenMemory MCP Server brings memory to MCP-compatible tools without giving up control or privacy. It solves a foundational limitation in modern LLM workflows: the loss of context across tools, sessions, and environments.
By standardizing memory operations and keeping all data local, it reduces token overhead, improves performance, and unlocks more intelligent interactions across the growing ecosystem of AI assistants.
This is just the beginning. The MCP server is the first core layer in the OpenMemory platform - a broader effort to make memory portable, private, and interoperable across AI systems.
## Getting Started Today
- Github Repository: https://github.com/mem0ai/mem0
- Read the documentation: [Docs Link]
- Join our community: [Discord link]
With OpenMemory, your AI memories stay private, portable, and under your control, exactly where they belong.
OpenMemory: Your memories, your control.
## Contributing
OpenMemory is open source and we welcome contributions. Please see the [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/openmemory/CONTRIBUTING.md) file for more information.
+43
View File
@@ -0,0 +1,43 @@
---
title: Quickstart
icon: "terminal"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
## Setting Up OpenMemory
Getting started with OpenMemory is straight forward and takes just a few minutes to set up on your local machine. Follow these steps:
### Getting started
First clone the repository and then follow the instructions:
```bash
# Clone the repository
git clone https://github.com/mem0ai/mem0.git
cd mem0/openmemory
# Create the backend .env file with your OpenAI key
make env
# Build the Docker images
make build
# Start all services (API server, vector database, and MCP server components)
make up
```
You can configure the MCP client using the following command (replace username with your username):
```bash
npx install-mcp i "http://localhost:8765/mcp/cursor/sse/username" --client cursor
```
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories, as well as check connection status with your MCP clients.
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
### Getting Started Today
- Github Repository: https://github.com/mem0ai/mem0/openmemory
+1 -1
View File
@@ -70,7 +70,7 @@ const retrieveMemories = (memories: any) => {
export async function POST(req: Request) {
const { messages, system, tools, userId } = await req.json();
const memories = await getMemories(messages, { user_id: userId, rerank: true, threshold: 0.1 });
const memories = await getMemories(messages, { user_id: userId, rerank: true, threshold: 0.1, output_format: "v1.0" });
const mem0Instructions = retrieveMemories(memories);
const result = streamText({
+3 -3
View File
@@ -45,7 +45,7 @@ const useUserId = () => {
export const Assistant = () => {
const { userId, resetUserId } = useUserId();
const runtime = useChatRuntime({
api: "https://demo.mem0.ai/api/chat",
api: "/api/chat",
body: { userId },
});
@@ -91,8 +91,8 @@ export const Assistant = () => {
</button>
<GithubButton url="https://github.com/mem0ai/mem0/tree/main/examples" />
<Link href={"https://app.mem0.ai/"} target="_blank" className="py-2 ml-2 px-4 font-semibold dark:bg-zinc-100 dark:hover:bg-zinc-200 bg-zinc-800 text-white rounded-full hover:bg-zinc-900 dark:text-[#475569]">
Save Memories
<Link href={"https://app.mem0.ai/"} target="_blank" className="py-1 ml-2 px-4 font-semibold dark:bg-zinc-100 dark:hover:bg-zinc-200 bg-zinc-800 text-white rounded-full hover:bg-zinc-900 dark:text-[#475569]">
Playground
</Link>
</div>
</header>
-18
View File
@@ -2,24 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
assetPrefix: "https://demo.mem0.ai",
images: {
path: "https://demo.mem0.ai",
},
async headers() {
return [
{
// matching all API routes
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Access-Control-Allow-Methods", value: "GET,DELETE,PATCH,POST,PUT" },
{ key: "Access-Control-Allow-Headers", value: "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" },
]
}
]
}
};
export default nextConfig;
+1 -1
View File
@@ -13,7 +13,7 @@
"@assistant-ui/react": "^0.8.2",
"@assistant-ui/react-ai-sdk": "^0.8.0",
"@assistant-ui/react-markdown": "^0.8.0",
"@mem0/vercel-ai-provider": "^1.0.0",
"@mem0/vercel-ai-provider": "^1.0.4",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-popover": "^1.1.6",
+9
View File
@@ -33,6 +33,10 @@ class BaseEmbedderConfig(ABC):
memory_search_embedding_type: Optional[str] = None,
# LM Studio specific
lmstudio_base_url: Optional[str] = "http://localhost:1234/v1",
# AWS Bedrock specific
aws_access_key_id: Optional[str] = None,
aws_secret_access_key: Optional[str] = None,
aws_region: Optional[str] = "us-west-2",
):
"""
Initializes a configuration class instance for the Embeddings.
@@ -92,3 +96,8 @@ class BaseEmbedderConfig(ABC):
# LM Studio specific
self.lmstudio_base_url = lmstudio_base_url
# AWS Bedrock specific
self.aws_access_key_id = aws_access_key_id
self.aws_secret_access_key = aws_secret_access_key
self.aws_region = aws_region
+9
View File
@@ -41,6 +41,10 @@ class BaseLlmConfig(ABC):
xai_base_url: Optional[str] = None,
# LM Studio specific
lmstudio_base_url: Optional[str] = "http://localhost:1234/v1",
# AWS Bedrock specific
aws_access_key_id: Optional[str] = None,
aws_secret_access_key: Optional[str] = None,
aws_region: Optional[str] = "us-west-2",
):
"""
Initializes a configuration class instance for the LLM.
@@ -123,3 +127,8 @@ class BaseLlmConfig(ABC):
# LM Studio specific
self.lmstudio_base_url = lmstudio_base_url
# AWS Bedrock specific
self.aws_access_key_id = aws_access_key_id
self.aws_secret_access_key = aws_secret_access_key
self.aws_region = aws_region
+31 -15
View File
@@ -1,4 +1,4 @@
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union, Type
from pydantic import BaseModel, Field, model_validator
@@ -7,14 +7,33 @@ class OpenSearchConfig(BaseModel):
collection_name: str = Field("mem0", description="Name of the index")
host: str = Field("localhost", description="OpenSearch host")
port: int = Field(9200, description="OpenSearch port")
user: Optional[str] = Field(None, description="Username for authentication")
password: Optional[str] = Field(None, description="Password for authentication")
api_key: Optional[str] = Field(None, description="API key for authentication (if applicable)")
embedding_model_dims: int = Field(1536, description="Dimension of the embedding vector")
verify_certs: bool = Field(False, description="Verify SSL certificates (default False for OpenSearch)")
use_ssl: bool = Field(False, description="Use SSL for connection (default False for OpenSearch)")
auto_create_index: bool = Field(True, description="Automatically create index during initialization")
http_auth: Optional[object] = Field(None, description="HTTP authentication method / AWS SigV4")
user: Optional[str] = Field(
None, description="Username for authentication"
)
password: Optional[str] = Field(
None, description="Password for authentication"
)
api_key: Optional[str] = Field(
None, description="API key for authentication (if applicable)"
)
embedding_model_dims: int = Field(
1536, description="Dimension of the embedding vector"
)
verify_certs: bool = Field(
False, description="Verify SSL certificates (default False for OpenSearch)"
)
use_ssl: bool = Field(
False, description="Use SSL for connection (default False for OpenSearch)"
)
http_auth: Optional[object] = Field(
None, description="HTTP authentication method / AWS SigV4"
)
connection_class: Optional[Union[str, Type]] = Field(
"RequestsHttpConnection", description="Connection class for OpenSearch"
)
pool_maxsize: int = Field(
20, description="Maximum number of connections in the pool"
)
@model_validator(mode="before")
@classmethod
@@ -22,11 +41,7 @@ class OpenSearchConfig(BaseModel):
# Check if host is provided
if not values.get("host"):
raise ValueError("Host must be provided for OpenSearch")
# Authentication: Either API key or user/password must be provided
if not any([values.get("api_key"), (values.get("user") and values.get("password")), values.get("http_auth")]):
raise ValueError("Either api_key or user/password must be provided for OpenSearch authentication")
return values
@model_validator(mode="before")
@@ -37,6 +52,7 @@ class OpenSearchConfig(BaseModel):
extra_fields = input_fields - allowed_fields
if extra_fields:
raise ValueError(
f"Extra fields not allowed: {', '.join(extra_fields)}. " f"Allowed fields: {', '.join(allowed_fields)}"
f"Extra fields not allowed: {', '.join(extra_fields)}. "
f"Allowed fields: {', '.join(allowed_fields)}"
)
return values
+21 -1
View File
@@ -1,4 +1,5 @@
import json
import os
from typing import Literal, Optional
try:
@@ -22,7 +23,26 @@ class AWSBedrockEmbedding(EmbeddingBase):
super().__init__(config)
self.config.model = self.config.model or "amazon.titan-embed-text-v1"
self.client = boto3.client("bedrock-runtime")
# Get AWS config from environment variables or use defaults
aws_access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
aws_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
aws_region = os.environ.get("AWS_REGION", "us-west-2")
# Check if AWS config is provided in the config
if hasattr(self.config, "aws_access_key_id"):
aws_access_key = self.config.aws_access_key_id
if hasattr(self.config, "aws_secret_access_key"):
aws_secret_key = self.config.aws_secret_access_key
if hasattr(self.config, "aws_region"):
aws_region = self.config.aws_region
self.client = boto3.client(
"bedrock-runtime",
region_name=aws_region,
aws_access_key_id=aws_access_key if aws_access_key else None,
aws_secret_access_key=aws_secret_key if aws_secret_key else None,
)
def _normalize_vector(self, embeddings):
"""Normalize the embedding to a unit vector."""
+71 -14
View File
@@ -1,4 +1,6 @@
import json
import os
import re
from typing import Any, Dict, List, Optional
try:
@@ -9,6 +11,14 @@ except ImportError:
from mem0.configs.llms.base import BaseLlmConfig
from mem0.llms.base import LLMBase
PROVIDERS = ["ai21", "amazon", "anthropic", "cohere", "meta", "mistral", "stability", "writer"]
def extract_provider(model: str) -> str:
for provider in PROVIDERS:
if re.search(rf"\b{re.escape(provider)}\b", model):
return provider
raise ValueError(f"Unknown provider in model: {model}")
class AWSBedrockLLM(LLMBase):
def __init__(self, config: Optional[BaseLlmConfig] = None):
@@ -16,7 +26,27 @@ class AWSBedrockLLM(LLMBase):
if not self.config.model:
self.config.model = "anthropic.claude-3-5-sonnet-20240620-v1:0"
self.client = boto3.client("bedrock-runtime")
# Get AWS config from environment variables or use defaults
aws_access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
aws_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
aws_region = os.environ.get("AWS_REGION", "us-west-2")
# Check if AWS config is provided in the config
if hasattr(self.config, "aws_access_key_id"):
aws_access_key = self.config.aws_access_key_id
if hasattr(self.config, "aws_secret_access_key"):
aws_secret_key = self.config.aws_secret_access_key
if hasattr(self.config, "aws_region"):
aws_region = self.config.aws_region
self.client = boto3.client(
"bedrock-runtime",
region_name=aws_region,
aws_access_key_id=aws_access_key if aws_access_key else None,
aws_secret_access_key=aws_secret_key if aws_secret_key else None,
)
self.model_kwargs = {
"temperature": self.config.temperature,
"max_tokens_to_sample": self.config.max_tokens,
@@ -34,13 +64,14 @@ class AWSBedrockLLM(LLMBase):
Returns:
str: A formatted string combining all messages, structured with roles capitalized and separated by newlines.
"""
formatted_messages = []
for message in messages:
role = message["role"].capitalize()
content = message["content"]
formatted_messages.append(f"\n\n{role}: {content}")
return "".join(formatted_messages) + "\n\nAssistant:"
return "\n\nHuman: " + "".join(formatted_messages) + "\n\nAssistant:"
def _parse_response(self, response, tools) -> str:
"""
@@ -68,8 +99,9 @@ class AWSBedrockLLM(LLMBase):
return processed_response
response_body = json.loads(response["body"].read().decode())
return response_body.get("completion", "")
response_body = response.get("body").read().decode()
response_json = json.loads(response_body)
return response_json.get("content", [{"text": ""}])[0].get("text", "")
def _prepare_input(
self,
@@ -113,9 +145,9 @@ class AWSBedrockLLM(LLMBase):
input_body = {
"inputText": prompt,
"textGenerationConfig": {
"maxTokenCount": model_kwargs.get("max_tokens_to_sample"),
"topP": model_kwargs.get("top_p"),
"temperature": model_kwargs.get("temperature"),
"maxTokenCount": self.model_kwargs["max_tokens_to_sample"] or self.model_kwargs["max_tokens"] or 5000,
"topP": self.model_kwargs["top_p"] or 0.9,
"temperature": self.model_kwargs["temperature"] or 0.1,
},
}
input_body["textGenerationConfig"] = {
@@ -206,15 +238,40 @@ class AWSBedrockLLM(LLMBase):
else:
# Use invoke_model method when no tools are provided
prompt = self._format_messages(messages)
provider = self.model.split(".")[0]
input_body = self._prepare_input(provider, self.config.model, prompt, **self.model_kwargs)
provider = extract_provider(self.config.model)
input_body = self._prepare_input(provider, self.config.model, prompt, model_kwargs=self.model_kwargs)
body = json.dumps(input_body)
response = self.client.invoke_model(
body=body,
modelId=self.model,
accept="application/json",
contentType="application/json",
if provider == "anthropic" or provider == "deepseek":
input_body = {
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
}
],
"max_tokens": self.model_kwargs["max_tokens_to_sample"] or self.model_kwargs["max_tokens"] or 5000,
"temperature": self.model_kwargs["temperature"] or 0.1,
"top_p": self.model_kwargs["top_p"] or 0.9,
"anthropic_version": "bedrock-2023-05-31",
}
body = json.dumps(input_body)
response = self.client.invoke_model(
body=body,
modelId=self.config.model,
accept="application/json",
contentType="application/json",
)
else:
response = self.client.invoke_model(
body=body,
modelId=self.config.model,
accept="application/json",
contentType="application/json",
)
return self._parse_response(response, tools)
+34 -8
View File
@@ -338,15 +338,23 @@ class MemoryGraph:
cypher = f"""
MATCH (source)
WHERE elementId(source) = $source_id
SET source.mentions = coalesce(source.mentions, 0) + 1
WITH source
MERGE (destination:{destination_type} {{name: $destination_name, user_id: $user_id}})
ON CREATE SET
destination.created = timestamp()
destination.created = timestamp(),
destination.mentions = 1
ON MATCH SET
destination.mentions = coalesce(destination.mentions, 0) + 1
WITH source, destination
CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding)
WITH source, destination
MERGE (source)-[r:{relationship}]->(destination)
ON CREATE SET
r.created = timestamp()
r.created = timestamp(),
r.mentions = 1
ON MATCH SET
r.mentions = coalesce(r.mentions, 0) + 1
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
"""
@@ -360,15 +368,23 @@ class MemoryGraph:
cypher = f"""
MATCH (destination)
WHERE elementId(destination) = $destination_id
SET destination.mentions = coalesce(destination.mentions, 0) + 1
WITH destination
MERGE (source:{source_type} {{name: $source_name, user_id: $user_id}})
ON CREATE SET
source.created = timestamp()
source.created = timestamp(),
source.mentions = 1
ON MATCH SET
source.mentions = coalesce(source.mentions, 0) + 1
WITH source, destination
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
WITH source, destination
MERGE (source)-[r:{relationship}]->(destination)
ON CREATE SET
r.created = timestamp()
r.created = timestamp(),
r.mentions = 1
ON MATCH SET
r.mentions = coalesce(r.mentions, 0) + 1
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
"""
@@ -382,12 +398,17 @@ class MemoryGraph:
cypher = f"""
MATCH (source)
WHERE elementId(source) = $source_id
SET source.mentions = coalesce(source.mentions, 0) + 1
WITH source
MATCH (destination)
WHERE elementId(destination) = $destination_id
SET destination.mentions = coalesce(destination.mentions) + 1
MERGE (source)-[r:{relationship}]->(destination)
ON CREATE SET
r.created_at = timestamp(),
r.updated_at = timestamp()
r.updated_at = timestamp(),
r.mentions = 1
ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
@@ -400,17 +421,22 @@ class MemoryGraph:
else:
cypher = f"""
MERGE (n:{source_type} {{name: $source_name, user_id: $user_id}})
ON CREATE SET n.created = timestamp()
ON CREATE SET n.created = timestamp(),
n.mentions = 1
ON MATCH SET n.mentions = coalesce(n.mentions, 0) + 1
WITH n
CALL db.create.setNodeVectorProperty(n, 'embedding', $source_embedding)
WITH n
MERGE (m:{destination_type} {{name: $dest_name, user_id: $user_id}})
ON CREATE SET m.created = timestamp()
ON CREATE SET m.created = timestamp(),
m.mentions = 1
ON MATCH SET m.mentions = coalesce(m.mentions, 0) + 1
WITH n, m
CALL db.create.setNodeVectorProperty(m, 'embedding', $source_embedding)
WITH n, m
MERGE (n)-[rel:{relationship}]->(m)
ON CREATE SET rel.created = timestamp()
ON CREATE SET rel.created = timestamp(), rel.mentions = 1
ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1
RETURN n.name AS source, type(rel) AS relationship, m.name AS target
"""
params = {
-3
View File
@@ -69,17 +69,14 @@ class Memory(MemoryBase):
self.enable_graph = True
else:
self.graph = None
self.config.vector_store.config.collection_name = "mem0migrations"
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
self.config.vector_store.config.path = os.path.join(mem0_dir, provider_path)
os.makedirs(self.config.vector_store.config.path, exist_ok=True)
self._telemetry_vector_store = VectorStoreFactory.create(
self.config.vector_store.provider, self.config.vector_store.config
)
capture_event("mem0.init", self, {"sync_type": "sync"})
@classmethod
+2 -2
View File
@@ -38,7 +38,7 @@ def get_or_create_user_id(vector_store):
# Try to get existing user_id from vector store
try:
existing = vector_store.get(vector_id=VECTOR_ID)
existing = vector_store.get(vector_id=user_id)
if existing and hasattr(existing, "payload") and existing.payload and "user_id" in existing.payload:
return existing.payload["user_id"]
except Exception:
@@ -48,7 +48,7 @@ def get_or_create_user_id(vector_store):
try:
dims = getattr(vector_store, "embedding_model_dims", 1536)
vector_store.insert(
vectors=[[0.0] * dims], payloads=[{"user_id": user_id, "type": "user_identity"}], ids=[VECTOR_ID]
vectors=[[0.1] * dims], payloads=[{"user_id": user_id, "type": "user_identity"}], ids=[user_id]
)
except Exception:
pass
+1 -1
View File
@@ -25,7 +25,7 @@ def format_entities(entities):
formatted_lines = []
for entity in entities:
simplified = f"{entity['source']} -- {entity['relatationship']} -- {entity['destination']}"
simplified = f"{entity['source']} -- {entity['relationship']} -- {entity['destination']}"
formatted_lines.append(simplified)
return "\n".join(formatted_lines)
+194 -63
View File
@@ -1,5 +1,6 @@
import logging
from typing import Any, Dict, List, Optional
import time
try:
from opensearchpy import OpenSearch, RequestsHttpConnection
@@ -34,28 +35,26 @@ class OpenSearchDB(VectorStoreBase):
use_ssl=config.use_ssl,
verify_certs=config.verify_certs,
connection_class=RequestsHttpConnection,
pool_maxsize=20
)
self.collection_name = config.collection_name
self.embedding_model_dims = config.embedding_model_dims
# Create index only if auto_create_index is True
if config.auto_create_index:
self.create_index()
self.create_col(self.collection_name, self.embedding_model_dims)
def create_index(self) -> None:
"""Create OpenSearch index with proper mappings if it doesn't exist."""
index_settings = {
"settings": {
"index": {"number_of_replicas": 1, "number_of_shards": 5, "refresh_interval": "1s", "knn": True}
"index": {"number_of_replicas": 1, "number_of_shards": 5, "refresh_interval": "10s", "knn": True}
},
"mappings": {
"properties": {
"text": {"type": "text"},
"vector": {
"vector_field": {
"type": "knn_vector",
"dimension": self.embedding_model_dims,
"method": {"engine": "lucene", "name": "hnsw", "space_type": "cosinesimil"},
"method": {"engine": "nmslib", "name": "hnsw", "space_type": "cosinesimil"},
},
"metadata": {"type": "object", "properties": {"user_id": {"type": "keyword"}}},
}
@@ -71,12 +70,15 @@ class OpenSearchDB(VectorStoreBase):
def create_col(self, name: str, vector_size: int) -> None:
"""Create a new collection (index in OpenSearch)."""
index_settings = {
"settings": {
"index.knn": True
},
"mappings": {
"properties": {
"vector": {
"vector_field": {
"type": "knn_vector",
"dimension": vector_size,
"method": {"engine": "lucene", "name": "hnsw", "space_type": "cosinesimil"},
"method": {"engine": "nmslib", "name": "hnsw", "space_type": "cosinesimil"},
},
"payload": {"type": "object"},
"id": {"type": "keyword"},
@@ -88,6 +90,24 @@ class OpenSearchDB(VectorStoreBase):
self.client.indices.create(index=name, body=index_settings)
logger.info(f"Created index {name}")
# Wait for index to be ready
max_retries = 60 # 60 seconds timeout
retry_count = 0
while retry_count < max_retries:
try:
# Check if index is ready by attempting a simple search
self.client.search(index=name, body={"query": {"match_all": {}}})
logger.info(f"Index {name} is ready")
time.sleep(1)
return
except Exception:
retry_count += 1
if retry_count == max_retries:
raise TimeoutError(
f"Index {name} creation timed out after {max_retries} seconds"
)
time.sleep(0.5)
def insert(
self, vectors: List[List[float]], payloads: Optional[List[Dict]] = None, ids: Optional[List[str]] = None
) -> List[OutputData]:
@@ -98,74 +118,161 @@ class OpenSearchDB(VectorStoreBase):
if payloads is None:
payloads = [{} for _ in range(len(vectors))]
actions = []
for i, (vec, id_) in enumerate(zip(vectors, ids)):
action = {
"_index": self.collection_name,
"_id": id_,
"_source": {
"vector": vec,
"metadata": payloads[i], # Store metadata in the metadata field
},
body = {
"vector_field": vec,
"payload": payloads[i],
"id": id_,
}
actions.append(action)
bulk(self.client, actions)
self.client.index(index=self.collection_name, body=body)
results = []
for i, id_ in enumerate(ids):
results.append(OutputData(id=id_, score=1.0, payload=payloads[i]))
return results
def search(
self, query: str, vectors: List[float], limit: int = 5, filters: Optional[Dict] = None
) -> List[OutputData]:
"""Search for similar vectors using OpenSearch k-NN search with pre-filtering."""
search_query = {
"size": limit,
"query": {
"knn": {
"vector": {
"vector": vectors,
"k": limit,
}
"""Search for similar vectors using OpenSearch k-NN search with optional filters."""
# Base KNN query
knn_query = {
"knn": {
"vector_field": {
"vector": vectors,
"k": limit * 2,
}
},
}
}
# Start building the full query
query_body = {
"size": limit * 2,
"query": None
}
# Prepare filter conditions if applicable
filter_clauses = []
if filters:
filter_conditions = [{"term": {f"metadata.{key}": value}} for key, value in filters.items()]
search_query["query"]["knn"]["vector"]["filter"] = {"bool": {"filter": filter_conditions}}
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
filter_clauses.append({
"term": {f"payload.{key}.keyword": value}
})
response = self.client.search(index=self.collection_name, body=search_query)
# Combine knn with filters if needed
if filter_clauses:
query_body["query"] = {
"bool": {
"must": knn_query,
"filter": filter_clauses
}
}
else:
query_body["query"] = knn_query
# Execute search
response = self.client.search(index=self.collection_name, body=query_body)
hits = response["hits"]["hits"]
results = [
OutputData(id=hit["_id"], score=hit["_score"], payload=hit["_source"].get("metadata", {}))
for hit in response["hits"]["hits"]
OutputData(
id=hit["_source"].get("id"),
score=hit["_score"],
payload=hit["_source"].get("payload", {})
)
for hit in hits
]
return results
def delete(self, vector_id: str) -> None:
"""Delete a vector by ID."""
self.client.delete(index=self.collection_name, id=vector_id)
"""Delete a vector by custom ID."""
# First, find the document by custom ID
search_query = {
"query": {
"term": {
"id": vector_id
}
}
}
response = self.client.search(index=self.collection_name, body=search_query)
hits = response.get("hits", {}).get("hits", [])
if not hits:
return
opensearch_id = hits[0]["_id"]
# Delete using the actual document ID
self.client.delete(index=self.collection_name, id=opensearch_id)
def update(self, vector_id: str, vector: Optional[List[float]] = None, payload: Optional[Dict] = None) -> None:
"""Update a vector and its payload."""
"""Update a vector and its payload using the custom 'id' field."""
# First, find the document by custom ID
search_query = {
"query": {
"term": {
"id": vector_id
}
}
}
response = self.client.search(index=self.collection_name, body=search_query)
hits = response.get("hits", {}).get("hits", [])
if not hits:
return
opensearch_id = hits[0]["_id"] # The actual document ID in OpenSearch
# Prepare updated fields
doc = {}
if vector is not None:
doc["vector"] = vector
doc["vector_field"] = vector
if payload is not None:
doc["metadata"] = payload
doc["payload"] = payload
if doc:
try:
response = self.client.update(index=self.collection_name, id=opensearch_id, body={"doc": doc})
except Exception:
pass
self.client.update(index=self.collection_name, id=vector_id, body={"doc": doc})
def get(self, vector_id: str) -> Optional[OutputData]:
"""Retrieve a vector by ID."""
try:
response = self.client.get(index=self.collection_name, id=vector_id)
return OutputData(id=response["_id"], score=1.0, payload=response["_source"].get("metadata", {}))
# First check if index exists
if not self.client.indices.exists(index=self.collection_name):
logger.info(f"Index {self.collection_name} does not exist, creating it...")
self.create_col(self.collection_name, self.embedding_model_dims)
return None
search_query = {
"query": {
"term": {
"id": vector_id
}
}
}
response = self.client.search(index=self.collection_name, body=search_query)
hits = response["hits"]["hits"]
if not hits:
return None
return OutputData(
id=hits[0]["_source"].get("id"),
score=1.0,
payload=hits[0]["_source"].get("payload", {})
)
except Exception as e:
logger.error(f"Error retrieving vector {vector_id}: {e}")
logger.error(f"Error retrieving vector {vector_id}: {str(e)}")
return None
def list_cols(self) -> List[str]:
@@ -180,28 +287,52 @@ class OpenSearchDB(VectorStoreBase):
"""Get information about a collection (index)."""
return self.client.indices.get(index=name)
def list(self, filters: Optional[Dict] = None, limit: Optional[int] = None) -> List[List[OutputData]]:
"""List all memories."""
query = {"query": {"match_all": {}}}
def list(self, filters: Optional[Dict] = None, limit: Optional[int] = None) -> List[OutputData]:
if filters:
query["query"] = {
"bool": {"must": [{"term": {f"metadata.{key}": value}} for key, value in filters.items()]}
try:
"""List all memories with optional filters."""
query: Dict = {
"query": {
"match_all": {}
}
}
if limit:
query["size"] = limit
filter_clauses = []
if filters:
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
filter_clauses.append({
"term": {f"payload.{key}.keyword": value}
})
if filter_clauses:
query["query"] = {
"bool": {
"filter": filter_clauses
}
}
if limit:
query["size"] = limit
response = self.client.search(index=self.collection_name, body=query)
hits = response["hits"]["hits"]
return [[
OutputData(
id=hit["_source"].get("id"),
score=1.0,
payload=hit["_source"].get("payload", {})
)
for hit in hits
]]
except Exception:
return []
response = self.client.search(index=self.collection_name, body=query)
return [
[
OutputData(id=hit["_id"], score=1.0, payload=hit["_source"].get("metadata", {}))
for hit in response["hits"]["hits"]
]
]
def reset(self):
"""Reset the index by deleting and recreating it."""
logger.warning(f"Resetting index {self.collection_name}...")
self.delete_col()
self.create_index()
self.create_col(self.collection_name, self.embedding_model_dims)
+13
View File
@@ -0,0 +1,13 @@
*.db
.env*
!.env.example
!.env.dev
!ui/lib
.venv/
__pycache__
.DS_Store
node_modules/
*.log
api/.openmemory*
**/.next
.openmemory/
+70
View File
@@ -0,0 +1,70 @@
# Contributing to OpenMemory
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
## Ways to Contribute
We welcome all forms of contributions:
- Bug reports and feature requests through GitHub Issues
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support and discussions
## Development Workflow
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
## Development Setup
### Backend Setup
```bash
# Copy environment file and edit file to update OPENAI_API_KEY and other secrets
make env
# Build the containers
make build
# Start the services
make up
```
### Frontend Setup
The frontend is a React application. To start the frontend:
```bash
# Install dependencies and start the development server
make ui-dev
```
### Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (for LLM interactions)
### Getting Started
Follow the setup instructions in the README.md file to set up your development environment.
## Code Standards
We value:
- Clean, well-documented code
- Thoughtful discussions about features and improvements
- Respectful and constructive feedback
- A welcoming environment for all contributors
## Pull Request Process
1. Ensure your code follows the project's coding standards
2. Update documentation as needed
3. Include tests for new features
4. Make sure all tests pass before submitting
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
+52
View File
@@ -0,0 +1,52 @@
.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev ui-build ui-dev-start
NEXT_PUBLIC_USER_ID=$(USER)
NEXT_PUBLIC_API_URL=http://localhost:8765
# Default target
help:
@echo "Available commands:"
@echo " make env - Copy .env.example to .env"
@echo " make up - Start the containers"
@echo " make down - Stop the containers"
@echo " make logs - Show container logs"
@echo " make shell - Open a shell in the api container"
@echo " make migrate - Run database migrations"
@echo " make test - Run tests in a new container"
@echo " make test-clean - Run tests and clean up volumes"
@echo " make ui-install - Install frontend dependencies"
@echo " make ui-start - Start the frontend development server"
@echo " make ui-dev - Install dependencies and start the frontend in dev mode"
@echo " make ui - Install dependencies and start the frontend in production mode"
env:
cd api && cp .env.example .env
cd ui && cp .env.example .env
build:
docker compose build
up:
NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) docker compose up
down:
docker compose down -v
rm -f api/openmemory.db
logs:
docker compose logs -f
shell:
docker compose exec api bash
upgrade:
docker compose exec api alembic upgrade head
migrate:
docker compose exec api alembic upgrade head
downgrade:
docker compose exec api alembic downgrade -1
ui-dev:
cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm install && pnpm dev
+50
View File
@@ -0,0 +1,50 @@
# OpenMemory
OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure.
![OpenMemory](https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b)
## Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (required for LLM interactions, run `cp api/.env.example api/.env` then change **OPENAI_API_KEY** to yours)
## Quickstart
You can run the project using the following two commands:
```bash
make build # builds the mcp server and ui
make up # runs openmemory mcp server and ui
```
After running these commands, you will have:
- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs)
- OpenMemory UI running at: http://localhost:3000
## Project Structure
- `api/` - Backend APIs + MCP server
- `ui/` - Frontend React application
## Contributing
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
We welcome all forms of contributions:
- Bug reports and feature requests
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support
How to contribute:
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
+1
View File
@@ -0,0 +1 @@
OPENAI_API_KEY=sk-xxx
+1
View File
@@ -0,0 +1 @@
3.12
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.name="mem0/openmemory-mcp"
WORKDIR /usr/src/openmemory
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8765
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]
+60
View File
@@ -0,0 +1,60 @@
# OpenMemory API
This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things.
## Quick Start with Docker (Recommended)
The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed.
1. Build the containers:
```bash
make build
```
2. Create `.env` file:
```bash
make env
```
Once you run this command, edit the file `api/.env` and enter the `OPENAI_API_KEY`.
3. Start the services:
```bash
make up
```
The API will be available at `http://localhost:8765`
### Common Docker Commands
- View logs: `make logs`
- Open shell in container: `make shell`
- Run database migrations: `make migrate`
- Run tests: `make test`
- Run tests and clean up: `make test-clean`
- Stop containers: `make down`
## API Documentation
Once the server is running, you can access the API documentation at:
- Swagger UI: `http://localhost:8765/docs`
- ReDoc: `http://localhost:8765/redoc`
## Project Structure
- `app/`: Main application code
- `models.py`: Database models
- `database.py`: Database configuration
- `routers/`: API route handlers
- `migrations/`: Database migration files
- `tests/`: Test files
- `alembic/`: Alembic migration configuration
- `main.py`: Application entry point
## Development Guidelines
- Follow PEP 8 style guide
- Use type hints
- Write tests for new features
- Update documentation when making changes
- Run migrations for database changes
+114
View File
@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./openmemory.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+92
View File
@@ -0,0 +1,92 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
from dotenv import load_dotenv
# Add the parent directory to the Python path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load environment variables
load_dotenv()
# Import your models here
from app.database import Base
from app.models import * # Import all your models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,226 @@
"""Initial migration
Revision ID: 0b53c747049a
Revises:
Create Date: 2025-04-19 00:59:56.244203
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0b53c747049a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('access_controls',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('subject_type', sa.String(), nullable=False),
sa.Column('subject_id', sa.UUID(), nullable=True),
sa.Column('object_type', sa.String(), nullable=False),
sa.Column('object_id', sa.UUID(), nullable=True),
sa.Column('effect', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False)
op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False)
op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False)
op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False)
op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False)
op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False)
op.create_table('archive_policies',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('criteria_type', sa.String(), nullable=False),
sa.Column('criteria_id', sa.UUID(), nullable=True),
sa.Column('days_to_archive', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False)
op.create_table('categories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True)
op.create_table('apps',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False)
op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False)
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True)
op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False)
op.create_table('memories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('content', sa.String(), nullable=False),
sa.Column('vector', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('archived_at', sa.DateTime(), nullable=True),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False)
op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False)
op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False)
op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False)
op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False)
op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False)
op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False)
op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False)
op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False)
op.create_table('memory_access_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('accessed_at', sa.DateTime(), nullable=True),
sa.Column('access_type', sa.String(), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False)
op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False)
op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False)
op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False)
op.create_table('memory_categories',
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('memory_id', 'category_id')
)
op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False)
op.create_table('memory_status_history',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('changed_by', sa.UUID(), nullable=False),
sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('changed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False)
op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False)
op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False)
op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False)
op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history')
op.drop_index('idx_history_user_time', table_name='memory_status_history')
op.drop_index('idx_history_memory_state', table_name='memory_status_history')
op.drop_table('memory_status_history')
op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories')
op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories')
op.drop_index('idx_memory_category', table_name='memory_categories')
op.drop_table('memory_categories')
op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs')
op.drop_index('idx_access_memory_time', table_name='memory_access_logs')
op.drop_index('idx_access_app_time', table_name='memory_access_logs')
op.drop_table('memory_access_logs')
op.drop_index(op.f('ix_memories_user_id'), table_name='memories')
op.drop_index(op.f('ix_memories_state'), table_name='memories')
op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories')
op.drop_index(op.f('ix_memories_created_at'), table_name='memories')
op.drop_index(op.f('ix_memories_archived_at'), table_name='memories')
op.drop_index(op.f('ix_memories_app_id'), table_name='memories')
op.drop_index('idx_memory_user_state', table_name='memories')
op.drop_index('idx_memory_user_app', table_name='memories')
op.drop_index('idx_memory_app_state', table_name='memories')
op.drop_table('memories')
op.drop_index(op.f('ix_apps_owner_id'), table_name='apps')
op.drop_index(op.f('ix_apps_name'), table_name='apps')
op.drop_index(op.f('ix_apps_is_active'), table_name='apps')
op.drop_index(op.f('ix_apps_created_at'), table_name='apps')
op.drop_table('apps')
op.drop_index(op.f('ix_users_user_id'), table_name='users')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_created_at'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_created_at'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies')
op.drop_index('idx_policy_criteria', table_name='archive_policies')
op.drop_table('archive_policies')
op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls')
op.drop_index('idx_access_subject', table_name='access_controls')
op.drop_index('idx_access_object', table_name='access_controls')
op.drop_table('access_controls')
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
# This file makes the app directory a Python package
+4
View File
@@ -0,0 +1,4 @@
import os
USER_ID = os.getenv("USER", "default_user")
DEFAULT_APP_ID = "openmemory"
+29
View File
@@ -0,0 +1,29 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from dotenv import load_dotenv
# load .env file (make sure you have DATABASE_URL set)
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL is not set in environment")
# SQLAlchemy engine & session
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
# Dependency for FastAPI
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+382
View File
@@ -0,0 +1,382 @@
import logging
import json
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from app.utils.memory import get_memory_client
from fastapi import FastAPI, Request
from fastapi.routing import APIRouter
import contextvars
import os
from dotenv import load_dotenv
from app.database import SessionLocal
from app.models import Memory, MemoryState, MemoryStatusHistory, MemoryAccessLog
from app.utils.db import get_user_and_app
import uuid
import datetime
from app.utils.permissions import check_memory_access_permissions
from qdrant_client import models as qdrant_models
# Load environment variables
load_dotenv()
# Initialize MCP and memory client
mcp = FastMCP("mem0-mcp-server")
# Check if OpenAI API key is set
if not os.getenv("OPENAI_API_KEY"):
raise Exception("OPENAI_API_KEY is not set in .env file")
memory_client = get_memory_client()
# Context variables for user_id and client_name
user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id")
client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name")
# Create a router for MCP endpoints
mcp_router = APIRouter(prefix="/mcp")
# Initialize SSE transport
sse = SseServerTransport("/mcp/messages/")
@mcp.tool(description="Add new memories to the user's memory")
async def add_memories(text: str) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Check if app is active
if not app.is_active:
return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories."
response = memory_client.add(text,
user_id=uid,
metadata={
"source_app": "openmemory",
"mcp_client": client_name,
})
# Process the response and update database
if isinstance(response, dict) and 'results' in response:
for result in response['results']:
memory_id = uuid.UUID(result['id'])
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if result['event'] == 'ADD':
if not memory:
memory = Memory(
id=memory_id,
user_id=user.id,
app_id=app.id,
content=result['memory'],
state=MemoryState.active
)
db.add(memory)
else:
memory.state = MemoryState.active
memory.content = result['memory']
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted if memory else None,
new_state=MemoryState.active
)
db.add(history)
elif result['event'] == 'DELETE':
if memory:
memory.state = MemoryState.deleted
memory.deleted_at = datetime.datetime.now(datetime.UTC)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
db.commit()
return response
finally:
db.close()
except Exception as e:
return f"Error adding to memory: {e}"
@mcp.tool(description="Search the user's memory for memories that match the query")
async def search_memory(query: str) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get accessible memory IDs based on ACL
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
conditions = [qdrant_models.FieldCondition(key="user_id", match=qdrant_models.MatchValue(value=uid))]
logging.info(f"Accessible memory IDs: {accessible_memory_ids}")
logging.info(f"Conditions: {conditions}")
if accessible_memory_ids:
# Convert UUIDs to strings for Qdrant
accessible_memory_ids_str = [str(memory_id) for memory_id in accessible_memory_ids]
conditions.append(qdrant_models.HasIdCondition(has_id=accessible_memory_ids_str))
filters = qdrant_models.Filter(must=conditions)
logging.info(f"Filters: {filters}")
embeddings = memory_client.embedding_model.embed(query, "search")
hits = memory_client.vector_store.client.query_points(
collection_name=memory_client.vector_store.collection_name,
query=embeddings,
query_filter=filters,
limit=10,
)
memories = hits.points
memories = [
{
"id": memory.id,
"memory": memory.payload["data"],
"hash": memory.payload.get("hash"),
"created_at": memory.payload.get("created_at"),
"updated_at": memory.payload.get("updated_at"),
"score": memory.score,
}
for memory in memories
]
# Log memory access for each memory found
if isinstance(memories, dict) and 'results' in memories:
print(f"Memories: {memories}")
for memory_data in memories['results']:
if 'id' in memory_data:
memory_id = uuid.UUID(memory_data['id'])
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="search",
metadata_={
"query": query,
"score": memory_data.get('score'),
"hash": memory_data.get('hash')
}
)
db.add(access_log)
db.commit()
else:
for memory in memories:
memory_id = uuid.UUID(memory['id'])
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="search",
metadata_={
"query": query,
"score": memory.get('score'),
"hash": memory.get('hash')
}
)
db.add(access_log)
db.commit()
return json.dumps(memories, indent=2)
finally:
db.close()
except Exception as e:
logging.exception(e)
return f"Error searching memory: {e}"
@mcp.tool(description="List all memories in the user's memory")
async def list_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get all memories
memories = memory_client.get_all(user_id=uid)
filtered_memories = []
# Filter memories based on permissions
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
if isinstance(memories, dict) and 'results' in memories:
for memory_data in memories['results']:
if 'id' in memory_data:
memory_id = uuid.UUID(memory_data['id'])
if memory_id in accessible_memory_ids:
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory_data.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory_data)
db.commit()
else:
for memory in memories:
memory_id = uuid.UUID(memory['id'])
memory_obj = db.query(Memory).filter(Memory.id == memory_id).first()
if memory_obj and check_memory_access_permissions(db, memory_obj, app.id):
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory)
db.commit()
return json.dumps(filtered_memories, indent=2)
finally:
db.close()
except Exception as e:
return f"Error getting memories: {e}"
@mcp.tool(description="Delete all memories in the user's memory")
async def delete_all_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
# delete the accessible memories only
for memory_id in accessible_memory_ids:
memory_client.delete(memory_id)
# Update each memory's state and create history entries
now = datetime.datetime.now(datetime.UTC)
for memory_id in accessible_memory_ids:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
# Update memory state
memory.state = MemoryState.deleted
memory.deleted_at = now
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="delete_all",
metadata_={"operation": "bulk_delete"}
)
db.add(access_log)
db.commit()
return "Successfully deleted all memories"
finally:
db.close()
except Exception as e:
return f"Error deleting memories: {e}"
@mcp_router.get("/{client_name}/sse/{user_id}")
async def handle_sse(request: Request):
"""Handle SSE connections for a specific user and client"""
# Extract user_id and client_name from path parameters
uid = request.path_params.get("user_id")
user_token = user_id_var.set(uid or "")
client_name = request.path_params.get("client_name")
client_token = client_name_var.set(client_name or "")
try:
# Handle SSE connection
async with sse.connect_sse(
request.scope,
request.receive,
request._send,
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options(),
)
finally:
# Clean up context variables
user_id_var.reset(user_token)
client_name_var.reset(client_token)
@mcp_router.post("/messages/")
async def handle_post_message(request: Request):
"""Handle POST messages for SSE"""
try:
body = await request.body()
# Create a simple receive function that returns the body
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
# Create a simple send function that does nothing
async def send(message):
pass
# Call handle_post_message with the correct arguments
await sse.handle_post_message(request.scope, receive, send)
# Return a success response
return {"status": "ok"}
finally:
pass
# Clean up context variable
# client_name_var.reset(client_token)
def setup_mcp_server(app: FastAPI):
"""Setup MCP server with the FastAPI application"""
mcp._mcp_server.name = f"mem0-mcp-server"
# Include MCP router in the FastAPI app
app.include_router(mcp_router)
+217
View File
@@ -0,0 +1,217 @@
import enum
import uuid
import datetime
from sqlalchemy import (
Column, String, Boolean, ForeignKey, Enum, Table,
DateTime, JSON, Integer, UUID, Index, event
)
from sqlalchemy.orm import relationship
from app.database import Base
from sqlalchemy.orm import Session
from app.utils.categorization import get_categories_for_memory
def get_current_utc_time():
"""Get current UTC time"""
return datetime.datetime.now(datetime.UTC)
class MemoryState(enum.Enum):
active = "active"
paused = "paused"
archived = "archived"
deleted = "deleted"
class User(Base):
__tablename__ = "users"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(String, nullable=False, unique=True, index=True)
name = Column(String, nullable=True, index=True)
email = Column(String, unique=True, nullable=True, index=True)
metadata_ = Column('metadata', JSON, default=dict)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
apps = relationship("App", back_populates="owner")
memories = relationship("Memory", back_populates="user")
class App(Base):
__tablename__ = "apps"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
owner = relationship("User", back_populates="apps")
memories = relationship("Memory", back_populates="app")
class Memory(Base):
__tablename__ = "memories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
content = Column(String, nullable=False)
vector = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
state = Column(Enum(MemoryState), default=MemoryState.active, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
archived_at = Column(DateTime, nullable=True, index=True)
deleted_at = Column(DateTime, nullable=True, index=True)
user = relationship("User", back_populates="memories")
app = relationship("App", back_populates="memories")
categories = relationship("Category", secondary="memory_categories", back_populates="memories")
__table_args__ = (
Index('idx_memory_user_state', 'user_id', 'state'),
Index('idx_memory_app_state', 'app_id', 'state'),
Index('idx_memory_user_app', 'user_id', 'app_id'),
)
class Category(Base):
__tablename__ = "categories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String)
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
memories = relationship("Memory", secondary="memory_categories", back_populates="categories")
memory_categories = Table(
"memory_categories", Base.metadata,
Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True),
Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True),
Index('idx_memory_category', 'memory_id', 'category_id')
)
class AccessControl(Base):
__tablename__ = "access_controls"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
subject_type = Column(String, nullable=False, index=True)
subject_id = Column(UUID, nullable=True, index=True)
object_type = Column(String, nullable=False, index=True)
object_id = Column(UUID, nullable=True, index=True)
effect = Column(String, nullable=False, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_access_subject', 'subject_type', 'subject_id'),
Index('idx_access_object', 'object_type', 'object_id'),
)
class ArchivePolicy(Base):
__tablename__ = "archive_policies"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
criteria_type = Column(String, nullable=False, index=True)
criteria_id = Column(UUID, nullable=True, index=True)
days_to_archive = Column(Integer, nullable=False)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_policy_criteria', 'criteria_type', 'criteria_id'),
)
class MemoryStatusHistory(Base):
__tablename__ = "memory_status_history"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
old_state = Column(Enum(MemoryState), nullable=False, index=True)
new_state = Column(Enum(MemoryState), nullable=False, index=True)
changed_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_history_memory_state', 'memory_id', 'new_state'),
Index('idx_history_user_time', 'changed_by', 'changed_at'),
)
class MemoryAccessLog(Base):
__tablename__ = "memory_access_logs"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
accessed_at = Column(DateTime, default=get_current_utc_time, index=True)
access_type = Column(String, nullable=False, index=True)
metadata_ = Column('metadata', JSON, default=dict)
__table_args__ = (
Index('idx_access_memory_time', 'memory_id', 'accessed_at'),
Index('idx_access_app_time', 'app_id', 'accessed_at'),
)
def categorize_memory(memory: Memory, db: Session) -> None:
"""Categorize a memory using OpenAI and store the categories in the database."""
try:
# Get categories from OpenAI
categories = get_categories_for_memory(memory.content)
# Get or create categories in the database
for category_name in categories:
category = db.query(Category).filter(Category.name == category_name).first()
if not category:
category = Category(
name=category_name,
description=f"Automatically created category for {category_name}"
)
db.add(category)
db.flush() # Flush to get the category ID
# Check if the memory-category association already exists
existing = db.execute(
memory_categories.select().where(
(memory_categories.c.memory_id == memory.id) &
(memory_categories.c.category_id == category.id)
)
).first()
if not existing:
# Create the association
db.execute(
memory_categories.insert().values(
memory_id=memory.id,
category_id=category.id
)
)
db.commit()
except Exception as e:
db.rollback()
print(f"Error categorizing memory: {e}")
@event.listens_for(Memory, 'after_insert')
def after_memory_insert(mapper, connection, target):
"""Trigger categorization after a memory is inserted."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()
@event.listens_for(Memory, 'after_update')
def after_memory_update(mapper, connection, target):
"""Trigger categorization after a memory is updated."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()
+5
View File
@@ -0,0 +1,5 @@
from .memories import router as memories_router
from .apps import router as apps_router
from .stats import router as stats_router
__all__ = ["memories_router", "apps_router", "stats_router"]
+223
View File
@@ -0,0 +1,223 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, desc
from app.database import get_db
from app.models import App, Memory, MemoryAccessLog, MemoryState
router = APIRouter(prefix="/api/v1/apps", tags=["apps"])
# Helper functions
def get_app_or_404(db: Session, app_id: UUID) -> App:
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="App not found")
return app
# List all apps with filtering
@router.get("/")
async def list_apps(
name: Optional[str] = None,
is_active: Optional[bool] = None,
sort_by: str = 'name',
sort_direction: str = 'asc',
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Create a subquery for memory counts
memory_counts = db.query(
Memory.app_id,
func.count(Memory.id).label('memory_count')
).filter(
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
).group_by(Memory.app_id).subquery()
# Create a subquery for access counts
access_counts = db.query(
MemoryAccessLog.app_id,
func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count')
).group_by(MemoryAccessLog.app_id).subquery()
# Base query
query = db.query(
App,
func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'),
func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed')
)
# Join with subqueries
query = query.outerjoin(
memory_counts,
App.id == memory_counts.c.app_id
).outerjoin(
access_counts,
App.id == access_counts.c.app_id
)
if name:
query = query.filter(App.name.ilike(f"%{name}%"))
if is_active is not None:
query = query.filter(App.is_active == is_active)
# Apply sorting
if sort_by == 'name':
sort_field = App.name
elif sort_by == 'memories':
sort_field = func.coalesce(memory_counts.c.memory_count, 0)
elif sort_by == 'memories_accessed':
sort_field = func.coalesce(access_counts.c.access_count, 0)
else:
sort_field = App.name # default sort
if sort_direction == 'desc':
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
total = query.count()
apps = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"apps": [
{
"id": app[0].id,
"name": app[0].name,
"is_active": app[0].is_active,
"total_memories_created": app[1],
"total_memories_accessed": app[2]
}
for app in apps
]
}
# Get app details
@router.get("/{app_id}")
async def get_app_details(
app_id: UUID,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
# Get memory access statistics
access_stats = db.query(
func.count(MemoryAccessLog.id).label("total_memories_accessed"),
func.min(MemoryAccessLog.accessed_at).label("first_accessed"),
func.max(MemoryAccessLog.accessed_at).label("last_accessed")
).filter(MemoryAccessLog.app_id == app_id).first()
return {
"is_active": app.is_active,
"total_memories_created": db.query(Memory)
.filter(Memory.app_id == app_id)
.count(),
"total_memories_accessed": access_stats.total_memories_accessed or 0,
"first_accessed": access_stats.first_accessed,
"last_accessed": access_stats.last_accessed
}
# List memories created by app
@router.get("/{app_id}/memories")
async def list_app_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
get_app_or_404(db, app_id)
query = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
for memory in memories
]
}
# List memories accessed by app
@router.get("/{app_id}/accessed")
async def list_app_accessed_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Get memories with access counts
query = db.query(
Memory,
func.count(MemoryAccessLog.id).label("access_count")
).join(
MemoryAccessLog,
Memory.id == MemoryAccessLog.memory_id
).filter(
MemoryAccessLog.app_id == app_id
).group_by(
Memory.id
).order_by(
desc("access_count")
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
results = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"memory": {
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
},
"access_count": count
}
for memory, count in results
]
}
@router.put("/{app_id}")
async def update_app_details(
app_id: UUID,
is_active: bool,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
app.is_active = is_active
db.commit()
return {"status": "success", "message": "Updated app details successfully"}
+575
View File
@@ -0,0 +1,575 @@
from datetime import datetime, UTC
from typing import List, Optional, Set
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from fastapi_pagination import Page, Params
from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate
from pydantic import BaseModel
from sqlalchemy import or_, func
from app.database import get_db
from app.models import (
Memory, MemoryState, MemoryAccessLog, App,
MemoryStatusHistory, User, Category, AccessControl
)
from app.schemas import MemoryResponse, PaginatedMemoryResponse
from app.utils.permissions import check_memory_access_permissions
router = APIRouter(prefix="/api/v1/memories", tags=["memories"])
def get_memory_or_404(db: Session, memory_id: UUID) -> Memory:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if not memory:
raise HTTPException(status_code=404, detail="Memory not found")
return memory
def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID):
memory = get_memory_or_404(db, memory_id)
old_state = memory.state
# Update memory state
memory.state = new_state
if new_state == MemoryState.archived:
memory.archived_at = datetime.now(UTC)
elif new_state == MemoryState.deleted:
memory.deleted_at = datetime.now(UTC)
# Record state change
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user_id,
old_state=old_state,
new_state=new_state
)
db.add(history)
db.commit()
return memory
def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]:
"""
Get the set of memory IDs that the app has access to based on app-level ACL rules.
Returns all memory IDs if no specific restrictions are found.
"""
# Get app-level access controls
app_access = db.query(AccessControl).filter(
AccessControl.subject_type == "app",
AccessControl.subject_id == app_id,
AccessControl.object_type == "memory"
).all()
# If no app-level rules exist, return None to indicate all memories are accessible
if not app_access:
return None
# Initialize sets for allowed and denied memory IDs
allowed_memory_ids = set()
denied_memory_ids = set()
# Process app-level rules
for rule in app_access:
if rule.effect == "allow":
if rule.object_id: # Specific memory access
allowed_memory_ids.add(rule.object_id)
else: # All memories access
return None # All memories allowed
elif rule.effect == "deny":
if rule.object_id: # Specific memory denied
denied_memory_ids.add(rule.object_id)
else: # All memories denied
return set() # No memories accessible
# Remove denied memories from allowed set
if allowed_memory_ids:
allowed_memory_ids -= denied_memory_ids
return allowed_memory_ids
# List all memories with filtering
@router.get("/", response_model=Page[MemoryResponse])
async def list_memories(
user_id: str,
app_id: Optional[UUID] = None,
from_date: Optional[int] = Query(
None,
description="Filter memories created after this date (timestamp)",
examples=[1718505600]
),
to_date: Optional[int] = Query(
None,
description="Filter memories created before this date (timestamp)",
examples=[1718505600]
),
categories: Optional[str] = None,
params: Params = Depends(),
search_query: Optional[str] = None,
sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"),
sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived,
Memory.content.ilike(f"%{search_query}%") if search_query else True
)
# Apply filters
if app_id:
query = query.filter(Memory.app_id == app_id)
if from_date:
from_datetime = datetime.fromtimestamp(from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if to_date:
to_datetime = datetime.fromtimestamp(to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Add joins for app and categories after filtering
query = query.outerjoin(App, Memory.app_id == App.id)
query = query.outerjoin(Memory.categories)
# Apply category filter if provided
if categories:
category_list = [c.strip() for c in categories.split(",")]
query = query.filter(Category.name.in_(category_list))
# Apply sorting if specified
if sort_column:
sort_field = getattr(Memory, sort_column, None)
if sort_field:
query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc())
# Get paginated results
paginated_results = sqlalchemy_paginate(query, params)
# Filter results based on permissions
filtered_items = []
for item in paginated_results.items:
if check_memory_access_permissions(db, item, app_id):
filtered_items.append(item)
# Update paginated results with filtered items
paginated_results.items = filtered_items
paginated_results.total = len(filtered_items)
return paginated_results
# Get all categories
@router.get("/categories")
async def get_categories(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get unique categories associated with the user's memories
# Get all memories
memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all()
# Get all categories from memories
categories = [category for memory in memories for category in memory.categories]
# Get unique categories
unique_categories = list(set(categories))
return {
"categories": unique_categories,
"total": len(unique_categories)
}
class CreateMemoryRequest(BaseModel):
user_id: str
text: str
metadata: dict = {}
infer: bool = True
app: str = "openmemory"
# Create new memory
@router.post("/")
async def create_memory(
request: CreateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get or create app
app_obj = db.query(App).filter(App.name == request.app).first()
if not app_obj:
app_obj = App(name=request.app, owner_id=user.id)
db.add(app_obj)
db.commit()
db.refresh(app_obj)
# Check if app is active
if not app_obj.is_active:
raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.")
# Create memory
memory = Memory(
user_id=user.id,
app_id=app_obj.id,
content=request.text,
metadata_=request.metadata
)
db.add(memory)
db.commit()
db.refresh(memory)
return memory
# Get memory by ID
@router.get("/{memory_id}")
async def get_memory(
memory_id: UUID,
db: Session = Depends(get_db)
):
memory = get_memory_or_404(db, memory_id)
return {
"id": memory.id,
"text": memory.content,
"created_at": int(memory.created_at.timestamp()),
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
class DeleteMemoriesRequest(BaseModel):
memory_ids: List[UUID]
user_id: str
# Delete multiple memories
@router.delete("/")
async def delete_memories(
request: DeleteMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
for memory_id in request.memory_ids:
update_memory_state(db, memory_id, MemoryState.deleted, user.id)
return {"message": f"Successfully deleted {len(request.memory_ids)} memories"}
# Archive memories
@router.post("/actions/archive")
async def archive_memories(
memory_ids: List[UUID],
user_id: UUID,
db: Session = Depends(get_db)
):
for memory_id in memory_ids:
update_memory_state(db, memory_id, MemoryState.archived, user_id)
return {"message": f"Successfully archived {len(memory_ids)} memories"}
class PauseMemoriesRequest(BaseModel):
memory_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
app_id: Optional[UUID] = None
all_for_app: bool = False
global_pause: bool = False
state: Optional[MemoryState] = None
user_id: str
# Pause access to memories
@router.post("/actions/pause")
async def pause_memories(
request: PauseMemoriesRequest,
db: Session = Depends(get_db)
):
global_pause = request.global_pause
all_for_app = request.all_for_app
app_id = request.app_id
memory_ids = request.memory_ids
category_ids = request.category_ids
state = request.state or MemoryState.paused
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_id = user.id
if global_pause:
# Pause all memories
memories = db.query(Memory).filter(
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": "Successfully paused all memories"}
if app_id:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused all memories for app {app_id}"}
if all_for_app and memory_ids:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.id.in_(memory_ids)
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused all memories"}
if memory_ids:
# Pause specific memories
for memory_id in memory_ids:
update_memory_state(db, memory_id, state, user_id)
return {"message": f"Successfully paused {len(memory_ids)} memories"}
if category_ids:
# Pause memories by category
memories = db.query(Memory).join(Memory.categories).filter(
Category.id.in_(category_ids),
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused memories in {len(category_ids)} categories"}
raise HTTPException(status_code=400, detail="Invalid pause request parameters")
# Get memory access logs
@router.get("/{memory_id}/access-log")
async def get_memory_access_log(
memory_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id)
total = query.count()
logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Get app name
for log in logs:
app = db.query(App).filter(App.id == log.app_id).first()
log.app_name = app.name if app else None
return {
"total": total,
"page": page,
"page_size": page_size,
"logs": logs
}
class UpdateMemoryRequest(BaseModel):
memory_content: str
user_id: str
# Update a memory
@router.put("/{memory_id}")
async def update_memory(
memory_id: UUID,
request: UpdateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
memory = get_memory_or_404(db, memory_id)
memory.content = request.memory_content
db.commit()
db.refresh(memory)
return memory
class FilterMemoriesRequest(BaseModel):
user_id: str
page: int = 1
size: int = 10
search_query: Optional[str] = None
app_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
sort_column: Optional[str] = None
sort_direction: Optional[str] = None
from_date: Optional[int] = None
to_date: Optional[int] = None
show_archived: Optional[bool] = False
@router.post("/filter", response_model=Page[MemoryResponse])
async def filter_memories(
request: FilterMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
)
# Filter archived memories based on show_archived parameter
if not request.show_archived:
query = query.filter(Memory.state != MemoryState.archived)
# Apply search filter
if request.search_query:
query = query.filter(Memory.content.ilike(f"%{request.search_query}%"))
# Apply app filter
if request.app_ids:
query = query.filter(Memory.app_id.in_(request.app_ids))
# Add joins for app and categories
query = query.outerjoin(App, Memory.app_id == App.id)
# Apply category filter
if request.category_ids:
query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids))
else:
query = query.outerjoin(Memory.categories)
# Apply date filters
if request.from_date:
from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if request.to_date:
to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Apply sorting
if request.sort_column and request.sort_direction:
sort_direction = request.sort_direction.lower()
if sort_direction not in ['asc', 'desc']:
raise HTTPException(status_code=400, detail="Invalid sort direction")
sort_mapping = {
'memory': Memory.content,
'app_name': App.name,
'created_at': Memory.created_at
}
if request.sort_column not in sort_mapping:
raise HTTPException(status_code=400, detail="Invalid sort column")
sort_field = sort_mapping[request.sort_column]
if sort_direction == 'desc':
query = query.order_by(sort_field.desc())
else:
query = query.order_by(sort_field.asc())
else:
# Default sorting
query = query.order_by(Memory.created_at.desc())
# Add eager loading for categories and make the query distinct
query = query.options(
joinedload(Memory.categories)
).distinct(Memory.id)
# Use fastapi-pagination's paginate function
return sqlalchemy_paginate(
query,
Params(page=request.page, size=request.size),
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)
@router.get("/{memory_id}/related", response_model=Page[MemoryResponse])
async def get_related_memories(
memory_id: UUID,
user_id: str,
params: Params = Depends(),
db: Session = Depends(get_db)
):
# Validate user
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get the source memory
memory = get_memory_or_404(db, memory_id)
# Extract category IDs from the source memory
category_ids = [category.id for category in memory.categories]
if not category_ids:
return Page.create([], total=0, params=params)
# Build query for related memories
query = db.query(Memory).distinct(Memory.id).filter(
Memory.user_id == user.id,
Memory.id != memory_id,
Memory.state != MemoryState.deleted
).join(Memory.categories).filter(
Category.id.in_(category_ids)
).options(
joinedload(Memory.categories),
joinedload(Memory.app)
).order_by(
func.count(Category.id).desc(),
Memory.created_at.desc()
).group_by(Memory.id)
# ⚡ Force page size to be 5
params = Params(page=params.page, size=5)
return sqlalchemy_paginate(
query,
params,
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Memory, App, MemoryState
router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
@router.get("/")
async def get_profile(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get total number of memories
total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count()
# Get total number of apps
apps = db.query(App).filter(App.owner == user)
total_apps = apps.count()
return {
"total_memories": total_memories,
"total_apps": total_apps,
"apps": apps.all()
}
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime
from typing import Optional, List
from uuid import UUID
from pydantic import BaseModel, Field, validator
class MemoryBase(BaseModel):
content: str
metadata_: Optional[dict] = Field(default_factory=dict)
class MemoryCreate(MemoryBase):
user_id: UUID
app_id: UUID
class Category(BaseModel):
name: str
class App(BaseModel):
id: UUID
name: str
class Memory(MemoryBase):
id: UUID
user_id: UUID
app_id: UUID
created_at: datetime
updated_at: Optional[datetime] = None
state: str
categories: Optional[List[Category]] = None
app: App
class Config:
from_attributes = True
class MemoryUpdate(BaseModel):
content: Optional[str] = None
metadata_: Optional[dict] = None
state: Optional[str] = None
class MemoryResponse(BaseModel):
id: UUID
content: str
created_at: int
state: str
app_id: UUID
app_name: str
categories: List[str]
metadata_: Optional[dict] = None
@validator('created_at', pre=True)
def convert_to_epoch(cls, v):
if isinstance(v, datetime):
return int(v.timestamp())
return v
class PaginatedMemoryResponse(BaseModel):
items: List[MemoryResponse]
total: int
page: int
size: int
pages: int
@@ -0,0 +1,37 @@
import json
import logging
from openai import OpenAI
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT
load_dotenv()
openai_client = OpenAI()
class MemoryCategories(BaseModel):
categories: List[str]
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
def get_categories_for_memory(memory: str) -> List[str]:
"""Get categories for a memory."""
try:
response = openai_client.responses.parse(
model="gpt-4o-mini",
instructions=MEMORY_CATEGORIZATION_PROMPT,
input=memory,
temperature=0,
text_format=MemoryCategories,
)
response_json =json.loads(response.output[0].content[0].text)
categories = response_json['categories']
categories = [cat.strip().lower() for cat in categories]
# TODO: Validate categories later may be
return categories
except Exception as e:
raise e
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from app.models import User, App
from typing import Tuple
def get_or_create_user(db: Session, user_id: str) -> User:
"""Get or create a user with the given user_id"""
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
user = User(user_id=user_id)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_or_create_app(db: Session, user: User, app_id: str) -> App:
"""Get or create an app for the given user"""
app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first()
if not app:
app = App(owner_id=user.id, name=app_id)
db.add(app)
db.commit()
db.refresh(app)
return app
def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]:
"""Get or create both user and their app"""
user = get_or_create_user(db, user_id)
app = get_or_create_app(db, user, app_id)
return user, app
+51
View File
@@ -0,0 +1,51 @@
import os
from mem0 import Memory
memory_client = None
def get_memory_client(custom_instructions: str = None):
"""
Get or initialize the Mem0 client.
Args:
custom_instructions: Optional instructions for the memory project.
Returns:
Initialized Mem0 client instance.
Raises:
Exception: If required API keys are not set.
"""
global memory_client
if memory_client is not None:
return memory_client
try:
config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "openmemory",
"host": "mem0_store",
"port": 6333,
}
}
}
memory_client = Memory.from_config(config_dict=config)
except Exception:
raise Exception("Exception occurred while initializing memory client")
# Update project with custom instructions if provided
if custom_instructions:
memory_client.update_project(custom_instructions=custom_instructions)
return memory_client
def get_default_user_id():
return "default_user"
+52
View File
@@ -0,0 +1,52 @@
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models import Memory, App, MemoryState
def check_memory_access_permissions(
db: Session,
memory: Memory,
app_id: Optional[UUID] = None
) -> bool:
"""
Check if the given app has permission to access a memory based on:
1. Memory state (must be active)
2. App state (must not be paused)
3. App-specific access controls
Args:
db: Database session
memory: Memory object to check access for
app_id: Optional app ID to check permissions for
Returns:
bool: True if access is allowed, False otherwise
"""
# Check if memory is active
if memory.state != MemoryState.active:
return False
# If no app_id provided, only check memory state
if not app_id:
return True
# Check if app exists and is active
app = db.query(App).filter(App.id == app_id).first()
if not app:
return False
# Check if app is paused/inactive
if not app.is_active:
return False
# Check app-specific access controls
from app.routers.memories import get_accessible_memory_ids
accessible_memory_ids = get_accessible_memory_ids(db, app_id)
# If accessible_memory_ids is None, all memories are accessible
if accessible_memory_ids is None:
return True
# Check if memory is in the accessible set
return memory.id in accessible_memory_ids
+28
View File
@@ -0,0 +1,28 @@
MEMORY_CATEGORIZATION_PROMPT = """Your task is to assign each piece of information (or “memory”) to one or more of the following categories. Feel free to use multiple categories per item when appropriate.
- Personal: family, friends, home, hobbies, lifestyle
- Relationships: social network, significant others, colleagues
- Preferences: likes, dislikes, habits, favorite media
- Health: physical fitness, mental health, diet, sleep
- Travel: trips, commutes, favorite places, itineraries
- Work: job roles, companies, projects, promotions
- Education: courses, degrees, certifications, skills development
- Projects: to‑dos, milestones, deadlines, status updates
- AI, ML & Technology: infrastructure, algorithms, tools, research
- Technical Support: bug reports, error logs, fixes
- Finance: income, expenses, investments, billing
- Shopping: purchases, wishlists, returns, deliveries
- Legal: contracts, policies, regulations, privacy
- Entertainment: movies, music, games, books, events
- Messages: emails, SMS, alerts, reminders
- Customer Support: tickets, inquiries, resolutions
- Product Feedback: ratings, bug reports, feature requests
- News: articles, headlines, trending topics
- Organization: meetings, appointments, calendars
- Goals: ambitions, KPIs, long‑term objectives
Guidelines:
- Return only the categories under 'categories' key in the JSON format.
- If you cannot categorize the memory, return an empty list with key 'categories'.
- Don't limit yourself to the categories listed above only. Feel free to create new categories based on the memory. Make sure that it is a single phrase.
"""
+86
View File
@@ -0,0 +1,86 @@
import datetime
from fastapi import FastAPI
from app.database import engine, Base, SessionLocal
from app.mcp_server import setup_mcp_server
from app.routers import memories_router, apps_router, stats_router
from fastapi_pagination import add_pagination
from fastapi.middleware.cors import CORSMiddleware
from app.models import User, App
from uuid import uuid4
from app.config import USER_ID, DEFAULT_APP_ID
app = FastAPI(title="OpenMemory API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Create all tables
Base.metadata.create_all(bind=engine)
# Check for USER_ID and create default user if needed
def create_default_user():
db = SessionLocal()
try:
# Check if user exists
user = db.query(User).filter(User.user_id == USER_ID).first()
if not user:
# Create default user
user = User(
id=uuid4(),
user_id=USER_ID,
name="Default User",
created_at=datetime.datetime.now(datetime.UTC)
)
db.add(user)
db.commit()
finally:
db.close()
def create_default_app():
db = SessionLocal()
try:
user = db.query(User).filter(User.user_id == USER_ID).first()
if not user:
return
# Check if app already exists
existing_app = db.query(App).filter(
App.name == DEFAULT_APP_ID,
App.owner_id == user.id
).first()
if existing_app:
return
app = App(
id=uuid4(),
name=DEFAULT_APP_ID,
owner_id=user.id,
created_at=datetime.datetime.now(datetime.UTC),
updated_at=datetime.datetime.now(datetime.UTC),
)
db.add(app)
db.commit()
finally:
db.close()
# Create default user on startup
create_default_user()
create_default_app()
# Setup MCP server
setup_mcp_server(app)
# Include routers
app.include_router(memories_router)
app.include_router(apps_router)
app.include_router(stats_router)
# Add pagination support
add_pagination(app)
+15
View File
@@ -0,0 +1,15 @@
fastapi>=0.68.0
uvicorn>=0.15.0
sqlalchemy>=1.4.0
python-dotenv>=0.19.0
alembic>=1.7.0
psycopg2-binary>=2.9.0
python-multipart>=0.0.5
fastapi-pagination>=0.12.0
mem0ai>=0.1.92
mcp[cli]>=1.3.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0
pytest-cov>=4.0.0
tenacity==9.1.2
+35
View File
@@ -0,0 +1,35 @@
services:
mem0_store:
image: qdrant/qdrant
ports:
- "6333:6333"
volumes:
- mem0_storage:/mem0/storage
openmemory-mcp:
image: mem0/openmemory-mcp
build: api/
environment:
- USER
env_file:
- api/.env
depends_on:
- mem0_store
ports:
- "8765:8765"
volumes:
- ./api:/usr/src/openmemory
command: >
sh -c "uvicorn main:app --host 0.0.0.0 --port 8765 --reload --workers 4"
openmemory-ui:
build:
context: ui/
dockerfile: Dockerfile
image: mem0/openmemory-ui:latest
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_USER_ID=${USER}
volumes:
mem0_storage:
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker.io/docker/dockerfile:1
# Base stage for common setup
FROM node:18-alpine AS base
# Install dependencies for pnpm
RUN apk add --no-cache libc6-compat curl && \
corepack enable && \
corepack prepare pnpm@latest --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY . .
RUN cp next.config.dev.mjs next.config.mjs
RUN cp .env.example .env
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh
RUN chmod +x /home/nextjs/entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/home/nextjs/entrypoint.sh"]
CMD ["node", "server.js"]
@@ -0,0 +1,166 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { PauseIcon, Loader2, PlayIcon } from "lucide-react";
import { useAppsApi } from "@/hooks/useAppsApi";
import Image from "next/image";
import { useDispatch, useSelector } from "react-redux";
import { setAppDetails } from "@/store/appsSlice";
import { BiEdit } from "react-icons/bi";
import { constants } from "@/components/shared/source-app";
import { RootState } from "@/store/store";
const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
const AppDetailCard = ({
appId,
selectedApp,
}: {
appId: string;
selectedApp: any;
}) => {
const { updateAppDetails } = useAppsApi();
const [isLoading, setIsLoading] = useState(false);
const dispatch = useDispatch();
const apps = useSelector((state: RootState) => state.apps.apps);
const currentApp = apps.find((app: any) => app.id === appId);
const appConfig = currentApp
? constants[currentApp.name as keyof typeof constants] || constants.default
: constants.default;
const handlePauseAccess = async () => {
setIsLoading(true);
try {
await updateAppDetails(appId, {
is_active: !selectedApp.details.is_active,
});
dispatch(
setAppDetails({ appId, isActive: !selectedApp.details.is_active })
);
} catch (error) {
console.error("Failed to toggle app pause state:", error);
} finally {
setIsLoading(false);
}
};
const buttonText = selectedApp.details.is_active
? "Pause Access"
: "Unpause Access";
return (
<div>
<div className="bg-zinc-900 border w-[320px] border-zinc-800 rounded-xl mb-6">
<div className="flex items-center gap-2 mb-4 bg-zinc-800 rounded-t-xl p-3">
<div className="w-5 h-5 flex items-center justify-center">
{appConfig.iconImage ? (
<div>
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={appConfig.iconImage}
alt={appConfig.name}
width={40}
height={40}
/>
</div>
</div>
) : (
<div className="w-5 h-5 flex items-center justify-center bg-zinc-700 rounded-full">
<BiEdit className="w-4 h-4 text-zinc-400" />
</div>
)}
</div>
<h2 className="text-md font-semibold">{appConfig.name}</h2>
</div>
<div className="space-y-4 p-3">
<div>
<p className="text-xs text-zinc-400">Access Status</p>
<p
className={`font-medium ${
selectedApp.details.is_active
? "text-emerald-500"
: "text-red-500"
}`}
>
{capitalize(
selectedApp.details.is_active ? "active" : "inactive"
)}
</p>
</div>
<div>
<p className="text-xs text-zinc-400">Total Memories Created</p>
<p className="font-medium">
{selectedApp.details.total_memories_created} Memories
</p>
</div>
<div>
<p className="text-xs text-zinc-400">Total Memories Accessed</p>
<p className="font-medium">
{selectedApp.details.total_memories_accessed} Memories
</p>
</div>
<div>
<p className="text-xs text-zinc-400">First Accessed</p>
<p className="font-medium">
{selectedApp.details.first_accessed
? new Date(
selectedApp.details.first_accessed
).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "numeric",
})
: "Never"}
</p>
</div>
<div>
<p className="text-xs text-zinc-400">Last Accessed</p>
<p className="font-medium">
{selectedApp.details.last_accessed
? new Date(
selectedApp.details.last_accessed
).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "numeric",
})
: "Never"}
</p>
</div>
<hr className="border-zinc-800" />
<div className="flex gap-2 justify-end">
<Button
onClick={handlePauseAccess}
className="flex bg-transparent w-[170px] bg-zinc-800 border-zinc-800 hover:bg-zinc-800 text-white"
size="sm"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : buttonText === "Pause Access" ? (
<PauseIcon className="h-4 w-4" />
) : (
<PlayIcon className="h-4 w-4" />
)}
{buttonText}
</Button>
</div>
</div>
</div>
</div>
);
};
export default AppDetailCard;
@@ -0,0 +1,115 @@
import { ArrowRight } from "lucide-react";
import Categories from "@/components/shared/categories";
import Link from "next/link";
import { constants } from "@/components/shared/source-app";
import Image from "next/image";
interface MemoryCardProps {
id: string;
content: string;
created_at: string;
metadata?: Record<string, any>;
categories?: string[];
access_count?: number;
app_name: string;
state: string;
}
export function MemoryCard({
id,
content,
created_at,
metadata,
categories,
access_count,
app_name,
state,
}: MemoryCardProps) {
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="p-4">
<div className="border-l-2 border-primary pl-4 mb-4">
<p
className={`${state !== "active" ? "text-zinc-400" : "text-white"}`}
>
{content}
</p>
</div>
{metadata && Object.keys(metadata).length > 0 && (
<div className="mb-4">
<p className="text-xs text-zinc-500 uppercase mb-2">METADATA</p>
<div className="bg-zinc-800 rounded p-3 text-zinc-400">
<pre className="whitespace-pre-wrap">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
</div>
)}
<div className="mb-2">
<Categories
categories={categories as any}
isPaused={state !== "active"}
/>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-zinc-400 text-sm">
{access_count ? (
<span className="relative top-1">
Accessed {access_count} times
</span>
) : (
new Date(created_at + "Z").toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})
)}
</span>
{state !== "active" && (
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
{state === "paused" ? "Paused" : "Archived"}
</span>
)}
</div>
{!app_name && (
<Link
href={`/memory/${id}`}
className="hover:cursor-pointer bg-zinc-800 hover:bg-zinc-700 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:text-white"
>
View Details
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
)}
{app_name && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
<span className="text-sm text-zinc-400">Created by:</span>
<div className="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={
constants[app_name as keyof typeof constants]
?.iconImage || ""
}
alt="OpenMemory"
width={24}
height={24}
/>
</div>
<p className="text-sm text-zinc-100 font-semibold">
{constants[app_name as keyof typeof constants]?.name}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useAppsApi } from "@/hooks/useAppsApi";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MemoryCard } from "./components/MemoryCard";
import AppDetailCard from "./components/AppDetailCard";
import "@/styles/animation.css";
import NotFound from "@/app/not-found";
import { AppDetailCardSkeleton } from "@/skeleton/AppDetailCardSkeleton";
import { MemoryCardSkeleton } from "@/skeleton/MemoryCardSkeleton";
export default function AppDetailsPage() {
const params = useParams();
const appId = params.appId as string;
const [activeTab, setActiveTab] = useState("created");
const {
fetchAppDetails,
fetchAppMemories,
fetchAppAccessedMemories,
fetchApps,
} = useAppsApi();
const selectedApp = useSelector((state: RootState) => state.apps.selectedApp);
useEffect(() => {
fetchApps({});
}, [fetchApps]);
useEffect(() => {
const loadData = async () => {
if (appId) {
try {
// Load all data in parallel
await Promise.all([
fetchAppDetails(appId),
fetchAppMemories(appId),
fetchAppAccessedMemories(appId),
]);
} catch (error) {
console.error("Error loading app data:", error);
}
}
};
loadData();
}, [appId, fetchAppDetails, fetchAppMemories, fetchAppAccessedMemories]);
if (selectedApp.error) {
return (
<NotFound message={selectedApp.error} title="Error loading app details" />
);
}
if (!selectedApp.details) {
return (
<div className="flex-1 py-6 text-white">
<div className="container flex justify-between">
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
<div className="mb-6">
<div className="h-10 w-64 bg-zinc-800 rounded animate-pulse mb-6" />
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<MemoryCardSkeleton key={i} />
))}
</div>
</div>
</div>
<div className="p-14 animate-fade-slide-down delay-2">
<AppDetailCardSkeleton />
</div>
</div>
</div>
);
}
const renderCreatedMemories = () => {
const memories = selectedApp.memories.created;
if (memories.loading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<MemoryCardSkeleton key={i} />
))}
</div>
);
}
if (memories.error) {
return (
<NotFound message={memories.error} title="Error loading memories" />
);
}
if (memories.items.length === 0) {
return (
<div className="text-zinc-400 text-center py-8">No memories found</div>
);
}
return memories.items.map((memory) => (
<MemoryCard
key={memory.id + memory.created_at}
id={memory.id}
content={memory.content}
created_at={memory.created_at}
metadata={memory.metadata_}
categories={memory.categories}
app_name={memory.app_name}
state={memory.state}
/>
));
};
const renderAccessedMemories = () => {
const memories = selectedApp.memories.accessed;
if (memories.loading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<MemoryCardSkeleton key={i} />
))}
</div>
);
}
if (memories.error) {
return (
<div className="text-red-400 bg-red-400/10 p-4 rounded-lg">
Error loading memories: {memories.error}
</div>
);
}
if (memories.items.length === 0) {
return (
<div className="text-zinc-400 text-center py-8">
No accessed memories found
</div>
);
}
return memories.items.map((accessedMemory) => (
<div
key={accessedMemory.memory.id + accessedMemory.memory.created_at}
className="relative"
>
<MemoryCard
id={accessedMemory.memory.id}
content={accessedMemory.memory.content}
created_at={accessedMemory.memory.created_at}
metadata={accessedMemory.memory.metadata_}
categories={accessedMemory.memory.categories}
access_count={accessedMemory.access_count}
app_name={accessedMemory.memory.app_name}
state={accessedMemory.memory.state}
/>
</div>
));
};
return (
<div className="flex-1 py-6 text-white">
<div className="container flex justify-between">
{/* Main content area */}
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
<Tabs
defaultValue="created"
className="mb-6"
onValueChange={setActiveTab}
>
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-8 p-0">
<TabsTrigger
value="created"
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
activeTab === "created" ? "text-white" : "text-zinc-400"
}`}
>
Created ({selectedApp.memories.created.total})
</TabsTrigger>
<TabsTrigger
value="accessed"
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
activeTab === "accessed" ? "text-white" : "text-zinc-400"
}`}
>
Accessed ({selectedApp.memories.accessed.total})
</TabsTrigger>
</TabsList>
<TabsContent
value="created"
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
>
{renderCreatedMemories()}
</TabsContent>
<TabsContent
value="accessed"
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
>
{renderAccessedMemories()}
</TabsContent>
</Tabs>
</div>
{/* Sidebar */}
<div className="p-14 animate-fade-slide-down delay-2">
<AppDetailCard appId={appId} selectedApp={selectedApp} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import type React from "react";
import { ArrowRight } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { constants } from "@/components/shared/source-app";
import { App } from "@/store/appsSlice";
import Image from "next/image";
import { useRouter } from "next/navigation";
interface AppCardProps {
app: App;
}
export function AppCard({ app }: AppCardProps) {
const router = useRouter();
const appConfig =
constants[app.name as keyof typeof constants] || constants.default;
const isActive = app.is_active;
return (
<Card className="bg-zinc-900 text-white border-zinc-800">
<CardHeader className="pb-2">
<div className="flex items-center gap-1">
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-6 h-6 flex items-center justify-center flex-shrink-0">
{appConfig.iconImage ? (
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={appConfig.iconImage}
alt={appConfig.name}
width={28}
height={28}
/>
</div>
) : (
<div className="w-6 h-6 flex items-center justify-center">
{appConfig.icon}
</div>
)}
</div>
<h2 className="text-xl font-semibold">{appConfig.name}</h2>
</div>
</CardHeader>
<CardContent className="pb-4 my-1">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-zinc-400 text-sm mb-1">Memories Created</p>
<p className="text-xl font-medium">
{app.total_memories_created.toLocaleString()} Memories
</p>
</div>
<div>
<p className="text-zinc-400 text-sm mb-1">Memories Accessed</p>
<p className="text-xl font-medium">
{app.total_memories_accessed.toLocaleString()} Memories
</p>
</div>
</div>
</CardContent>
<CardFooter className="border-t border-zinc-800 p-0 px-6 py-2 flex justify-between items-center">
<div
className={`${
isActive
? "bg-green-800 text-white hover:bg-green-500/20"
: "bg-red-500/20 text-red-400 hover:bg-red-500/20"
} rounded-lg px-2 py-0.5 flex items-center text-sm`}
>
<span className="h-2 w-2 my-auto mr-1 rounded-full inline-block bg-current"></span>
{isActive ? "Active" : "Inactive"}
</div>
<div
onClick={() => router.push(`/apps/${app.id}`)}
className="border hover:cursor-pointer border-zinc-700 bg-zinc-950 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:bg-zinc-950/50 hover:text-white"
>
View Details <ArrowRight className="ml-2 h-4 w-4" />
</div>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import { Search, ChevronDown, SortAsc, SortDesc } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import {
setSearchQuery,
setActiveFilter,
setSortBy,
setSortDirection,
} from "@/store/appsSlice";
import { RootState } from "@/store/store";
import { useCallback } from "react";
import debounce from "lodash/debounce";
import { useAppsApi } from "@/hooks/useAppsApi";
import { AppFiltersSkeleton } from "@/skeleton/AppFiltersSkeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const sortOptions = [
{ value: "name", label: "Name" },
{ value: "memories", label: "Memories Created" },
{ value: "memories_accessed", label: "Memories Accessed" },
];
export function AppFilters() {
const dispatch = useDispatch();
const filters = useSelector((state: RootState) => state.apps.filters);
const [localSearch, setLocalSearch] = useState(filters.searchQuery);
const { isLoading } = useAppsApi();
const debouncedSearch = useCallback(
debounce((query: string) => {
dispatch(setSearchQuery(query));
}, 300),
[dispatch]
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setLocalSearch(query);
debouncedSearch(query);
};
const handleActiveFilterChange = (value: string) => {
dispatch(setActiveFilter(value === "all" ? "all" : value === "true"));
};
const setSorting = (sortBy: "name" | "memories" | "memories_accessed") => {
const newDirection =
filters.sortBy === sortBy && filters.sortDirection === "asc"
? "desc"
: "asc";
dispatch(setSortBy(sortBy));
dispatch(setSortDirection(newDirection));
};
useEffect(() => {
setLocalSearch(filters.searchQuery);
}, [filters.searchQuery]);
if (isLoading) {
return <AppFiltersSkeleton />;
}
return (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<Input
placeholder="Search Apps..."
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
value={localSearch}
onChange={handleSearchChange}
/>
</div>
<Select
value={String(filters.isActive)}
onValueChange={handleActiveFilterChange}
>
<SelectTrigger className="w-[130px] border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="true">Active</SelectItem>
<SelectItem value="false">Inactive</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-9 px-4 border-zinc-700 bg-zinc-900 hover:bg-zinc-800"
>
{filters.sortDirection === "asc" ? (
<SortDesc className="h-4 w-4 mr-2" />
) : (
<SortAsc className="h-4 w-4 mr-2" />
)}
Sort: {sortOptions.find((o) => o.value === filters.sortBy)?.label}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-zinc-800" />
<DropdownMenuGroup>
{sortOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() =>
setSorting(
option.value as "name" | "memories" | "memories_accessed"
)
}
className="cursor-pointer flex justify-between items-center"
>
{option.label}
{filters.sortBy === option.value &&
(filters.sortDirection === "asc" ? (
<SortAsc className="h-4 w-4 text-primary" />
) : (
<SortDesc className="h-4 w-4 text-primary" />
))}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useAppsApi } from "@/hooks/useAppsApi";
import { AppCard } from "./AppCard";
import { AppCardSkeleton } from "@/skeleton/AppCardSkeleton";
export function AppGrid() {
const { fetchApps, isLoading } = useAppsApi();
const apps = useSelector((state: RootState) => state.apps.apps);
const filters = useSelector((state: RootState) => state.apps.filters);
useEffect(() => {
fetchApps({
name: filters.searchQuery,
is_active: filters.isActive === "all" ? undefined : filters.isActive,
sort_by: filters.sortBy,
sort_direction: filters.sortDirection,
});
}, [fetchApps, filters]);
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<AppCardSkeleton key={i} />
))}
</div>
);
}
if (apps.length === 0) {
return (
<div className="text-center text-zinc-500 py-8">
No apps found matching your filters
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { AppFilters } from "./components/AppFilters";
import { AppGrid } from "./components/AppGrid";
import "@/styles/animation.css";
export default function AppsPage() {
return (
<main className="flex-1 py-6">
<div className="container">
<div className="mt-1 pb-4 animate-fade-slide-down">
<AppFilters />
</div>
<div className="animate-fade-slide-down delay-1">
<AppGrid />
</div>
</div>
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 260 94% 59%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 260 94% 59%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 260 94% 59%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 260 94% 59%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type React from "react";
import "@/app/globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Navbar } from "@/components/Navbar";
import { Toaster } from "@/components/ui/toaster";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Providers } from "./providers";
export const metadata = {
title: "OpenMemory - Developer Dashboard",
description: "Manage your OpenMemory integration and stored memories",
generator: "v0.dev",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="h-screen font-sans antialiased flex flex-col bg-zinc-950">
<Providers>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<Navbar />
<ScrollArea className="h-[calc(100vh-64px)]">{children}</ScrollArea>
<Toaster />
</ThemeProvider>
</Providers>
</body>
</html>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function Loading() {
return null;
}
@@ -0,0 +1,88 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useState, useRef } from "react";
import { GoPlus } from "react-icons/go";
import { Loader2 } from "lucide-react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
export function CreateMemoryDialog() {
const { createMemory, isLoading, fetchMemories } = useMemoriesApi();
const [open, setOpen] = useState(false);
const textRef = useRef<HTMLTextAreaElement>(null);
const handleCreateMemory = async (text: string) => {
try {
await createMemory(text);
toast.success("Memory created successfully");
// close the dialog
setOpen(false);
// refetch memories
await fetchMemories();
} catch (error) {
console.error(error);
toast.error("Failed to create memory");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="bg-primary hover:bg-primary/90 text-white"
>
<GoPlus />
Create Memory
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[525px] bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle>Create New Memory</DialogTitle>
<DialogDescription>
Add a new memory to your OpenMemory instance
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="memory">Memory</Label>
<Textarea
ref={textRef}
id="memory"
placeholder="e.g., Lives in San Francisco"
className="bg-zinc-950 border-zinc-800 min-h-[150px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={isLoading}
onClick={() => handleCreateMemory(textRef?.current?.value || "")}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
"Save Memory"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,411 @@
"use client";
import { useEffect, useState } from "react";
import { Filter, X, ChevronDown, SortAsc, SortDesc } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
} from "@/components/ui/dropdown-menu";
import { RootState } from "@/store/store";
import { useAppsApi } from "@/hooks/useAppsApi";
import { useFiltersApi } from "@/hooks/useFiltersApi";
import {
setSelectedApps,
setSelectedCategories,
clearFilters,
} from "@/store/filtersSlice";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
const columns = [
{
label: "Memory",
value: "memory",
},
{
label: "App Name",
value: "app_name",
},
{
label: "Created On",
value: "created_at",
},
];
export default function FilterComponent() {
const dispatch = useDispatch();
const { fetchApps } = useAppsApi();
const { fetchCategories, updateSort } = useFiltersApi();
const { fetchMemories } = useMemoriesApi();
const [isOpen, setIsOpen] = useState(false);
const [tempSelectedApps, setTempSelectedApps] = useState<string[]>([]);
const [tempSelectedCategories, setTempSelectedCategories] = useState<
string[]
>([]);
const [showArchived, setShowArchived] = useState(false);
const apps = useSelector((state: RootState) => state.apps.apps);
const categories = useSelector(
(state: RootState) => state.filters.categories.items
);
const filters = useSelector((state: RootState) => state.filters.apps);
useEffect(() => {
fetchApps();
fetchCategories();
}, [fetchApps, fetchCategories]);
useEffect(() => {
// Initialize temporary selections with current active filters when dialog opens
if (isOpen) {
setTempSelectedApps(filters.selectedApps);
setTempSelectedCategories(filters.selectedCategories);
setShowArchived(filters.showArchived || false);
}
}, [isOpen, filters]);
useEffect(() => {
handleClearFilters();
}, []);
const toggleAppFilter = (app: string) => {
setTempSelectedApps((prev) =>
prev.includes(app) ? prev.filter((a) => a !== app) : [...prev, app]
);
};
const toggleCategoryFilter = (category: string) => {
setTempSelectedCategories((prev) =>
prev.includes(category)
? prev.filter((c) => c !== category)
: [...prev, category]
);
};
const toggleAllApps = (checked: boolean) => {
setTempSelectedApps(checked ? apps.map((app) => app.id) : []);
};
const toggleAllCategories = (checked: boolean) => {
setTempSelectedCategories(checked ? categories.map((cat) => cat.name) : []);
};
const handleClearFilters = async () => {
setTempSelectedApps([]);
setTempSelectedCategories([]);
setShowArchived(false);
dispatch(clearFilters());
await fetchMemories();
};
const handleApplyFilters = async () => {
try {
// Get category IDs for selected category names
const selectedCategoryIds = categories
.filter((cat) => tempSelectedCategories.includes(cat.name))
.map((cat) => cat.id);
// Get app IDs for selected app names
const selectedAppIds = apps
.filter((app) => tempSelectedApps.includes(app.id))
.map((app) => app.id);
// Update the global state with temporary selections
dispatch(setSelectedApps(tempSelectedApps));
dispatch(setSelectedCategories(tempSelectedCategories));
dispatch({ type: "filters/setShowArchived", payload: showArchived });
await fetchMemories(undefined, 1, 10, {
apps: selectedAppIds,
categories: selectedCategoryIds,
sortColumn: filters.sortColumn,
sortDirection: filters.sortDirection,
showArchived: showArchived,
});
setIsOpen(false);
} catch (error) {
console.error("Failed to apply filters:", error);
}
};
const handleDialogChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
// Reset temporary selections to active filters when dialog closes without applying
setTempSelectedApps(filters.selectedApps);
setTempSelectedCategories(filters.selectedCategories);
setShowArchived(filters.showArchived || false);
}
};
const setSorting = async (column: string) => {
const newDirection =
filters.sortColumn === column && filters.sortDirection === "asc"
? "desc"
: "asc";
updateSort(column, newDirection);
// Get category IDs for selected category names
const selectedCategoryIds = categories
.filter((cat) => tempSelectedCategories.includes(cat.name))
.map((cat) => cat.id);
// Get app IDs for selected app names
const selectedAppIds = apps
.filter((app) => tempSelectedApps.includes(app.id))
.map((app) => app.id);
try {
await fetchMemories(undefined, 1, 10, {
apps: selectedAppIds,
categories: selectedCategoryIds,
sortColumn: column,
sortDirection: newDirection,
});
} catch (error) {
console.error("Failed to apply sorting:", error);
}
};
const hasActiveFilters =
filters.selectedApps.length > 0 ||
filters.selectedCategories.length > 0 ||
filters.showArchived;
const hasTempFilters =
tempSelectedApps.length > 0 ||
tempSelectedCategories.length > 0 ||
showArchived;
return (
<div className="flex items-center gap-2">
<Dialog open={isOpen} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>
<Button
variant="outline"
className={`h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800 ${
hasActiveFilters ? "border-primary" : ""
}`}
>
<Filter
className={`h-4 w-4 ${hasActiveFilters ? "text-primary" : ""}`}
/>
Filter
{hasActiveFilters && (
<Badge className="ml-2 bg-primary hover:bg-primary/80 text-xs">
{filters.selectedApps.length +
filters.selectedCategories.length +
(filters.showArchived ? 1 : 0)}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-zinc-900 border-zinc-800 text-zinc-100">
<DialogHeader>
<DialogTitle className="text-zinc-100 flex justify-between items-center">
<span>Filters</span>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="apps" className="w-full">
<TabsList className="grid grid-cols-3 bg-zinc-800">
<TabsTrigger
value="apps"
className="data-[state=active]:bg-zinc-700"
>
Apps
</TabsTrigger>
<TabsTrigger
value="categories"
className="data-[state=active]:bg-zinc-700"
>
Categories
</TabsTrigger>
<TabsTrigger
value="archived"
className="data-[state=active]:bg-zinc-700"
>
Archived
</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="mt-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all-apps"
checked={
apps.length > 0 && tempSelectedApps.length === apps.length
}
onCheckedChange={(checked) =>
toggleAllApps(checked as boolean)
}
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor="select-all-apps"
className="text-sm font-normal text-zinc-300 cursor-pointer"
>
Select All
</Label>
</div>
{apps.map((app) => (
<div key={app.id} className="flex items-center space-x-2">
<Checkbox
id={`app-${app.id}`}
checked={tempSelectedApps.includes(app.id)}
onCheckedChange={() => toggleAppFilter(app.id)}
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor={`app-${app.id}`}
className="text-sm font-normal text-zinc-300 cursor-pointer"
>
{app.name}
</Label>
</div>
))}
</div>
</TabsContent>
<TabsContent value="categories" className="mt-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all-categories"
checked={
categories.length > 0 &&
tempSelectedCategories.length === categories.length
}
onCheckedChange={(checked) =>
toggleAllCategories(checked as boolean)
}
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor="select-all-categories"
className="text-sm font-normal text-zinc-300 cursor-pointer"
>
Select All
</Label>
</div>
{categories.map((category) => (
<div
key={category.name}
className="flex items-center space-x-2"
>
<Checkbox
id={`category-${category.name}`}
checked={tempSelectedCategories.includes(category.name)}
onCheckedChange={() =>
toggleCategoryFilter(category.name)
}
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor={`category-${category.name}`}
className="text-sm font-normal text-zinc-300 cursor-pointer"
>
{category.name}
</Label>
</div>
))}
</div>
</TabsContent>
<TabsContent value="archived" className="mt-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) =>
setShowArchived(checked as boolean)
}
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor="show-archived"
className="text-sm font-normal text-zinc-300 cursor-pointer"
>
Show Archived Memories
</Label>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end mt-4 gap-3">
{/* Clear all button */}
{hasTempFilters && (
<Button
onClick={handleClearFilters}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
>
Clear All
</Button>
)}
{/* Apply filters button */}
<Button
onClick={handleApplyFilters}
className="bg-primary hover:bg-primary/80 text-white"
>
Apply Filters
</Button>
</div>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
>
{filters.sortDirection === "asc" ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
Sort: {columns.find((c) => c.value === filters.sortColumn)?.label}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-zinc-800" />
<DropdownMenuGroup>
{columns.map((column) => (
<DropdownMenuItem
key={column.value}
onClick={() => setSorting(column.value)}
className="cursor-pointer flex justify-between items-center"
>
{column.label}
{filters.sortColumn === column.value &&
(filters.sortDirection === "asc" ? (
<SortAsc className="h-4 w-4 text-primary" />
) : (
<SortDesc className="h-4 w-4 text-primary" />
))}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,145 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Category, Client } from "../../../components/types";
import { MemoryTable } from "./MemoryTable";
import { MemoryPagination } from "./MemoryPagination";
import { CreateMemoryDialog } from "./CreateMemoryDialog";
import { PageSizeSelector } from "./PageSizeSelector";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { useRouter, useSearchParams } from "next/navigation";
import { MemoryTableSkeleton } from "@/skeleton/MemoryTableSkeleton";
export function MemoriesSection() {
const router = useRouter();
const searchParams = useSearchParams();
const { fetchMemories } = useMemoriesApi();
const [memories, setMemories] = useState<any[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const currentPage = Number(searchParams.get("page")) || 1;
const itemsPerPage = Number(searchParams.get("size")) || 10;
const [selectedCategory, setSelectedCategory] = useState<Category | "all">(
"all"
);
const [selectedClient, setSelectedClient] = useState<Client | "all">("all");
useEffect(() => {
const loadMemories = async () => {
setIsLoading(true);
try {
const searchQuery = searchParams.get("search") || "";
const result = await fetchMemories(
searchQuery,
currentPage,
itemsPerPage
);
setMemories(result.memories);
setTotalItems(result.total);
setTotalPages(result.pages);
} catch (error) {
console.error("Failed to fetch memories:", error);
}
setIsLoading(false);
};
loadMemories();
}, [currentPage, itemsPerPage, fetchMemories, searchParams]);
const setCurrentPage = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
params.set("size", itemsPerPage.toString());
router.push(`?${params.toString()}`);
};
const handlePageSizeChange = (size: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", "1"); // Reset to page 1 when changing page size
params.set("size", size.toString());
router.push(`?${params.toString()}`);
};
if (isLoading) {
return (
<div className="w-full bg-transparent">
<MemoryTableSkeleton />
<div className="flex items-center justify-between mt-4">
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
<div className="h-8 w-48 bg-zinc-800 rounded animate-pulse" />
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
</div>
</div>
);
}
return (
<div className="w-full bg-transparent">
<div>
{memories.length > 0 ? (
<>
<MemoryTable />
<div className="flex items-center justify-between mt-4">
<PageSizeSelector
pageSize={itemsPerPage}
onPageSizeChange={handlePageSizeChange}
/>
<div className="text-sm text-zinc-500 mr-2">
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
{Math.min(currentPage * itemsPerPage, totalItems)} of{" "}
{totalItems} memories
</div>
<MemoryPagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
/>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-zinc-800 p-3 mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-6 w-6 text-zinc-400"
>
<path d="M21 9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"></path>
<path d="M16 2v6h6"></path>
<path d="M12 18v-6"></path>
<path d="M9 15h6"></path>
</svg>
</div>
<h3 className="text-lg font-medium">No memories found</h3>
<p className="text-zinc-400 mt-1 mb-4">
{selectedCategory !== "all" || selectedClient !== "all"
? "Try adjusting your filters"
: "Create your first memory to see it here"}
</p>
{selectedCategory !== "all" || selectedClient !== "all" ? (
<Button
variant="outline"
onClick={() => {
setSelectedCategory("all");
setSelectedClient("all");
}}
>
Clear Filters
</Button>
) : (
<CreateMemoryDialog />
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,154 @@
"use client";
import { Archive, Pause, Play, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FiTrash2 } from "react-icons/fi";
import { useSelector, useDispatch } from "react-redux";
import { RootState } from "@/store/store";
import { clearSelection } from "@/store/memoriesSlice";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useRouter, useSearchParams } from "next/navigation";
import { debounce } from "lodash";
import { useEffect, useRef } from "react";
import FilterComponent from "./FilterComponent";
import { clearFilters } from "@/store/filtersSlice";
export function MemoryFilters() {
const dispatch = useDispatch();
const selectedMemoryIds = useSelector(
(state: RootState) => state.memories.selectedMemoryIds
);
const { deleteMemories, updateMemoryState, fetchMemories } = useMemoriesApi();
const router = useRouter();
const searchParams = useSearchParams();
const activeFilters = useSelector((state: RootState) => state.filters.apps);
const inputRef = useRef<HTMLInputElement>(null);
const handleDeleteSelected = async () => {
try {
await deleteMemories(selectedMemoryIds);
dispatch(clearSelection());
} catch (error) {
console.error("Failed to delete memories:", error);
}
};
const handleArchiveSelected = async () => {
try {
await updateMemoryState(selectedMemoryIds, "archived");
} catch (error) {
console.error("Failed to archive memories:", error);
}
};
const handlePauseSelected = async () => {
try {
await updateMemoryState(selectedMemoryIds, "paused");
} catch (error) {
console.error("Failed to pause memories:", error);
}
};
const handleResumeSelected = async () => {
try {
await updateMemoryState(selectedMemoryIds, "active");
} catch (error) {
console.error("Failed to resume memories:", error);
}
};
// add debounce
const handleSearch = debounce(async (query: string) => {
router.push(`/memories?search=${query}`);
}, 500);
useEffect(() => {
// if the url has a search param, set the input value to the search param
if (searchParams.get("search")) {
if (inputRef.current) {
inputRef.current.value = searchParams.get("search") || "";
inputRef.current.focus();
}
}
}, []);
const handleClearAllFilters = async () => {
dispatch(clearFilters());
await fetchMemories(); // Fetch memories without any filters
};
const hasActiveFilters =
activeFilters.selectedApps.length > 0 ||
activeFilters.selectedCategories.length > 0;
return (
<div className="flex flex-col md:flex-row gap-4 mb-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<Input
ref={inputRef}
placeholder="Search memories..."
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
<FilterComponent />
{hasActiveFilters && (
<Button
variant="outline"
className="bg-zinc-900 text-zinc-300 hover:bg-zinc-800"
onClick={handleClearAllFilters}
>
Clear Filters
</Button>
)}
{selectedMemoryIds.length > 0 && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-800"
>
<DropdownMenuItem onClick={handleArchiveSelected}>
<Archive className="mr-2 h-4 w-4" />
Archive Selected
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePauseSelected}>
<Pause className="mr-2 h-4 w-4" />
Pause Selected
</DropdownMenuItem>
<DropdownMenuItem onClick={handleResumeSelected}>
<Play className="mr-2 h-4 w-4" />
Resume Selected
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDeleteSelected}
className="text-red-500"
>
<FiTrash2 className="mr-2 h-4 w-4" />
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface MemoryPaginationProps {
currentPage: number;
totalPages: number;
setCurrentPage: (page: number) => void;
}
export function MemoryPagination({
currentPage,
totalPages,
setCurrentPage,
}: MemoryPaginationProps) {
return (
<div className="flex items-center justify-between my-auto">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
Page {currentPage} of {totalPages}
</div>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(Math.min(currentPage + 1, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,302 @@
import {
Edit,
MoreHorizontal,
Trash2,
Pause,
Archive,
Play,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
selectMemory,
deselectMemory,
selectAllMemories,
clearSelection,
} from "@/store/memoriesSlice";
import SourceApp from "@/components/shared/source-app";
import { HiMiniRectangleStack } from "react-icons/hi2";
import { PiSwatches } from "react-icons/pi";
import { GoPackage } from "react-icons/go";
import { CiCalendar } from "react-icons/ci";
import { useRouter } from "next/navigation";
import Categories from "@/components/shared/categories";
import { useUI } from "@/hooks/useUI";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate } from "@/lib/helpers";
export function MemoryTable() {
const { toast } = useToast();
const router = useRouter();
const dispatch = useDispatch();
const selectedMemoryIds = useSelector(
(state: RootState) => state.memories.selectedMemoryIds
);
const memories = useSelector((state: RootState) => state.memories.memories);
const { deleteMemories, updateMemoryState, isLoading } = useMemoriesApi();
const handleDeleteMemory = (id: string) => {
deleteMemories([id]);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
dispatch(selectAllMemories());
} else {
dispatch(clearSelection());
}
};
const handleSelectMemory = (id: string, checked: boolean) => {
if (checked) {
dispatch(selectMemory(id));
} else {
dispatch(deselectMemory(id));
}
};
const { handleOpenUpdateMemoryDialog } = useUI();
const handleEditMemory = (memory_id: string, memory_content: string) => {
handleOpenUpdateMemoryDialog(memory_id, memory_content);
};
const handleUpdateMemoryState = async (id: string, newState: string) => {
try {
await updateMemoryState([id], newState);
} catch (error) {
toast({
title: "Error",
description: "Failed to update memory state",
variant: "destructive",
});
}
};
const isAllSelected =
memories.length > 0 && selectedMemoryIds.length === memories.length;
const isPartiallySelected =
selectedMemoryIds.length > 0 && selectedMemoryIds.length < memories.length;
const handleMemoryClick = (id: string) => {
router.push(`/memory/${id}`);
};
return (
<div className="rounded-md border">
<Table className="">
<TableHeader>
<TableRow className="bg-zinc-800 hover:bg-zinc-800">
<TableHead className="w-[50px] pl-4">
<Checkbox
className="data-[state=checked]:border-primary border-zinc-500/50"
checked={isAllSelected}
data-state={
isPartiallySelected
? "indeterminate"
: isAllSelected
? "checked"
: "unchecked"
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="border-zinc-700">
<div className="flex items-center min-w-[600px]">
<HiMiniRectangleStack className="mr-1" />
Memory
</div>
</TableHead>
<TableHead className="border-zinc-700">
<div className="flex items-center">
<PiSwatches className="mr-1" size={15} />
Categories
</div>
</TableHead>
<TableHead className="w-[140px] border-zinc-700">
<div className="flex items-center">
<GoPackage className="mr-1" />
Source App
</div>
</TableHead>
<TableHead className="w-[140px] border-zinc-700">
<div className="flex items-center w-full justify-center">
<CiCalendar className="mr-1" size={16} />
Created On
</div>
</TableHead>
<TableHead className="text-right border-zinc-700 flex justify-center">
<div className="flex items-center justify-end">
<MoreHorizontal className="h-4 w-4 mr-2" />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{memories.map((memory) => (
<TableRow
key={memory.id}
className={`hover:bg-zinc-900/50 ${
memory.state === "paused" || memory.state === "archived"
? "text-zinc-400"
: ""
} ${isLoading ? "animate-pulse opacity-50" : ""}`}
>
<TableCell className="pl-4">
<Checkbox
className="data-[state=checked]:border-primary border-zinc-500/50"
checked={selectedMemoryIds.includes(memory.id)}
onCheckedChange={(checked) =>
handleSelectMemory(memory.id, checked as boolean)
}
/>
</TableCell>
<TableCell className="">
{memory.state === "paused" || memory.state === "archived" ? (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<div
onClick={() => handleMemoryClick(memory.id)}
className={`font-medium ${
memory.state === "paused" ||
memory.state === "archived"
? "text-zinc-400"
: "text-white"
} cursor-pointer`}
>
{memory.memory}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
This memory is{" "}
<span className="font-bold">
{memory.state === "paused" ? "paused" : "archived"}
</span>{" "}
and <span className="font-bold">disabled</span>.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div
onClick={() => handleMemoryClick(memory.id)}
className={`font-medium text-white cursor-pointer`}
>
{memory.memory}
</div>
)}
</TableCell>
<TableCell className="">
<div className="flex flex-wrap gap-1">
<Categories
categories={memory.categories}
isPaused={
memory.state === "paused" || memory.state === "archived"
}
concat={true}
/>
</div>
</TableCell>
<TableCell className="w-[140px] text-center">
<SourceApp source={memory.app_name} />
</TableCell>
<TableCell className="w-[140px] text-center">
{formatDate(memory.created_at)}
</TableCell>
<TableCell className="text-right flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-800"
>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
const newState =
memory.state === "active" ? "paused" : "active";
handleUpdateMemoryState(memory.id, newState);
}}
>
{memory?.state === "active" ? (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Resume
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
const newState =
memory.state === "active" ? "archived" : "active";
handleUpdateMemoryState(memory.id, newState);
}}
>
<Archive className="mr-2 h-4 w-4" />
{memory?.state !== "archived" ? (
<>Archive</>
) : (
<>Unarchive</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => handleEditMemory(memory.id, memory.memory)}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-500"
onClick={() => handleDeleteMemory(memory.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,43 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface PageSizeSelectorProps {
pageSize: number;
onPageSizeChange: (size: number) => void;
}
export function PageSizeSelector({
pageSize,
onPageSizeChange,
}: PageSizeSelectorProps) {
const pageSizeOptions = [10, 20, 50, 100];
return (
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-500">Show</span>
<Select
value={pageSize.toString()}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="w-[70px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-zinc-500">items</span>
</div>
);
}
export default PageSizeSelector;
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
import { useRouter, useSearchParams } from "next/navigation";
import "@/styles/animation.css";
import UpdateMemory from "@/components/shared/update-memory";
import { useUI } from "@/hooks/useUI";
export default function MemoriesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
useEffect(() => {
// Set default pagination values if not present in URL
if (!searchParams.has("page") || !searchParams.has("size")) {
const params = new URLSearchParams(searchParams.toString());
if (!searchParams.has("page")) params.set("page", "1");
if (!searchParams.has("size")) params.set("size", "10");
router.push(`?${params.toString()}`);
}
}, []);
return (
<div className="">
<UpdateMemory
memoryId={updateMemoryDialog.memoryId || ""}
memoryContent={updateMemoryDialog.memoryContent || ""}
open={updateMemoryDialog.isOpen}
onOpenChange={handleCloseUpdateMemoryDialog}
/>
<main className="flex-1 py-6">
<div className="container">
<div className="mt-1 pb-4 animate-fade-slide-down">
<MemoryFilters />
</div>
<div className="animate-fade-slide-down delay-1">
<MemoriesSection />
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,116 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { constants } from "@/components/shared/source-app";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AccessLogEntry {
id: string;
app_name: string;
accessed_at: string;
}
interface AccessLogProps {
memoryId: string;
}
export function AccessLog({ memoryId }: AccessLogProps) {
const { fetchAccessLogs } = useMemoriesApi();
const accessEntries = useSelector(
(state: RootState) => state.memories.accessLogs
);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAccessLogs = async () => {
try {
await fetchAccessLogs(memoryId);
} catch (error) {
console.error("Failed to fetch access logs:", error);
} finally {
setIsLoading(false);
}
};
loadAccessLogs();
}, []);
if (isLoading) {
return (
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden bg-[#1c1c1c] text-white p-6">
<p className="text-center text-zinc-500">Loading access logs...</p>
</div>
);
}
return (
<div className="w-full max-w-md mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white pb-1">
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
<h2 className="font-semibold">Access Log</h2>
{/* <button className="px-3 py-1 text-sm rounded-lg border border-[#ff5533] text-[#ff5533] flex items-center gap-2 hover:bg-[#ff5533]/10 transition-colors">
<PauseIcon size={18} />
<span>Pause Access</span>
</button> */}
</div>
<ScrollArea className="p-6 max-h-[450px]">
{accessEntries.length === 0 && (
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden min-h-[110px] flex items-center justify-center text-white p-6">
<p className="text-center text-zinc-500">
No access logs available
</p>
</div>
)}
<ul className="space-y-8">
{accessEntries.map((entry: AccessLogEntry, index: number) => {
const appConfig =
constants[entry.app_name as keyof typeof constants] ||
constants.default;
return (
<li key={entry.id} className="relative flex items-start gap-4">
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-8 h-8 flex items-center justify-center flex-shrink-0">
{appConfig.iconImage ? (
<Image
src={appConfig.iconImage}
alt={`${appConfig.name} icon`}
width={30}
height={30}
className="w-8 h-8 object-contain"
/>
) : (
<div className="w-8 h-8 flex items-center justify-center">
{appConfig.icon}
</div>
)}
</div>
{index < accessEntries.length - 1 && (
<div className="absolute left-4 top-6 bottom-0 w-[1px] h-[calc(100%+1rem)] bg-[#333333] transform -translate-x-1/2"></div>
)}
<div className="flex flex-col">
<span className="font-medium">{appConfig.name}</span>
<span className="text-zinc-400 text-sm">
{new Date(entry.accessed_at + "Z").toLocaleDateString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
}
)}
</span>
</div>
</li>
);
})}
</ul>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,114 @@
import { Button } from "@/components/ui/button";
import { Pencil, Archive, Trash, Pause, Play, ChevronDown } from "lucide-react";
import { useUI } from "@/hooks/useUI";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
interface MemoryActionsProps {
memoryId: string;
memoryContent: string;
memoryState: string;
}
export function MemoryActions({
memoryId,
memoryContent,
memoryState,
}: MemoryActionsProps) {
const { handleOpenUpdateMemoryDialog } = useUI();
const { updateMemoryState, isLoading } = useMemoriesApi();
const handleEdit = () => {
handleOpenUpdateMemoryDialog(memoryId, memoryContent);
};
const handleStateChange = (newState: string) => {
updateMemoryState([memoryId], newState);
};
const getStateLabel = () => {
switch (memoryState) {
case "archived":
return "Archived";
case "paused":
return "Paused";
default:
return "Active";
}
};
const getStateIcon = () => {
switch (memoryState) {
case "archived":
return <Archive className="h-3 w-3 mr-2" />;
case "paused":
return <Pause className="h-3 w-3 mr-2" />;
default:
return <Play className="h-3 w-3 mr-2" />;
}
};
return (
<div className="flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={isLoading}
variant="outline"
size="sm"
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
>
<span className="font-semibold">{getStateLabel()}</span>
<ChevronDown className="h-3 w-3 mt-1 -ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40 bg-zinc-900 border-zinc-800 text-zinc-100">
<DropdownMenuLabel>Change State</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-zinc-800" />
<DropdownMenuItem
onClick={() => handleStateChange("active")}
className="cursor-pointer flex items-center"
disabled={memoryState === "active"}
>
<Play className="h-3 w-3 mr-2" />
<span className="font-semibold">Active</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStateChange("paused")}
className="cursor-pointer flex items-center"
disabled={memoryState === "paused"}
>
<Pause className="h-3 w-3 mr-2" />
<span className="font-semibold">Pause</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStateChange("archived")}
className="cursor-pointer flex items-center"
disabled={memoryState === "archived"}
>
<Archive className="h-3 w-3 mr-2" />
<span className="font-semibold">Archive</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
disabled={isLoading}
variant="outline"
size="sm"
onClick={handleEdit}
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
>
<Pencil className="h-3 w-3 -mr-1" />
<span className="font-semibold">Edit</span>
</Button>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { MemoryActions } from "./MemoryActions";
import { ArrowLeft, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { AccessLog } from "./AccessLog";
import Image from "next/image";
import Categories from "@/components/shared/categories";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { constants } from "@/components/shared/source-app";
import { RelatedMemories } from "./RelatedMemories";
interface MemoryDetailsProps {
memory_id: string;
}
export function MemoryDetails({ memory_id }: MemoryDetailsProps) {
const router = useRouter();
const { fetchMemoryById, hasUpdates } = useMemoriesApi();
const memory = useSelector(
(state: RootState) => state.memories.selectedMemory
);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (memory?.id) {
await navigator.clipboard.writeText(memory.id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
useEffect(() => {
fetchMemoryById(memory_id);
}, []);
return (
<div className="container mx-auto py-6 px-4">
<Button
variant="ghost"
className="mb-4 text-zinc-400 hover:text-white"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Memories
</Button>
<div className="flex gap-4 w-full">
<div className="rounded-lg w-2/3 border h-fit pb-2 border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="">
<div className="flex px-6 py-3 justify-between items-center mb-6 bg-zinc-800 border-b border-zinc-800">
<div className="flex items-center gap-2">
<h1 className="font-semibold text-white">
Memory{" "}
<span className="ml-1 text-zinc-400 text-sm font-normal">
#{memory?.id?.slice(0, 6)}
</span>
</h1>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 text-zinc-400 hover:text-white -ml-[5px] mt-1"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<MemoryActions
memoryId={memory?.id || ""}
memoryContent={memory?.text || ""}
memoryState={memory?.state || ""}
/>
</div>
<div className="px-6 py-2">
<div className="border-l-2 border-primary pl-4 mb-6">
<p
className={`${
memory?.state === "archived" || memory?.state === "paused"
? "text-zinc-400"
: "text-white"
}`}
>
{memory?.text}
</p>
</div>
<div className="mt-6 pt-4 border-t border-zinc-800">
<div className="flex justify-between items-center">
<div className="">
<Categories
categories={memory?.categories || []}
isPaused={
memory?.state === "archived" ||
memory?.state === "paused"
}
/>
</div>
<div className="flex items-center gap-2 min-w-[300px] justify-end">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
<span className="text-sm text-zinc-400">
Created by:
</span>
<div className="w-4 h-4 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={
constants[
memory?.app_name as keyof typeof constants
]?.iconImage || ""
}
alt="OpenMemory"
width={24}
height={24}
/>
</div>
<p className="text-sm text-zinc-100 font-semibold">
{
constants[
memory?.app_name as keyof typeof constants
]?.name
}
</p>
</div>
</div>
</div>
</div>
{/* <div className="flex justify-end gap-2 w-full mt-2">
<p className="text-sm font-semibold text-primary my-auto">
{new Date(memory.created_at).toLocaleString()}
</p>
</div> */}
</div>
</div>
</div>
</div>
<div className="w-1/3 flex flex-col gap-4">
<AccessLog memoryId={memory?.id || ""} />
<RelatedMemories memoryId={memory?.id || ""} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { Memory } from "@/components/types";
import Categories from "@/components/shared/categories";
import Link from "next/link";
import { formatDate } from "@/lib/helpers";
interface RelatedMemoriesProps {
memoryId: string;
}
export function RelatedMemories({ memoryId }: RelatedMemoriesProps) {
const { fetchRelatedMemories } = useMemoriesApi();
const relatedMemories = useSelector(
(state: RootState) => state.memories.relatedMemories
);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadRelatedMemories = async () => {
try {
await fetchRelatedMemories(memoryId);
} catch (error) {
console.error("Failed to fetch related memories:", error);
} finally {
setIsLoading(false);
}
};
loadRelatedMemories();
}, []);
if (isLoading) {
return (
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
<p className="text-center text-zinc-500">Loading related memories...</p>
</div>
);
}
if (!relatedMemories.length) {
return (
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
<p className="text-center text-zinc-500">No related memories found</p>
</div>
);
}
return (
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white">
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
<h2 className="font-semibold">Related Memories</h2>
</div>
<div className="space-y-6 p-6">
{relatedMemories.map((memory: Memory) => (
<div
key={memory.id}
className="border-l-2 border-zinc-800 pl-6 py-1 hover:bg-zinc-700/10 transition-colors cursor-pointer"
>
<Link href={`/memory/${memory.id}`}>
<h3 className="font-medium mb-3">{memory.memory}</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Categories
categories={memory.categories}
isPaused={
memory.state === "paused" || memory.state === "archived"
}
concat={true}
/>
{memory.state !== "active" && (
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
{memory.state === "paused" ? "Paused" : "Archived"}
</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="text-zinc-400 text-sm">
{formatDate(memory.created_at)}
</div>
</div>
</div>
</Link>
</div>
))}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import "@/styles/animation.css";
import { useEffect } from "react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { use } from "react";
import { MemorySkeleton } from "@/skeleton/MemorySkeleton";
import { MemoryDetails } from "./components/MemoryDetails";
import UpdateMemory from "@/components/shared/update-memory";
import { useUI } from "@/hooks/useUI";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
import NotFound from "@/app/not-found";
function MemoryContent({ id }: { id: string }) {
const { fetchMemoryById, isLoading, error } = useMemoriesApi();
const memory = useSelector(
(state: RootState) => state.memories.selectedMemory
);
useEffect(() => {
const loadMemory = async () => {
try {
await fetchMemoryById(id);
} catch (err) {
console.error("Failed to load memory:", err);
}
};
loadMemory();
}, []);
if (isLoading) {
return <MemorySkeleton />;
}
if (error) {
return <NotFound message={error} />;
}
if (!memory) {
return <NotFound message="Memory not found" statusCode={404} />;
}
return <MemoryDetails memory_id={memory.id} />;
}
export default function MemoryPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const resolvedParams = use(params);
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
return (
<div>
<div className="animate-fade-slide-down delay-1">
<UpdateMemory
memoryId={updateMemoryDialog.memoryId || ""}
memoryContent={updateMemoryDialog.memoryContent || ""}
open={updateMemoryDialog.isOpen}
onOpenChange={handleCloseUpdateMemoryDialog}
/>
</div>
<div className="animate-fade-slide-down delay-2">
<MemoryContent id={resolvedParams.id} />
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import "@/styles/notfound.scss";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface NotFoundProps {
statusCode?: number;
message?: string;
title?: string;
}
const getStatusCode = (message: string) => {
const possibleStatusCodes = ["404", "403", "500", "422"];
const potentialStatusCode = possibleStatusCodes.find((code) =>
message.includes(code)
);
return potentialStatusCode ? parseInt(potentialStatusCode) : undefined;
};
export default function NotFound({
statusCode,
message = "Page Not Found",
title,
}: NotFoundProps) {
const potentialStatusCode = getStatusCode(message);
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-100px)]">
<div className="site">
<div className="sketch">
<div className="bee-sketch red"></div>
<div className="bee-sketch blue"></div>
</div>
<h1>
{statusCode
? `${statusCode}:`
: potentialStatusCode
? `${potentialStatusCode}:`
: "404"}
<small>{title || message || "Page Not Found"}</small>
</h1>
</div>
<div className="">
<Button
variant="outline"
className="bg-primary text-white hover:bg-primary/80"
>
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Install } from "@/components/dashboard/Install";
import Stats from "@/components/dashboard/Stats";
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
import "@/styles/animation.css";
export default function DashboardPage() {
return (
<div className="text-white py-6">
<div className="container">
<div className="w-full mx-auto space-y-6">
<div className="grid grid-cols-3 gap-6">
{/* Memory Category Breakdown */}
<div className="col-span-2 animate-fade-slide-down">
<Install />
</div>
{/* Memories Stats */}
<div className="col-span-1 animate-fade-slide-down delay-1">
<Stats />
</div>
</div>
<div>
<div className="animate-fade-slide-down delay-2">
<MemoryFilters />
</div>
<div className="animate-fade-slide-down delay-3">
<MemoriesSection />
</div>
</div>
</div>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Provider } from "react-redux";
import { store } from "../store/store";
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { Button } from "@/components/ui/button";
import { HiHome, HiMiniRectangleStack } from "react-icons/hi2";
import { RiApps2AddFill } from "react-icons/ri";
import { FiRefreshCcw } from "react-icons/fi";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CreateMemoryDialog } from "@/app/memories/components/CreateMemoryDialog";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import Image from "next/image";
import { useStats } from "@/hooks/useStats";
import { useAppsApi } from "@/hooks/useAppsApi";
export function Navbar() {
const pathname = usePathname();
const memoriesApi = useMemoriesApi();
const appsApi = useAppsApi();
const statsApi = useStats();
// Define route matchers with typed parameter extraction
const routeBasedFetchMapping: {
match: RegExp;
getFetchers: (params: Record<string, string>) => (() => Promise<any>)[];
}[] = [
{
match: /^\/memory\/([^/]+)$/,
getFetchers: ({ memory_id }) => [
() => memoriesApi.fetchMemoryById(memory_id),
() => memoriesApi.fetchAccessLogs(memory_id),
() => memoriesApi.fetchRelatedMemories(memory_id),
],
},
{
match: /^\/apps\/([^/]+)$/,
getFetchers: ({ app_id }) => [
() => appsApi.fetchAppMemories(app_id),
() => appsApi.fetchAppAccessedMemories(app_id),
() => appsApi.fetchAppDetails(app_id),
],
},
{
match: /^\/memories$/,
getFetchers: () => [memoriesApi.fetchMemories],
},
{
match: /^\/apps$/,
getFetchers: () => [appsApi.fetchApps],
},
{
match: /^\/$/,
getFetchers: () => [statsApi.fetchStats, memoriesApi.fetchMemories],
},
];
const getFetchersForPath = (path: string) => {
for (const route of routeBasedFetchMapping) {
const match = path.match(route.match);
if (match) {
if (route.match.source.includes("memory")) {
return route.getFetchers({ memory_id: match[1] });
}
if (route.match.source.includes("app")) {
return route.getFetchers({ app_id: match[1] });
}
return route.getFetchers({});
}
}
return [];
};
const handleRefresh = async () => {
const fetchers = getFetchersForPath(pathname);
await Promise.allSettled(fetchers.map((fn) => fn()));
};
const isActive = (href: string) => {
if (href === "/") return pathname === href;
return pathname.startsWith(href.substring(0, 5));
};
const activeClass = "bg-zinc-800 text-white border-zinc-600";
const inactiveClass = "text-zinc-300";
return (
<header className="sticky top-0 z-50 w-full border-b border-zinc-800 bg-zinc-950/95 backdrop-blur supports-[backdrop-filter]:bg-zinc-950/60">
<div className="container flex h-14 items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Image src="/logo.svg" alt="OpenMemory" width={26} height={26} />
<span className="text-xl font-medium">OpenMemory</span>
</Link>
<div className="flex items-center gap-2">
<Link href="/">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/") ? activeClass : inactiveClass
}`}
>
<HiHome />
Dashboard
</Button>
</Link>
<Link href="/memories">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/memories") ? activeClass : inactiveClass
}`}
>
<HiMiniRectangleStack />
Memories
</Button>
</Link>
<Link href="/apps">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/apps") ? activeClass : inactiveClass
}`}
>
<RiApps2AddFill />
Apps
</Button>
</Link>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
>
<FiRefreshCcw className="transition-transform duration-300 group-hover:rotate-180" />
Refresh
</Button>
<CreateMemoryDialog />
</div>
</div>
</header>
);
}
@@ -0,0 +1,196 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Copy, Check } from "lucide-react";
import Image from "next/image";
const clientTabs = [
{ key: "claude", label: "Claude", icon: "/images/claude.webp" },
{ key: "cursor", label: "Cursor", icon: "/images/cursor.png" },
{ key: "cline", label: "Cline", icon: "/images/cline.png" },
{ key: "roocline", label: "Roo Cline", icon: "/images/roocline.png" },
{ key: "windsurf", label: "Windsurf", icon: "/images/windsurf.png" },
{ key: "witsy", label: "Witsy", icon: "/images/witsy.png" },
{ key: "enconvo", label: "Enconvo", icon: "/images/enconvo.png" },
];
const colorGradientMap: { [key: string]: string } = {
claude:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]",
cline:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]",
cursor:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]",
roocline:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.8),_rgba(45,32,92,0))] data-[state=active]:border-[#7E3FF2]",
windsurf:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]",
witsy:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]",
enconvo:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]",
};
const getColorGradient = (color: string) => {
if (colorGradientMap[color]) {
return colorGradientMap[color];
}
return "data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]";
};
const allTabs = [{ key: "mcp", label: "MCP Link", icon: "🔗" }, ...clientTabs];
export const Install = () => {
const [copiedTab, setCopiedTab] = useState<string | null>(null);
const user = process.env.NEXT_PUBLIC_USER_ID || "user";
const URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8765";
const handleCopy = async (tab: string, isMcp: boolean = false) => {
const text = isMcp
? `${URL}/mcp/openmemory/sse/${user}`
: `npx install-mcp i ${URL}/mcp/${tab}/sse/${user} --client ${tab}`;
try {
// Try using the Clipboard API first
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback: Create a temporary textarea element
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
// Update UI to show success
setCopiedTab(tab);
setTimeout(() => setCopiedTab(null), 1500); // Reset after 1.5s
} catch (error) {
console.error("Failed to copy text:", error);
// You might want to add a toast notification here to show the error
}
};
return (
<div>
<h2 className="text-xl font-semibold mb-6">Install OpenMemory</h2>
<div className="hidden">
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.3),_rgba(45,32,92,0))] data-[state=active]:border-[#2D205C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(107,33,168,0.3),_rgba(107,33,168,0))] data-[state=active]:border-primary"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]"></div>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-0 p-0 grid grid-cols-8">
{allTabs.map(({ key, label, icon }) => (
<TabsTrigger
key={key}
value={key}
className={`flex-1 px-0 pb-2 rounded-none ${getColorGradient(
key
)} data-[state=active]:border-b-2 data-[state=active]:shadow-none text-zinc-400 data-[state=active]:text-white flex items-center justify-center gap-2 text-sm`}
>
{icon.startsWith("/") ? (
<div>
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image src={icon} alt={label} width={40} height={40} />
</div>
</div>
) : (
<div className="h-6">
<span className="relative top-1">{icon}</span>
</div>
)}
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
{/* MCP Tab Content */}
<TabsContent value="mcp" className="mt-6">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="py-4">
<CardTitle className="text-white text-xl">MCP Link</CardTitle>
</CardHeader>
<hr className="border-zinc-800" />
<CardContent className="py-4">
<div className="relative">
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
<code className="text-gray-300">
{URL}/mcp/openmemory/sse/{user}
</code>
</pre>
<div>
<button
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
aria-label="Copy to clipboard"
onClick={() => handleCopy("mcp", true)}
>
{copiedTab === "mcp" ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<Copy className="h-5 w-5 text-zinc-400" />
)}
</button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Client Tabs Content */}
{clientTabs.map(({ key }) => (
<TabsContent key={key} value={key} className="mt-6">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="py-4">
<CardTitle className="text-white text-xl">
{key.charAt(0).toUpperCase() + key.slice(1)} Installation
Command
</CardTitle>
</CardHeader>
<hr className="border-zinc-800" />
<CardContent className="py-4">
<div className="relative">
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
<code className="text-gray-300">
{`npx install-mcp i ${URL}/mcp/${key}/sse/${user} --client ${key}`}
</code>
</pre>
<div>
<button
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
aria-label="Copy to clipboard"
onClick={() => handleCopy(key)}
>
{copiedTab === key ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<Copy className="h-5 w-5 text-zinc-400" />
)}
</button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
);
};
export default Install;
@@ -0,0 +1,69 @@
import React, { useEffect } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useStats } from "@/hooks/useStats";
import Image from "next/image";
import { constants } from "@/components/shared/source-app";
const Stats = () => {
const totalMemories = useSelector(
(state: RootState) => state.profile.totalMemories
);
const totalApps = useSelector((state: RootState) => state.profile.totalApps);
const apps = useSelector((state: RootState) => state.profile.apps).slice(
0,
4
);
const { fetchStats } = useStats();
useEffect(() => {
fetchStats();
}, []);
return (
<div className="bg-zinc-900 rounded-lg border border-zinc-800">
<div className="bg-zinc-800 border-b border-zinc-800 rounded-t-lg p-4">
<div className="text-white text-xl font-semibold">Memories Stats</div>
</div>
<div className="space-y-3 p-4">
<div>
<p className="text-zinc-400">Total Memories</p>
<h3 className="text-lg font-bold text-white">
{totalMemories} Memories
</h3>
</div>
<div>
<p className="text-zinc-400">Total Apps Connected</p>
<div className="flex flex-col items-start gap-1 mt-2">
<div className="flex -space-x-2">
{apps.map((app) => (
<div
key={app.id}
className={`h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs`}
>
<div>
<div className="w-7 h-7 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={
constants[app.name as keyof typeof constants]
?.iconImage || ""
}
alt={
constants[app.name as keyof typeof constants]?.name
}
width={32}
height={32}
/>
</div>
</div>
</div>
))}
</div>
<h3 className="text-lg font-bold text-white">{totalApps} Apps</h3>
</div>
</div>
</div>
</div>
);
};
export default Stats;
@@ -0,0 +1,230 @@
import React, { useState } from "react";
import {
Book,
HeartPulse,
BriefcaseBusiness,
CircleHelp,
Palette,
Code,
Settings,
Users,
Heart,
Brain,
MapPin,
Globe,
PersonStandingIcon,
} from "lucide-react";
import {
FaLaptopCode,
FaPaintBrush,
FaBusinessTime,
FaRegHeart,
FaRegSmile,
FaUserTie,
FaMoneyBillWave,
FaBriefcase,
FaPlaneDeparture,
} from "react-icons/fa";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "../ui/badge";
type Category = string;
const defaultIcon = <CircleHelp className="w-4 h-4 mr-2" />;
const iconMap: Record<string, any> = {
// Core themes
health: <HeartPulse className="w-4 h-4 mr-2" />,
wellness: <Heart className="w-4 h-4 mr-2" />,
fitness: <HeartPulse className="w-4 h-4 mr-2" />,
education: <Book className="w-4 h-4 mr-2" />,
learning: <Book className="w-4 h-4 mr-2" />,
school: <Book className="w-4 h-4 mr-2" />,
coding: <FaLaptopCode className="w-4 h-4 mr-2" />,
programming: <Code className="w-4 h-4 mr-2" />,
development: <Code className="w-4 h-4 mr-2" />,
tech: <Settings className="w-4 h-4 mr-2" />,
design: <FaPaintBrush className="w-4 h-4 mr-2" />,
art: <Palette className="w-4 h-4 mr-2" />,
creativity: <Palette className="w-4 h-4 mr-2" />,
psychology: <Brain className="w-4 h-4 mr-2" />,
mental: <Brain className="w-4 h-4 mr-2" />,
social: <Users className="w-4 h-4 mr-2" />,
peronsal: <PersonStandingIcon className="w-4 h-4 mr-2" />,
life: <Heart className="w-4 h-4 mr-2" />,
// Work / Career
business: <FaBusinessTime className="w-4 h-4 mr-2" />,
work: <FaBriefcase className="w-4 h-4 mr-2" />,
career: <FaUserTie className="w-4 h-4 mr-2" />,
jobs: <BriefcaseBusiness className="w-4 h-4 mr-2" />,
finance: <FaMoneyBillWave className="w-4 h-4 mr-2" />,
money: <FaMoneyBillWave className="w-4 h-4 mr-2" />,
// Preferences
preference: <FaRegHeart className="w-4 h-4 mr-2" />,
interest: <FaRegSmile className="w-4 h-4 mr-2" />,
// Travel & Location
travel: <FaPlaneDeparture className="w-4 h-4 mr-2" />,
journey: <FaPlaneDeparture className="w-4 h-4 mr-2" />,
location: <MapPin className="w-4 h-4 mr-2" />,
trip: <Globe className="w-4 h-4 mr-2" />,
places: <Globe className="w-4 h-4 mr-2" />,
};
const getClosestIcon = (label: string): any => {
const normalized = label.toLowerCase().split(/[\s\-_.]+/);
let bestMatch: string | null = null;
let bestScore = 0;
Object.keys(iconMap).forEach((key) => {
const keyTokens = key.split(/[\s\-_.]+/);
const matchScore = normalized.filter((word) =>
keyTokens.some((token) => word.includes(token) || token.includes(word))
).length;
if (matchScore > bestScore) {
bestScore = matchScore;
bestMatch = key;
}
});
return bestMatch ? iconMap[bestMatch] : defaultIcon;
};
const getColor = (label: string): string => {
const l = label.toLowerCase();
if (l.includes("health") || l.includes("fitness"))
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
if (l.includes("education") || l.includes("school"))
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/20";
if (
l.includes("business") ||
l.includes("career") ||
l.includes("work") ||
l.includes("finance")
)
return "text-amber-400 bg-amber-500/10 border-amber-500/20";
if (l.includes("design") || l.includes("art") || l.includes("creative"))
return "text-pink-400 bg-pink-500/10 border-pink-500/20";
if (l.includes("tech") || l.includes("code") || l.includes("programming"))
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
if (l.includes("interest") || l.includes("preference"))
return "text-rose-400 bg-rose-500/10 border-rose-500/20";
if (
l.includes("travel") ||
l.includes("trip") ||
l.includes("location") ||
l.includes("place")
)
return "text-sky-400 bg-sky-500/10 border-sky-500/20";
if (l.includes("personal") || l.includes("life"))
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
};
const Categories = ({
categories,
isPaused = false,
concat = false,
}: {
categories: Category[];
isPaused?: boolean;
concat?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
if (!categories || categories.length === 0) return null;
const baseBadgeStyle =
"backdrop-blur-sm transition-colors hover:bg-opacity-20";
const pausedStyle =
"text-zinc-500 bg-zinc-800/40 border-zinc-700/40 hover:bg-zinc-800/60";
if (concat) {
const remainingCount = categories.length - 1;
return (
<div className="flex flex-wrap gap-2">
{/* First category */}
<Badge
variant="outline"
className={`${
isPaused
? pausedStyle
: `${getColor(categories[0])} ${baseBadgeStyle}`
}`}
>
{categories[0]}
</Badge>
{/* Popover for remaining categories */}
{remainingCount > 0 && (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<Badge
variant="outline"
className={
isPaused
? pausedStyle
: "text-zinc-400 bg-zinc-500/10 border-zinc-500/20 hover:bg-zinc-500/20"
}
>
+{remainingCount}
</Badge>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2 border bg-[#27272A] border-zinc-700/60 rounded-2xl"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2">
{categories.slice(1).map((cat, i) => (
<Badge
key={i}
variant="outline"
className={`${
isPaused
? pausedStyle
: `${getColor(cat)} ${baseBadgeStyle}`
}`}
>
{cat}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
// Default view
return (
<div className="flex flex-wrap gap-2">
{categories?.map((cat, i) => (
<Badge
key={i}
variant="outline"
className={`${
isPaused ? pausedStyle : `${getColor(cat)} ${baseBadgeStyle}`
}`}
>
{cat}
</Badge>
))}
</div>
);
};
export default Categories;
@@ -0,0 +1,80 @@
import React from "react";
import { BiEdit } from "react-icons/bi";
import Image from "next/image";
export const Icon = ({ source }: { source: string }) => {
return (
<div className="w-4 h-4 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden -mr-1">
<Image src={source} alt={source} width={40} height={40} />
</div>
);
};
export const constants = {
claude: {
name: "Claude",
icon: <Icon source="/images/claude.webp" />,
iconImage: "/images/claude.webp",
},
openmemory: {
name: "OpenMemory",
icon: <Icon source="/images/open-memory.svg" />,
iconImage: "/images/open-memory.svg",
},
cursor: {
name: "Cursor",
icon: <Icon source="/images/cursor.png" />,
iconImage: "/images/cursor.png",
},
cline: {
name: "Cline",
icon: <Icon source="/images/cline.png" />,
iconImage: "/images/cline.png",
},
roocline: {
name: "Roo Cline",
icon: <Icon source="/images/roocline.png" />,
iconImage: "/images/roocline.png",
},
windsurf: {
name: "Windsurf",
icon: <Icon source="/images/windsurf.png" />,
iconImage: "/images/windsurf.png",
},
witsy: {
name: "Witsy",
icon: <Icon source="/images/witsy.png" />,
iconImage: "/images/witsy.png",
},
enconvo: {
name: "Enconvo",
icon: <Icon source="/images/enconvo.png" />,
iconImage: "/images/enconvo.png",
},
default: {
name: "Default",
icon: <BiEdit size={18} className="ml-1" />,
iconImage: "/images/default.png",
},
};
const SourceApp = ({ source }: { source: string }) => {
if (!constants[source as keyof typeof constants]) {
return (
<div>
<BiEdit />
<span className="text-sm font-semibold">{source}</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
{constants[source as keyof typeof constants].icon}
<span className="text-sm font-semibold">
{constants[source as keyof typeof constants].name}
</span>
</div>
);
};
export default SourceApp;
@@ -0,0 +1,93 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useRef } from "react";
import { Loader2 } from "lucide-react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { usePathname } from "next/navigation";
interface UpdateMemoryProps {
memoryId: string;
memoryContent: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const UpdateMemory = ({
memoryId,
memoryContent,
open,
onOpenChange,
}: UpdateMemoryProps) => {
const { updateMemory, isLoading, fetchMemories, fetchMemoryById } =
useMemoriesApi();
const textRef = useRef<HTMLTextAreaElement>(null);
const pathname = usePathname();
const handleUpdateMemory = async (text: string) => {
try {
await updateMemory(memoryId, text);
toast.success("Memory updated successfully");
onOpenChange(false);
if (pathname.includes("memories")) {
await fetchMemories();
} else {
await fetchMemoryById(memoryId);
}
} catch (error) {
console.error(error);
toast.error("Failed to update memory");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px] bg-zinc-900 border-zinc-800 z-50">
<DialogHeader>
<DialogTitle>Update Memory</DialogTitle>
<DialogDescription>Edit your existing memory</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="memory">Memory</Label>
<Textarea
ref={textRef}
id="memory"
className="bg-zinc-950 border-zinc-800 min-h-[150px]"
defaultValue={memoryContent}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="w-[140px]"
disabled={isLoading}
onClick={() => handleUpdateMemory(textRef?.current?.value || "")}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
"Update Memory"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default UpdateMemory;
@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+13
View File
@@ -0,0 +1,13 @@
export type Category = "personal" | "work" | "health" | "finance" | "travel" | "education" | "preferences" | "relationships"
export type Client = "chrome" | "chatgpt" | "cursor" | "windsurf" | "terminal" | "api"
export interface Memory {
id: string
memory: string
metadata: any
client: Client
categories: Category[]
created_at: number
app_name: string
state: "active" | "paused" | "archived" | "deleted"
}
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

Some files were not shown because too many files have changed in this diff Show More