Compare commits

..
22 Commits
Author SHA1 Message Date
Dev KhantandGitHub c3f3f82a3e Migrate to Hatch and version bump -> 0.1.101 (#2727) 2025-05-20 22:58:51 +05:30
Saket AryanandGitHub 70af43c08c improvement(OMM): Added CurL Command to Easy Install OMM (#2731) 2025-05-20 20:18:07 +05:30
Tomaz BratanicandGitHub 1786d907f7 Add neo4j base label config (#2675) 2025-05-19 18:22:20 -07:00
Prateek ChhikaraandGitHub 12a268da30 Added docs for criteria based filtering (#2726) 2025-05-19 14:52:43 -07:00
Chaithanya KumarandGitHub 0aefdf5251 Refactored collaborative task agent documentation to enhance clarity and simplified (#2725) 2025-05-19 09:58:22 -07:00
Antaripa SahaandGitHub df72245b6b Update Index of Healthcare Example in docs (#2722) 2025-05-19 02:18:14 -07:00
Dev KhantandGitHub fe872d0776 Update Changelog (#2720) 2025-05-19 12:54:15 +05:30
Dev KhantandGitHub 052d31939d version bump -> 0.1.100 (#2719) 2025-05-19 12:27:38 +05:30
Antaripa SahaandGitHub 1c44b675d9 Healthcare assistant using Mem0 and Google ADK (#2705) 2025-05-18 07:50:06 -07:00
Chaithanya KumarandGitHub a1c9a63074 # feat: Add Group Chat Memory Feature support to Python SDK enhancing mem0 (#2669) 2025-05-16 11:08:36 -07:00
931df14e25 fix(OMM): Memories not appearing in MCP clients added from Dashboard (#2704)
Co-authored-by: Deshraj Yadav <deshraj@gatech.edu>
2025-05-16 22:11:22 +05:30
hengandGitHub 1b0d8bdd2e improvement(OMM)- fix the sse failed to connect issue (#2696) 2025-05-16 15:57:46 +05:30
5c67a5e6bc improvement(OSS): Fix AOSS and AWS BedRock LLM (#2697)
Co-authored-by: Prateek Chhikara <prateekchhikara24@gmail.com>
Co-authored-by: Deshraj Yadav <deshrajdry@gmail.com>
2025-05-16 04:49:29 +05:30
GongRzheandGitHub 267e5b13ea Update README.md (#2687) 2025-05-15 00:16:05 -07:00
a22287a3ba improvement(OpenMemory MCP): Improves Docker Compose commands (#2681)
Co-authored-by: Deshraj Yadav <deshrajdry@gmail.com>
2025-05-14 13:44:08 +05:30
Saket AryanandGitHub da59412150 Remove OpenMemory Directory from pyproject and Update Link (#2678) 2025-05-13 21:50:56 +05:30
Saket AryanandGitHub c41719ff9a Fix Backend Link in OpenMemory (#2677) 2025-05-13 08:36:59 -07:00
f51b39db91 Add OpenMemory (#2676)
Co-authored-by: Saket Aryan <94069182+whysosaket@users.noreply.github.com>
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
2025-05-13 08:30:59 -07:00
Saket AryanandGitHub 8d61d73d2f Added ElizaOS Example (#2670) 2025-05-12 10:05:11 -07:00
Saket AryanandGitHub 10acf78618 Added Missing Param in AI SDK and Updated Demo Application (#2667) 2025-05-12 04:22:23 +05:30
Tomaz BratanicandGitHub caeae60dda Add weights to Neo4j model (#2657) 2025-05-10 14:51:34 -07:00
Dev KhantandGitHub d7b8497b24 Doc: update azure ai (#2661) 2025-05-09 20:03:34 +05:30
213 changed files with 20853 additions and 905 deletions
+4 -7
View File
@@ -18,20 +18,17 @@ jobs:
with:
python-version: '3.11'
- name: Install Poetry
- name: Install Hatch
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
pip install hatch
- name: Install dependencies
run: |
cd mem0
poetry install
hatch env create
- name: Build a binary wheel and a source tarball
run: |
cd mem0
poetry build
hatch build --clean
# TODO: Needs to setup mem0 repo on Test PyPI
# - name: Publish distribution 📦 to Test PyPI
+16 -22
View File
@@ -44,25 +44,23 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: snok/install-poetry@v1
with:
version: 1.4.2
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install Hatch
run: pip install hatch
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-hatch-dependencies
uses: actions/cache@v3
with:
path: .venv
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
- name: Install dependencies
run: make install_all
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: |
make install_all
pip install -e ".[test]"
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
- name: Run Formatting
run: |
mkdir -p mem0/.ruff_cache && chmod -R 777 mem0/.ruff_cache
cd mem0 && poetry run ruff check . --select F
mkdir -p .ruff_cache && chmod -R 777 .ruff_cache
hatch run format
- name: Run tests and generate coverage report
run: make test
@@ -79,25 +77,21 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: snok/install-poetry@v1
with:
version: 1.4.2
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install Hatch
run: pip install hatch
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-hatch-dependencies
uses: actions/cache@v3
with:
path: .venv
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
- name: Install dependencies
run: cd embedchain && make install_all
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
- name: Run Formatting
run: |
mkdir -p embedchain/.ruff_cache && chmod -R 777 embedchain/.ruff_cache
cd embedchain && poetry run ruff check . --select F
cd embedchain && hatch run format
- name: Lint with ruff
run: cd embedchain && make lint
- name: Run tests and generate coverage report
+10 -11
View File
@@ -8,37 +8,36 @@ PROJECT_NAME := mem0ai
all: format sort lint
install:
poetry install
hatch env create
install_all:
poetry install
poetry run pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
google-generativeai elasticsearch opensearch-py vecs pinecone pinecone-text faiss-cpu langchain-community \
upstash-vector azure-search-documents langchain-memgraph
upstash-vector azure-search-documents langchain-memgraph langchain-neo4j rank-bm25
# Format code with ruff
format:
poetry run ruff format mem0/
hatch run format
# Sort imports with isort
sort:
poetry run isort mem0/
hatch run isort mem0/
# Lint code with ruff
lint:
poetry run ruff check mem0/
hatch run lint
docs:
cd docs && mintlify dev
build:
poetry build
hatch build
publish:
poetry publish
hatch publish
clean:
poetry run rm -rf dist
rm -rf dist
test:
poetry run pytest tests
hatch run test
+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">
+182
View File
@@ -8,6 +8,85 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2025-05-10" description="v0.1.100">
**New Features:**
- **Memory:** Added Group Chat Memory Feature support
- **Examples:** Added Healthcare assistant using Mem0 and Google ADK
**Bug Fixes:**
- **SSE:** Fixed SSE connection issues
- **MCP:** Fixed memories not appearing in MCP clients added from Dashboard
</Update>
<Update label="2025-05-07" description="v0.1.99">
**New Features:**
- **OpenMemory:** Added OpenMemory support
- **Neo4j:** Added weights to Neo4j model
- **AWS:** Added support for Opsearch Serverless
- **Examples:** Added ElizaOS Example
**Improvements:**
- **Documentation:** Updated Azure AI documentation
- **AI SDK:** Added missing parameters and updated demo application
- **OSS:** Fixed AOSS and AWS BedRock LLM
</Update>
<Update label="2025-04-30" description="v0.1.98">
**New Features:**
- **Neo4j:** Added support for Neo4j database
- **AWS:** Added support for AWS Bedrock Embeddings
**Improvements:**
- **Client:** Updated delete_users() to use V2 API endpoints
- **Documentation:** Updated timestamp and dual-identity memory management docs
- **Neo4j:** Improved Neo4j queries and removed warnings
- **AI SDK:** Added support for graceful failure when services are down
**Bug Fixes:**
- Fixed AI SDK filters
- Fixed new memories wrong type
- Fixed duplicated metadata issue while adding/updating memories
</Update>
<Update label="2025-04-23" description="v0.1.97">
**New Features:**
- **HuggingFace:** Added support for HF Inference
**Bug Fixes:**
- Fixed proxy for Mem0
</Update>
<Update label="2025-04-16" description="v0.1.96">
**New Features:**
- **Vercel AI SDK:** Added Graph Memory support
**Improvements:**
- **Documentation:** Fixed timestamp and README links
- **Client:** Updated TS client to use proper types for deleteUsers
- **Dependencies:** Removed unnecessary dependencies from base package
</Update>
<Update label="2025-04-09" description="v0.1.95">
**Improvements:**
- **Client:** Fixed Ping Method for using default org_id and project_id
- **Documentation:** Updated documentation
**Bug Fixes:**
- Fixed mem0-migrations issue
</Update>
<Update label="2025-04-26" description="v0.1.94">
**New Features:**
@@ -330,6 +409,104 @@ mode: "wide"
<Tab title="Platform">
<Update label="2025-05-19" description="">
**Bug Fixes:**
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
</Update>
<Update label="2025-05-17" description="">
**New Features:**
- **Graph:** Added Neo4J Graph Migration
- **API:** Added API to set custom instructions
</Update>
<Update label="2025-05-16" description="">
**New Features:**
- **API:** Added Org-wide API Limit and Usage
**Improvements:**
- **Database:** Added migration for "is_deleted" column
- **Graph:** Improved graph queries
</Update>
<Update label="2025-05-15" description="">
**New Features:**
- **Lambda:** Added actions to lambda
- **Core:** Added background runs support
- **Models:** Added o4-mini for pro users
</Update>
<Update label="2025-05-10" description="">
**New Features:**
- **Integrations:** Added Intercom Events integration
- **Billing:** Added prefilled email for payments
- **Organizations:** Added Pro organization marking
**Improvements:**
- **UI:** Fixed loading jitter for organization selection
- **Infrastructure:** Improved production scaling
</Update>
<Update label="2025-05-09" description="">
**Improvements:**
- **Memory:** Fixed filters in Memory Page
- **Deployment:** Added custom categories for on-premise
</Update>
<Update label="2025-05-08" description="">
**Improvements:**
- **Backend:** Updated Django settings for metrics
- **Memory:** Added retries to memory filtering
- **Search:** Added scoring mechanism
</Update>
<Update label="2025-05-07" description="">
**Improvements:**
- **Deployment:** Updated deployment scripts
- **Testing:** Added code coverage tracking
- **Memory:** Added background cron job for memory quality
</Update>
<Update label="2025-05-06" description="">
**New Features:**
- **Models:** Added support for 4.1-mini model
**Improvements:**
- **Infrastructure:** Increased instance count
- **API:** Added V2 for Manage Entities
</Update>
<Update label="2025-05-04" description="">
**New Features:**
- **Testing:** Added code coverage tracking
- **AI:** Added Keywords AI integration
**Improvements:**
- **UI:** Updated UI with tabs
- **Database:** Added migrations for custom instructions
- **Search:** Added criteria filtering
</Update>
<Update label="2025-04-26" description="">
**Improvements:**
@@ -487,6 +664,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
+5 -5
View File
@@ -29,7 +29,7 @@ For detailed guidance on pull requests, refer to [GitHub's documentation](https:
## 📦 Dependency Management
We use `poetry` as our package manager. Install it by following the [official instructions](https://python-poetry.org/docs/#installation).
We use `hatch` as our package manager. Install it by following the [official instructions](https://hatch.pypa.io/latest/install/).
⚠️ **Do NOT use `pip` or `conda` for dependency management.** Instead, run:
@@ -37,7 +37,7 @@ We use `poetry` as our package manager. Install it by following the [official in
make install_all
# Activate virtual environment
poetry shell
hatch shell
```
---
@@ -60,9 +60,9 @@ Run the linter and fix any reported issues before submitting your PR:
make lint
```
### 🎨 Code Formatting with `black`
### 🎨 Code Formatting
To maintain a consistent code style, format your code using `black`:
To maintain a consistent code style, format your code:
```bash
make format
@@ -76,7 +76,7 @@ Run tests to verify functionality before submitting your PR:
make test
```
💡 **Note:** Some dependencies have been removed from Poetry to reduce package size. Run `make install_all` to install necessary dependencies before running tests.
💡 **Note:** Some dependencies have been removed from the main dependencies to reduce package size. Run `make install_all` to install necessary dependencies before running tests.
---
+13
View File
@@ -47,6 +47,7 @@
"pages": [
"features/platform-overview",
"features/advanced-retrieval",
"features/criteria-retrieval",
"features/contextual-add",
"features/multimodal-support",
"features/timestamp",
@@ -184,6 +185,14 @@
}
]
},
{
"tab": "OpenMemory",
"icon": "square-terminal",
"pages": [
"openmemory/overview",
"openmemory/quickstart"
]
},
{
"tab": "Examples",
"groups": [
@@ -192,8 +201,11 @@
"icon": "lightbulb",
"pages": [
"examples",
"examples/aws_example",
"examples/mem0-demo",
"examples/ai_companion_js",
"examples/collaborative-task-agent",
"examples/eliza_os",
"examples/mem0-mastra",
"examples/mem0-with-ollama",
"examples/personal-ai-tutor",
@@ -207,6 +219,7 @@
"examples/mem0-agentic-tool",
"examples/openai-inbuilt-tools",
"examples/mem0-openai-voice-demo",
"examples/mem0-google-adk-healthcare-assistant",
"examples/email_processing",
"examples/youtube-assistant"
]
+5 -1
View File
@@ -75,7 +75,11 @@ Explore how **Mem0** can power real-world applications and bring personalized, i
<Card title="Mem0 OpenAI Voice Demo" icon="microphone" href="/examples/mem0-openai-voice-demo">
Use Mem0's memory capabilities with OpenAI's Inbuilt Tools to create AI agents with persistent memory.
</Card>
</Card>
<Card title="Healthcare Assistant Google ADK" icon="microphone" href="/examples/mem0-google-adk-healthcare-assistant">
Build a personalized healthcare assistant with persistent memory using Google's ADK and Mem0.
</Card>
<Card title="Email Processing" icon="envelope" href="/examples/email_processing">
Use Mem0's memory capabilities to process emails and create AI agents with persistent memory.
+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.
+125
View File
@@ -0,0 +1,125 @@
---
title: Multi-User Collaboration with Mem0
---
<Snippet file="paper-release.mdx" />
## Overview
Build a multi-user collaborative chat or task management system with Mem0. Each message is attributed to its author, and all messages are stored in a shared project space. Mem0 makes it easy to track contributions, sort and group messages, and collaborate in real time.
## Setup
Install the required packages:
```bash
pip install openai mem0ai
```
## Full Code Example
```python
from openai import OpenAI
from mem0 import Memory
import os
from datetime import datetime
from collections import defaultdict
# Set your OpenAI API key
os.environ["OPENAI_API_KEY"] = "sk-your-key"
# Shared project context
RUN_ID = "project-demo"
# Initialize Mem0
mem = Memory()
class CollaborativeAgent:
def __init__(self, run_id):
self.run_id = run_id
self.mem = mem
def add_message(self, role, name, content):
msg = {"role": role, "name": name, "content": content}
self.mem.add([msg], run_id=self.run_id, infer=False)
def brainstorm(self, prompt):
# Get recent messages for context
memories = self.mem.search(prompt, run_id=self.run_id, limit=5)["results"]
context = "\n".join(f"- {m['memory']} (by {m.get('actor_id', 'Unknown')})" for m in memories)
client = OpenAI()
messages = [
{"role": "system", "content": "You are a helpful project assistant."},
{"role": "user", "content": f"Prompt: {prompt}\nContext:\n{context}"}
]
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
).choices[0].message.content.strip()
self.add_message("assistant", "assistant", reply)
return reply
def get_all_messages(self):
return self.mem.get_all(run_id=self.run_id)["results"]
def print_sorted_by_time(self):
messages = self.get_all_messages()
messages.sort(key=lambda m: m.get('created_at', ''))
print("\n--- Messages (sorted by time) ---")
for m in messages:
who = m.get("actor_id") or "Unknown"
ts = m.get('created_at', 'Timestamp N/A')
try:
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
ts_fmt = dt.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
ts_fmt = ts
print(f"[{ts_fmt}] [{who}] {m['memory']}")
def print_grouped_by_actor(self):
messages = self.get_all_messages()
grouped = defaultdict(list)
for m in messages:
grouped[m.get("actor_id") or "Unknown"].append(m)
print("\n--- Messages (grouped by actor) ---")
for actor, mems in grouped.items():
print(f"\n=== {actor} ===")
for m in mems:
ts = m.get('created_at', 'Timestamp N/A')
try:
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
ts_fmt = dt.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
ts_fmt = ts
print(f"[{ts_fmt}] {m['memory']}")
```
## Usage
```python
# Example usage
agent = CollaborativeAgent(RUN_ID)
agent.add_message("user", "alice", "Let's list tasks for the new landing page.")
agent.add_message("user", "bob", "I'll own the hero section copy.")
agent.add_message("user", "carol", "I'll choose product screenshots.")
# Brainstorm with context
print("\nAssistant reply:\n", agent.brainstorm("What are the current open tasks?"))
# Print all messages sorted by time
agent.print_sorted_by_time()
# Print all messages grouped by actor
agent.print_grouped_by_actor()
```
## Key Points
- Each message is attributed to a user or agent (actor)
- All messages are stored in a shared project space (`run_id`)
- You can sort messages by time, group by actor, and format timestamps for clarity
- Mem0 makes it easy to build collaborative, attributed chat/task systems
## Conclusion
Mem0 enables fast, transparent collaboration for teams and agents, with full attribution, flexible memory search, and easy message organization.
+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
@@ -0,0 +1,291 @@
---
title: 'Healthcare Assistant with Mem0 and Google ADK'
description: 'Build a personalized healthcare agent that remembers patient information across conversations using Mem0 and Google ADK'
---
<Snippet file="paper-release.mdx" />
# Healthcare Assistant with Memory
This example demonstrates how to build a healthcare assistant that remembers patient information across conversations using Google ADK and Mem0.
## Overview
The Healthcare Assistant helps patients by:
- Remembering their medical history and symptoms
- Providing general health information
- Scheduling appointment reminders
- Maintaining a personalized experience across conversations
By integrating Mem0's memory layer with Google ADK, the assistant maintains context about the patient without requiring them to repeat information.
## Setup
Before you begin, make sure you have:
Installed Google ADK and Mem0 SDK:
```bash
pip install google-adk
pip install mem0ai
```
## Code Breakdown
Let's get started and understand the different components required in building a healthcare assistant powered by memory
```python
# Import dependencies
import os
from google.adk.agents import Agent
from google.adk.sessions import InMemorySessionService
from google.adk.runners import Runner
from google.genai import types
from mem0 import MemoryClient
# Set up API keys (replace with your actual keys)
os.environ["GOOGLE_API_KEY"] = "your-google-api-key"
os.environ["MEM0_API_KEY"] = "your-mem0-api-key"
# Define a global user ID for simplicity
USER_ID = "Alex"
# Initialize Mem0 client
mem0_client = MemoryClient()
```
## Define Memory Tools
First, we'll create tools that allow our agent to store and retrieve information using Mem0:
```python
def save_patient_info(information: str) -> dict:
"""Saves important patient information to memory."""
# Store in Mem0
response = mem0_client.add(
[{"role": "user", "content": information}],
user_id=USER_ID,
run_id="healthcare_session",
metadata={"type": "patient_information"}
)
def retrieve_patient_info(query: str) -> dict:
"""Retrieves relevant patient information from memory."""
# Search Mem0
results = mem0_client.search(
query,
user_id=USER_ID,
limit=5,
threshold=0.7, # Higher threshold for more relevant results
output_format="v1.1"
)
# Format and return the results
if results and len(results) > 0:
memories = [memory["memory"] for memory in results.get('results', [])]
return {
"status": "success",
"memories": memories,
"count": len(memories)
}
else:
return {
"status": "no_results",
"memories": [],
"count": 0
}
```
## Define Healthcare Tools
Next, we'll add tools specific to healthcare assistance:
```python
def schedule_appointment(date: str, time: str, reason: str) -> dict:
"""Schedules a doctor's appointment."""
# In a real app, this would connect to a scheduling system
appointment_id = f"APT-{hash(date + time) % 10000}"
return {
"status": "success",
"appointment_id": appointment_id,
"confirmation": f"Appointment scheduled for {date} at {time} for {reason}",
"message": "Please arrive 15 minutes early to complete paperwork."
}
```
## Create the Healthcare Assistant Agent
Now we'll create our main agent with all the tools:
```python
# Create the agent
healthcare_agent = Agent(
name="healthcare_assistant",
model="gemini-1.5-flash", # Using Gemini for healthcare assistant
description="Healthcare assistant that helps patients with health information and appointment scheduling.",
instruction="""You are a helpful Healthcare Assistant with memory capabilities.
Your primary responsibilities are to:
1. Remember patient information using the 'save_patient_info' tool when they share symptoms, conditions, or preferences.
2. Retrieve past patient information using the 'retrieve_patient_info' tool when relevant to the current conversation.
3. Help schedule appointments using the 'schedule_appointment' tool.
IMPORTANT GUIDELINES:
- Always be empathetic, professional, and helpful.
- Save important patient information like symptoms, conditions, allergies, and preferences.
- Check if you have relevant patient information before asking for details they may have shared previously.
- Make it clear you are not a doctor and cannot provide medical diagnosis or treatment.
- For serious symptoms, always recommend consulting a healthcare professional.
- Keep all patient information confidential.
""",
tools=[save_patient_info, retrieve_patient_info, schedule_appointment]
)
```
## Set Up Session and Runner
```python
# Set up Session Service and Runner
session_service = InMemorySessionService()
# Define constants for the conversation
APP_NAME = "healthcare_assistant_app"
USER_ID = "Alex"
SESSION_ID = "session_001"
# Create a session
session = session_service.create_session(
app_name=APP_NAME,
user_id=USER_ID,
session_id=SESSION_ID
)
# Create the runner
runner = Runner(
agent=healthcare_agent,
app_name=APP_NAME,
session_service=session_service
)
```
## Interact with the Healthcare Assistant
```python
# Function to interact with the agent
async def call_agent_async(query, runner, user_id, session_id):
"""Sends a query to the agent and returns the final response."""
print(f"\n>>> Patient: {query}")
# Format the user's message
content = types.Content(
role='user',
parts=[types.Part(text=query)]
)
# Set user_id for tools to access
save_patient_info.user_id = user_id
retrieve_patient_info.user_id = user_id
# Run the agent
async for event in runner.run_async(
user_id=user_id,
session_id=session_id,
new_message=content
):
if event.is_final_response():
if event.content and event.content.parts:
response = event.content.parts[0].text
print(f"<<< Assistant: {response}")
return response
return "No response received."
# Example conversation flow
async def run_conversation():
# First interaction - patient introduces themselves with key information
await call_agent_async(
"Hi, I'm Alex. I've been having headaches for the past week, and I have a penicillin allergy.",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Request for health information
await call_agent_async(
"Can you tell me more about what might be causing my headaches?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Schedule an appointment
await call_agent_async(
"I think I should see a doctor. Can you help me schedule an appointment for next Monday at 2pm?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Test memory - should remember patient name, symptoms, and allergy
await call_agent_async(
"What medications should I avoid for my headaches?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Run the conversation example
if __name__ == "__main__":
asyncio.run(run_conversation())
```
## How It Works
This healthcare assistant demonstrates several key capabilities:
1. **Memory Storage**: When Alex mentions her headaches and penicillin allergy, the agent stores this information in Mem0 using the `save_patient_info` tool.
2. **Contextual Retrieval**: When Alex asks about headache causes, the agent uses the `retrieve_patient_info` tool to recall her specific situation.
3. **Memory Application**: When discussing medications, the agent remembers Alex's penicillin allergy without her needing to repeat it, providing safer and more personalized advice.
4. **Conversation Continuity**: The agent maintains context across the entire conversation session, creating a more natural and efficient interaction.
## Key Implementation Details
### User ID Management
Instead of passing the user ID as a parameter to the memory tools (which would require modifying the ADK's tool calling system), we attach it directly to the function object:
```python
# Set user_id for tools to access
save_patient_info.user_id = user_id
retrieve_patient_info.user_id = user_id
```
Inside the tool functions, we retrieve this attribute:
```python
# Get user_id from session state or use default
user_id = getattr(save_patient_info, 'user_id', 'default_user')
```
This approach allows our tools to maintain user context without complicating their parameter signatures.
### Mem0 Integration
The integration with Mem0 happens through two primary functions:
1. `mem0_client.add()` - Stores new information with appropriate metadata
2. `mem0_client.search()` - Retrieves relevant memories using semantic search
The `threshold` parameter in the search function ensures that only highly relevant memories are returned.
## Conclusion
This example demonstrates how to build a healthcare assistant with persistent memory using Google ADK and Mem0. The integration allows for a more personalized patient experience by maintaining context across conversation turns, which is particularly valuable in healthcare scenarios where continuity of information is crucial.
By storing and retrieving patient information intelligently, the assistant provides more relevant responses without requiring the patient to repeat their medical history, symptoms, or preferences.
+184
View File
@@ -0,0 +1,184 @@
---
title: Criteria Retrieval
icon: "magnifying-glass-plus"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
Mem0's **Criteria Retrieval** feature allows you to retrieve memories based on specific criteria. This is useful when you need to find memories that match certain conditions or criteria, such as emotional content, sentiment, or other custom attributes.
## Setting Up Custom Criteria
You can define custom criteria at the project level, assigning weights to each criterion. These weights will be normalized during memory retrieval.
```python
from mem0 import MemoryClient
client = MemoryClient(
api_key="mem0_api_key",
org_id="mem0_organization_id",
project_id="mem0_project_id"
)
# Define custom criteria with weights
retrieval_criteria = [
{
"name": "joy",
"description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the sentence. A higher score reflects greater joy.",
"weight": 3
},
{
"name": "curiosity",
"description": "Assess the extent to which the sentence reflects inquisitiveness, interest in exploring new information, or asking questions. A higher score reflects stronger curiosity.",
"weight": 2
},
{
"name": "emotion",
"description": "Evaluate the presence and depth of sadness or negative emotional tone, including expressions of disappointment, frustration, or sorrow. A higher score reflects greater sadness.",
"weight": 1
}
]
# Update project with custom criteria
client.update_project(
retrieval_criteria=retrieval_criteria
)
```
## Using Criteria Retrieval
After setting up your criteria, you can use them to filter and retrieve memories. Here's an example:
```python
# Add some example memories
messages = [
{"role": "user", "content": "What a beautiful sunny day! I feel so refreshed and ready to take on anything!"},
{"role": "user", "content": "I've always wondered how storms form—what triggers them in the atmosphere?"},
{"role": "user", "content": "It's been raining for days, and it just makes everything feel heavier."},
{"role": "user", "content": "Finally I get time to draw something today, after a long time!! I am super happy today."}
]
client.add(messages, user_id="alice")
# Search with criteria-based filtering
filters = {
"AND": [
{"user_id": "alice"}
]
}
results_with_criteria = client.search(
query="Why I am feeling happy today?",
filters=filters,
version="v2"
)
# Standard search without criteria filtering
results_without_criteria = client.search(
query="Why I am feeling happy today?",
user_id="alice"
)
```
## Search Results Comparison
Let's compare the results from criteria-based retrieval versus standard retrieval to see how the emotional criteria affects ranking:
### Search Results (with Criteria)
```python
[
{
"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day",
"score": 0.666,
...
},
{
"memory": "User finally has time to draw something after a long time",
"score": 0.616,
...
},
{
"memory": "User is happy today",
"score": 0.500,
...
},
{
"memory": "User is curious about how storms form and what triggers them in the atmosphere.",
"score": 0.400,
...
},
{
"memory": "It has been raining for days, making everything feel heavier.",
"score": 0.116,
...
}
]
```
### Search Results (without Criteria)
```python
[
{
"memory": "User is happy today",
"score": 0.607,
...
},
{
"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day",
"score": 0.512,
...
},
{
"memory": "It has been raining for days, making everything feel heavier.",
"score": 0.4617,
...
},
{
"memory": "User is curious about how storms form and what triggers them in the atmosphere.",
"score": 0.340,
...
},
{
"memory": "User finally has time to draw something after a long time",
"score": 0.336,
...
}
]
```
Looking at the example results above, we can see how criteria-based filtering affects the output:
1. **Memory Ordering**: With criteria, memories with high joy scores (like feeling refreshed and drawing) are ranked higher, while without criteria, the most relevant memory ("User is happy today") comes first.
2. **Score Distribution**: With criteria, scores are more spread out (0.116 to 0.666) and reflect the criteria weights, while without criteria, scores are more clustered (0.336 to 0.607) and based purely on relevance.
3. **Negative Content**: With criteria, the negative memory about rain has a much lower score (0.116) due to the emotion criteria, while without criteria it maintains a relatively high score (0.4617) due to its relevance.
4. **Curiosity Content**: The storm-related memory gets a moderate score (0.400) with criteria due to the curiosity weighting, while without criteria it's ranked lower (0.340) as it's less relevant to the happiness query.
## Key Differences
1. **Scoring**: With criteria, normalized scores (0-1) are used based on custom criteria weights, while without criteria, standard relevance scoring is used
2. **Ordering**: With criteria, memories are first retrieved by relevance, then criteria-based filtering and prioritization is applied, while without criteria, ordering is solely by relevance
3. **Filtering**: With criteria, post-retrieval filtering based on custom criteria (joy, curiosity, etc.) is available, which isn't available without criteria
<Note>
When no custom criteria are specified, the search will default to standard relevance-based retrieval. In this case, results are returned based solely on their relevance to the query, without any additional filtering or prioritization that would normally be applied through criteria.
</Note>
## How It Works
1. **Criteria Definition**: Define custom criteria with names, descriptions, and weights
2. **Project Configuration**: Apply these criteria at the project level
3. **Memory Retrieval**: Use v2 search with filters to retrieve memories based on your criteria
4. **Weighted Scoring**: Memories are scored based on the defined criteria weights
<Note>
Criteria retrieval is currently supported only in search v2. Make sure to use `version="v2"` when performing searches with custom criteria.
</Note>
If you have any questions, please feel free to reach out to us using one of the following methods:
<Snippet file="get-help.mdx" />
+108
View File
@@ -0,0 +1,108 @@
---
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.
## OpenMemory Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store.
We suggest you follow the instructions [here](/openmemory/quickstart#setting-up-openmemory) to set up OpenMemory on your local machine, with more persistant memory store.
## 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.
+69
View File
@@ -0,0 +1,69 @@
---
title: Quickstart
icon: "terminal"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
## OpenMemory Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store.
We suggest you follow the instructions below to set up OpenMemory on your local machine, with more persistant memory store.
## 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",
@@ -0,0 +1,235 @@
import asyncio
import warnings
from google.adk.agents import Agent
from google.adk.sessions import InMemorySessionService
from google.adk.runners import Runner
from google.genai import types
from mem0 import MemoryClient
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Initialize Mem0 client
mem0_client = MemoryClient()
# Define Memory Tools
def save_patient_info(information: str) -> dict:
"""Saves important patient information to memory."""
print(f"Storing patient information: {information[:30]}...")
# Get user_id from session state or use default
user_id = getattr(save_patient_info, 'user_id', 'default_user')
# Store in Mem0
response = mem0_client.add(
[{"role": "user", "content": information}],
user_id=user_id,
run_id="healthcare_session",
metadata={"type": "patient_information"}
)
return {"status": "success", "message": "Information saved"}
def retrieve_patient_info(query: str) -> str:
"""Retrieves relevant patient information from memory."""
print(f"Searching for patient information: {query}")
# Get user_id from session state or use default
user_id = getattr(retrieve_patient_info, 'user_id', 'default_user')
# Search Mem0
results = mem0_client.search(
query,
user_id=user_id,
run_id="healthcare_session",
limit=5,
threshold=0.7 # Higher threshold for more relevant results
)
if not results:
return "I don't have any relevant memories about this topic."
memories = [f"• {result['memory']}" for result in results]
return "Here's what I remember that might be relevant:\n" + "\n".join(memories)
# Define Healthcare Tools
def schedule_appointment(date: str, time: str, reason: str) -> dict:
"""Schedules a doctor's appointment."""
# In a real app, this would connect to a scheduling system
appointment_id = f"APT-{hash(date + time) % 10000}"
return {
"status": "success",
"appointment_id": appointment_id,
"confirmation": f"Appointment scheduled for {date} at {time} for {reason}",
"message": "Please arrive 15 minutes early to complete paperwork."
}
# Create the Healthcare Assistant Agent
healthcare_agent = Agent(
name="healthcare_assistant",
model="gemini-1.5-flash", # Using Gemini for healthcare assistant
description="Healthcare assistant that helps patients with health information and appointment scheduling.",
instruction="""You are a helpful Healthcare Assistant with memory capabilities.
Your primary responsibilities are to:
1. Remember patient information using the 'save_patient_info' tool when they share symptoms, conditions, or preferences.
2. Retrieve past patient information using the 'retrieve_patient_info' tool when relevant to the current conversation.
3. Help schedule appointments using the 'schedule_appointment' tool.
IMPORTANT GUIDELINES:
- Always be empathetic, professional, and helpful.
- Save important patient information like symptoms, conditions, allergies, and preferences.
- Check if you have relevant patient information before asking for details they may have shared previously.
- Make it clear you are not a doctor and cannot provide medical diagnosis or treatment.
- For serious symptoms, always recommend consulting a healthcare professional.
- Keep all patient information confidential.
""",
tools=[save_patient_info, retrieve_patient_info, schedule_appointment]
)
# Set Up Session and Runner
session_service = InMemorySessionService()
# Define constants for the conversation
APP_NAME = "healthcare_assistant_app"
USER_ID = "Alex"
SESSION_ID = "session_001"
# Create a session
session = session_service.create_session(
app_name=APP_NAME,
user_id=USER_ID,
session_id=SESSION_ID
)
# Create the runner
runner = Runner(
agent=healthcare_agent,
app_name=APP_NAME,
session_service=session_service
)
# Interact with the Healthcare Assistant
async def call_agent_async(query, runner, user_id, session_id):
"""Sends a query to the agent and returns the final response."""
print(f"\n>>> Patient: {query}")
# Format the user's message
content = types.Content(
role='user',
parts=[types.Part(text=query)]
)
# Set user_id for tools to access
save_patient_info.user_id = user_id
retrieve_patient_info.user_id = user_id
# Run the agent
async for event in runner.run_async(
user_id=user_id,
session_id=session_id,
new_message=content
):
if event.is_final_response():
if event.content and event.content.parts:
response = event.content.parts[0].text
print(f"<<< Assistant: {response}")
return response
return "No response received."
# Example conversation flow
async def run_conversation():
# First interaction - patient introduces themselves with key information
await call_agent_async(
"Hi, I'm Alex. I've been having headaches for the past week, and I have a penicillin allergy.",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Request for health information
await call_agent_async(
"Can you tell me more about what might be causing my headaches?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Schedule an appointment
await call_agent_async(
"I think I should see a doctor. Can you help me schedule an appointment for next Monday at 2pm?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Test memory - should remember patient name, symptoms, and allergy
await call_agent_async(
"What medications should I avoid for my headaches?",
runner=runner,
user_id=USER_ID,
session_id=SESSION_ID
)
# Interactive mode
async def interactive_mode():
"""Run an interactive chat session with the healthcare assistant."""
print("=== Healthcare Assistant Interactive Mode ===")
print("Enter 'exit' to quit at any time.")
# Get user information
patient_id = input("Enter patient ID (or press Enter for default): ").strip() or USER_ID
session_id = f"session_{hash(patient_id) % 1000:03d}"
# Create session for this user
session = session_service.create_session(
app_name=APP_NAME,
user_id=patient_id,
session_id=session_id
)
print(f"\nStarting conversation with patient ID: {patient_id}")
print("Type your message and press Enter.")
while True:
user_input = input("\n>>> Patient: ").strip()
if user_input.lower() in ['exit', 'quit', 'bye']:
print("Ending conversation. Thank you!")
break
await call_agent_async(
user_input,
runner=runner,
user_id=patient_id,
session_id=session_id
)
# Main execution
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Healthcare Assistant with Memory')
parser.add_argument('--demo', action='store_true', help='Run the demo conversation')
parser.add_argument('--interactive', action='store_true', help='Run in interactive mode')
parser.add_argument('--patient-id', type=str, default=USER_ID, help='Patient ID for the conversation')
args = parser.parse_args()
if args.demo:
asyncio.run(run_conversation())
elif args.interactive:
asyncio.run(interactive_mode())
else:
# Default to demo mode if no arguments provided
asyncio.run(run_conversation())
+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."""
+1
View File
@@ -10,6 +10,7 @@ class Neo4jConfig(BaseModel):
username: Optional[str] = Field(None, description="Username for the graph database")
password: Optional[str] = Field(None, description="Password for the graph database")
database: Optional[str] = Field(None, description="Database for the graph database")
base_label: Optional[bool] = Field(None, description="Whether to use base node label __Entity__ for all entities")
@model_validator(mode="before")
def check_host_port_or_path(cls, values):
+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)
+83 -37
View File
@@ -35,11 +35,25 @@ class MemoryGraph:
self.config.graph_store.config.password,
self.config.graph_store.config.database,
refresh_schema=False,
driver_config={"notifications_min_severity":"OFF"},
driver_config={"notifications_min_severity": "OFF"},
)
self.embedding_model = EmbedderFactory.create(
self.config.embedder.provider, self.config.embedder.config, self.config.vector_store.config
)
self.node_label = ":`__Entity__`" if self.config.graph_store.config.base_label else ""
if self.config.graph_store.config.base_label:
# Safely add user_id index
try:
self.graph.query(f"CREATE INDEX entity_single IF NOT EXISTS FOR (n {self.node_label}) ON (n.user_id)")
except Exception:
pass
try: # Safely try to add composite index (Enterprise only)
self.graph.query(
f"CREATE INDEX entity_composite IF NOT EXISTS FOR (n {self.node_label}) ON (n.name, n.user_id)"
)
except Exception:
pass
self.llm_provider = "openai_structured"
if self.config.llm.provider:
@@ -108,8 +122,8 @@ class MemoryGraph:
return search_results
def delete_all(self, filters):
cypher = """
MATCH (n {user_id: $user_id})
cypher = f"""
MATCH (n {self.node_label} {{user_id: $user_id}})
DETACH DELETE n
"""
params = {"user_id": filters["user_id"]}
@@ -127,10 +141,9 @@ class MemoryGraph:
- 'contexts': The base data store response for each memory.
- 'entities': A list of strings representing the nodes and relationships
"""
# return all nodes and relationships
query = """
MATCH (n {user_id: $user_id})-[r]->(m {user_id: $user_id})
query = f"""
MATCH (n {self.node_label} {{user_id: $user_id}})-[r]->(m {self.node_label} {{user_id: $user_id}})
RETURN n.name AS source, type(r) AS relationship, m.name AS target
LIMIT $limit
"""
@@ -224,22 +237,21 @@ class MemoryGraph:
def _search_graph_db(self, node_list, filters, limit=100):
"""Search similar nodes among and their respective incoming and outgoing relations."""
result_relations = []
for node in node_list:
n_embedding = self.embedding_model.embed(node)
cypher_query = """
MATCH (n)
cypher_query = f"""
MATCH (n {self.node_label})
WHERE n.embedding IS NOT NULL AND n.user_id = $user_id
WITH n, round(2 * vector.similarity.cosine(n.embedding, $n_embedding) - 1, 4) AS similarity // denormalize for backward compatibility
WHERE similarity >= $threshold
CALL (n) {
CALL (n) {{
MATCH (n)-[r]->(m)
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id
UNION
MATCH (m)-[r]->(n)
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id
}
}}
WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity //deduplicate
RETURN source, source_id, relationship, relation_id, destination, destination_id, similarity
ORDER BY similarity DESC
@@ -294,9 +306,9 @@ class MemoryGraph:
# Delete the specific relationship between nodes
cypher = f"""
MATCH (n {{name: $source_name, user_id: $user_id}})
MATCH (n {self.node_label} {{name: $source_name, user_id: $user_id}})
-[r:{relationship}]->
(m {{name: $dest_name, user_id: $user_id}})
(m {self.node_label} {{name: $dest_name, user_id: $user_id}})
DELETE r
RETURN
n.name AS source,
@@ -323,7 +335,11 @@ class MemoryGraph:
# types
source_type = entity_type_map.get(source, "__User__")
source_label = self.node_label if self.node_label else f":`{source_type}`"
source_extra_set = f", source:`{source_type}`" if self.node_label else ""
destination_type = entity_type_map.get(destination, "__User__")
destination_label = self.node_label if self.node_label else f":`{destination_type}`"
destination_extra_set = f", destination:`{destination_type}`" if self.node_label else ""
# embeddings
source_embedding = self.embedding_model.embed(source)
@@ -338,15 +354,24 @@ class MemoryGraph:
cypher = f"""
MATCH (source)
WHERE elementId(source) = $source_id
MERGE (destination:{destination_type} {{name: $destination_name, user_id: $user_id}})
SET source.mentions = coalesce(source.mentions, 0) + 1
WITH source
MERGE (destination {destination_label} {{name: $destination_name, user_id: $user_id}})
ON CREATE SET
destination.created = timestamp()
destination.created = timestamp(),
destination.mentions = 1
{destination_extra_set}
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 +385,24 @@ class MemoryGraph:
cypher = f"""
MATCH (destination)
WHERE elementId(destination) = $destination_id
MERGE (source:{source_type} {{name: $source_name, user_id: $user_id}})
SET destination.mentions = coalesce(destination.mentions, 0) + 1
WITH destination
MERGE (source {source_label} {{name: $source_name, user_id: $user_id}})
ON CREATE SET
source.created = timestamp()
source.created = timestamp(),
source.mentions = 1
{source_extra_set}
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 +416,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
@@ -399,19 +438,26 @@ class MemoryGraph:
}
else:
cypher = f"""
MERGE (n:{source_type} {{name: $source_name, user_id: $user_id}})
ON CREATE SET n.created = timestamp()
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()
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()
RETURN n.name AS source, type(rel) AS relationship, m.name AS target
MERGE (source {source_label} {{name: $source_name, user_id: $user_id}})
ON CREATE SET source.created = timestamp(),
source.mentions = 1
{source_extra_set}
ON MATCH SET source.mentions = coalesce(source.mentions, 0) + 1
WITH source
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
WITH source
MERGE (destination {destination_label} {{name: $dest_name, user_id: $user_id}})
ON CREATE SET destination.created = timestamp(),
destination.mentions = 1
{destination_extra_set}
ON MATCH SET destination.mentions = coalesce(destination.mentions, 0) + 1
WITH source, destination
CALL db.create.setNodeVectorProperty(destination, 'embedding', $source_embedding)
WITH source, destination
MERGE (source)-[rel:{relationship}]->(destination)
ON CREATE SET rel.created = timestamp(), rel.mentions = 1
ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1
RETURN source.name AS source, type(rel) AS relationship, destination.name AS target
"""
params = {
"source_name": source,
@@ -432,8 +478,8 @@ class MemoryGraph:
return entity_list
def _search_source_node(self, source_embedding, user_id, threshold=0.9):
cypher = """
MATCH (source_candidate)
cypher = f"""
MATCH (source_candidate {self.node_label})
WHERE source_candidate.embedding IS NOT NULL
AND source_candidate.user_id = $user_id
@@ -458,8 +504,8 @@ class MemoryGraph:
return result
def _search_destination_node(self, destination_embedding, user_id, threshold=0.9):
cypher = """
MATCH (destination_candidate)
cypher = f"""
MATCH (destination_candidate {self.node_label})
WHERE destination_candidate.embedding IS NOT NULL
AND destination_candidate.user_id = $user_id
+715 -480
View File
File diff suppressed because it is too large Load Diff
+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
+132 -116
View File
@@ -1,144 +1,160 @@
import sqlite3
import threading
import uuid
import logging
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class SQLiteManager:
def __init__(self, db_path=":memory:"):
self.connection = sqlite3.connect(db_path, check_same_thread=False)
def __init__(self, db_path: str = ":memory:"):
self.db_path = db_path
self.connection = sqlite3.connect(self.db_path, check_same_thread=False)
self._lock = threading.Lock()
self._migrate_history_table()
self._create_history_table()
def _migrate_history_table(self):
with self._lock:
with self.connection:
cursor = self.connection.cursor()
def _migrate_history_table(self) -> None:
"""
If a pre-existing history table had the old group-chat columns,
rename it, create the new schema, copy the intersecting data, then
drop the old table.
"""
with self._lock, self.connection:
cur = self.connection.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='history'"
)
if cur.fetchone() is None:
return # nothing to migrate
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='history'")
table_exists = cursor.fetchone() is not None
cur.execute("PRAGMA table_info(history)")
old_cols = {row[1] for row in cur.fetchall()}
if table_exists:
# Get the current schema of the history table
cursor.execute("PRAGMA table_info(history)")
current_schema = {row[1]: row[2] for row in cursor.fetchall()}
expected_cols = {
"id",
"memory_id",
"old_memory",
"new_memory",
"event",
"created_at",
"updated_at",
"is_deleted",
"actor_id",
"role",
}
# Define the expected schema
expected_schema = {
"id": "TEXT",
"memory_id": "TEXT",
"old_memory": "TEXT",
"new_memory": "TEXT",
"new_value": "TEXT",
"event": "TEXT",
"created_at": "DATETIME",
"updated_at": "DATETIME",
"is_deleted": "INTEGER",
}
if old_cols == expected_cols:
return
# Check if the schemas are the same
if current_schema != expected_schema:
# Rename the old table
cursor.execute("ALTER TABLE history RENAME TO old_history")
logger.info("Migrating history table to new schema (no convo columns).")
cur.execute("ALTER TABLE history RENAME TO history_old")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
memory_id TEXT,
old_memory TEXT,
new_memory TEXT,
new_value TEXT,
event TEXT,
created_at DATETIME,
updated_at DATETIME,
is_deleted INTEGER
)
"""
)
self._create_history_table()
# Copy data from the old table to the new table
cursor.execute(
"""
INSERT INTO history (id, memory_id, old_memory, new_memory, new_value, event, created_at, updated_at, is_deleted)
SELECT id, memory_id, prev_value, new_value, new_value, event, timestamp, timestamp, is_deleted
FROM old_history
""" # noqa: E501
)
intersecting = list(expected_cols & old_cols)
cols_csv = ", ".join(intersecting)
cur.execute(
f"INSERT INTO history ({cols_csv}) SELECT {cols_csv} FROM history_old"
)
cur.execute("DROP TABLE history_old")
cursor.execute("DROP TABLE old_history")
self.connection.commit()
def _create_history_table(self):
with self._lock:
with self.connection:
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
memory_id TEXT,
old_memory TEXT,
new_memory TEXT,
new_value TEXT,
event TEXT,
created_at DATETIME,
updated_at DATETIME,
is_deleted INTEGER
)
def _create_history_table(self) -> None:
with self._lock, self.connection:
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
memory_id TEXT,
old_memory TEXT,
new_memory TEXT,
event TEXT,
created_at DATETIME,
updated_at DATETIME,
is_deleted INTEGER,
actor_id TEXT,
role TEXT
)
"""
)
def add_history(
self,
memory_id,
old_memory,
new_memory,
event,
created_at=None,
updated_at=None,
is_deleted=0,
):
with self._lock:
with self.connection:
self.connection.execute(
"""
INSERT INTO history (id, memory_id, old_memory, new_memory, event, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
memory_id,
old_memory,
new_memory,
event,
created_at,
updated_at,
is_deleted,
),
)
def get_history(self, memory_id):
with self._lock:
cursor = self.connection.execute(
memory_id: str,
old_memory: Optional[str],
new_memory: Optional[str],
event: str,
*,
created_at: Optional[str] = None,
updated_at: Optional[str] = None,
is_deleted: int = 0,
actor_id: Optional[str] = None,
role: Optional[str] = None,
) -> None:
with self._lock, self.connection:
self.connection.execute(
"""
SELECT id, memory_id, old_memory, new_memory, event, created_at, updated_at
INSERT INTO history (
id, memory_id, old_memory, new_memory, event,
created_at, updated_at, is_deleted, actor_id, role
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
memory_id,
old_memory,
new_memory,
event,
created_at,
updated_at,
is_deleted,
actor_id,
role,
),
)
def get_history(self, memory_id: str) -> List[Dict[str, Any]]:
with self._lock:
cur = self.connection.execute(
"""
SELECT id, memory_id, old_memory, new_memory, event,
created_at, updated_at, is_deleted, actor_id, role
FROM history
WHERE memory_id = ?
ORDER BY updated_at ASC
ORDER BY created_at ASC, DATETIME(updated_at) ASC
""",
(memory_id,),
)
rows = cursor.fetchall()
return [
{
"id": row[0],
"memory_id": row[1],
"old_memory": row[2],
"new_memory": row[3],
"event": row[4],
"created_at": row[5],
"updated_at": row[6],
}
for row in rows
]
rows = cur.fetchall()
return [
{
"id": r[0],
"memory_id": r[1],
"old_memory": r[2],
"new_memory": r[3],
"event": r[4],
"created_at": r[5],
"updated_at": r[6],
"is_deleted": bool(r[7]),
"actor_id": r[8],
"role": r[9],
}
for r in rows
]
def reset(self) -> None:
"""Drop and recreate the history table."""
with self._lock, self.connection:
self.connection.execute("DROP TABLE IF EXISTS history")
self._create_history_table()
def close(self) -> None:
if self.connection:
self.connection.close()
self.connection = None
def __del__(self):
self.close()
+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)
-1
View File
@@ -143,7 +143,6 @@ class Langchain(VectorStoreBase):
elif hasattr(self.client, "reset_collection"):
self.client.reset_collection()
else:
# Fallback to the generic delete method
self.client.delete(ids=None)
def col_info(self):
+194 -64
View File
@@ -1,9 +1,9 @@
import logging
from typing import Any, Dict, List, Optional
import time
try:
from opensearchpy import OpenSearch, RequestsHttpConnection
from opensearchpy.helpers import bulk
except ImportError:
raise ImportError("OpenSearch requires extra dependencies. Install with `pip install opensearch-py`") from None
@@ -34,28 +34,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 +69,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 +89,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 +117,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 +286,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
+74
View File
@@ -0,0 +1,74 @@
# 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)
## Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
## 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.
+23
View File
@@ -0,0 +1,23 @@
# Ignore all .env files
**/.env
**/.env.*
# Ignore all database files
**/*.db
**/*.sqlite
**/*.sqlite3
# Ignore logs
**/*.log
# Ignore runtime data
**/node_modules
**/__pycache__
**/.pytest_cache
**/.coverage
**/coverage
# Ignore Docker runtime files
**/.dockerignore
**/Dockerfile
**/docker-compose*.yml
+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()
+392
View File
@@ -0,0 +1,392 @@
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 a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevent information whcih can be useful in the future conversation. This can also be called when the user asks you to remember something.")
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 through stored memories. This method is called EVERYTIME the user asks anything.")
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))]
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)
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,
)
# Process search results
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_get_message(request: Request):
return await handle_post_message(request)
@mcp_router.post("/{client_name}/sse/{user_id}/messages/")
async def handle_post_message(request: Request):
return await handle_post_message(request)
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):
return {}
# 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"}
+665
View File
@@ -0,0 +1,665 @@
from datetime import datetime, UTC
from typing import List, Optional, Set
from uuid import UUID, uuid4
import logging
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.utils.memory import get_memory_client
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
memory_client = get_memory_client()
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.")
# Log what we're about to do
logging.info(f"Creating memory for user_id: {request.user_id} with app: {request.app}")
# Save to Qdrant via memory_client
qdrant_response = memory_client.add(
request.text,
user_id=request.user_id, # Use string user_id to match search
metadata={
"source_app": "openmemory",
"mcp_client": request.app,
}
)
# Log the response for debugging
logging.info(f"Qdrant response: {qdrant_response}")
# Process Qdrant response
if isinstance(qdrant_response, dict) and 'results' in qdrant_response:
for result in qdrant_response['results']:
if result['event'] == 'ADD':
# Get the Qdrant-generated ID
memory_id = UUID(result['id'])
# Check if memory already exists
existing_memory = db.query(Memory).filter(Memory.id == memory_id).first()
if existing_memory:
# Update existing memory
existing_memory.state = MemoryState.active
existing_memory.content = result['memory']
memory = existing_memory
else:
# Create memory with the EXACT SAME ID from Qdrant
memory = Memory(
id=memory_id, # Use the same ID that Qdrant generated
user_id=user.id,
app_id=app_obj.id,
content=result['memory'],
metadata_=request.metadata,
state=MemoryState.active
)
db.add(memory)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted if existing_memory else MemoryState.deleted,
new_state=MemoryState.active
)
db.add(history)
db.commit()
db.refresh(memory)
return memory
# Fallback to traditional DB-only approach if Qdrant integration fails
# Generate a random UUID for the memory
memory_id = uuid4()
memory = Memory(
id=memory_id,
user_id=user.id,
app_id=app_obj.id,
content=request.text,
metadata_=request.metadata
)
db.add(memory)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted,
new_state=MemoryState.active
)
db.add(history)
db.commit()
db.refresh(memory)
# Attempt to add to Qdrant with the same ID we just created
try:
# Try to add with our specific ID
memory_client.add(
request.text,
memory_id=str(memory_id), # Specify the ID
user_id=request.user_id,
metadata={
"source_app": "openmemory",
"mcp_client": request.app,
}
)
except Exception as e:
logging.error(f"Failed to add to Qdrant in fallback path: {e}")
# Continue anyway, the DB record is created
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:
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
set -e
echo "🚀 Starting OpenMemory installation..."
# Set environment variables
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
USER="${USER:-$(whoami)}"
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8765}"
if [ -z "$OPENAI_API_KEY" ]; then
echo "❌ OPENAI_API_KEY not set. Please run with: curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash"
echo "❌ OPENAI_API_KEY not set. You can also set it as global environment variable: export OPENAI_API_KEY=your_api_key"
exit 1
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "❌ Docker not found. Please install Docker first."
exit 1
fi
# Check if docker compose is available
if ! docker compose version &> /dev/null; then
echo "❌ Docker Compose not found. Please install Docker Compose V2."
exit 1
fi
# Check if the container "mem0_ui" already exists and remove it if necessary
if [ $(docker ps -aq -f name=mem0_ui) ]; then
echo "⚠️ Found existing container 'mem0_ui'. Removing it..."
docker rm -f mem0_ui
fi
# Find an available port starting from 3000
echo "🔍 Looking for available port for frontend..."
for port in {3000..3010}; do
if ! lsof -i:$port >/dev/null 2>&1; then
FRONTEND_PORT=$port
break
fi
done
if [ -z "$FRONTEND_PORT" ]; then
echo "❌ Could not find an available port between 3000 and 3010"
exit 1
fi
# Export required variables for Compose and frontend
export OPENAI_API_KEY
export USER
export NEXT_PUBLIC_API_URL
export NEXT_PUBLIC_USER_ID="$USER"
export FRONTEND_PORT
# Create docker-compose.yml file
echo "📝 Creating docker-compose.yml..."
cat > docker-compose.yml <<EOF
services:
mem0_store:
image: qdrant/qdrant
ports:
- "6333:6333"
volumes:
- mem0_storage:/mem0/storage
openmemory-mcp:
image: mem0/openmemory-mcp:latest
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- USER=${USER}
- QDRANT_HOST=mem0_store
- QDRANT_PORT=6333
depends_on:
- mem0_store
ports:
- "8765:8765"
volumes:
mem0_storage:
EOF
# Start services
echo "🚀 Starting backend services..."
docker compose up -d
# Start the frontend
echo "🚀 Starting frontend on port $FRONTEND_PORT..."
docker run -d \
--name mem0_ui \
-p ${FRONTEND_PORT}:3000 \
-e NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
-e NEXT_PUBLIC_USER_ID="$USER" \
mem0/openmemory-ui:latest
echo "✅ Backend: http://localhost:8765"
echo "✅ Frontend: http://localhost:$FRONTEND_PORT"
# Open the frontend URL in the default web browser
echo "🌐 Opening frontend in the default browser..."
URL="http://localhost:$FRONTEND_PORT"
if command -v xdg-open > /dev/null; then
xdg-open "$URL" # Linux
elif command -v open > /dev/null; then
open "$URL" # macOS
elif command -v start > /dev/null; then
start "$URL" # Windows (if run via Git Bash or similar)
else
echo "⚠️ Could not detect a method to open the browser. Please open $URL manually."
fi
+23
View File
@@ -0,0 +1,23 @@
# Ignore all .env files
**/.env
# Ignore all database files
**/*.db
**/*.sqlite
**/*.sqlite3
# Ignore logs
**/*.log
# Ignore runtime data
**/node_modules
**/__pycache__
**/.pytest_cache
**/.coverage
**/coverage
# Ignore Docker runtime files
**/.dockerignore
**/Dockerfile
**/docker-compose*.yml
+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>
);
}

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