+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ Just as a file system turns raw bytes into organized data, memU transforms raw i
## ⭐️ Star the repository
-
+
If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly appreciated.
---
@@ -95,10 +95,14 @@ If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly ap
## 🔄 How Proactive Memory Works
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -275,18 +279,17 @@ For enterprise deployment with custom proactive workflows, contact **info@nevami
#### Installation
```bash
-pip install -e .
+pip install memu-py
```
#### Basic Example
-> **Requirements**: Python 3.13+ and an OpenAI API key
+> **Requirements**: Python 3.12+ and an OpenAI API key
**Test Continuous Learning** (in-memory):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**Test with Persistent Storage** (PostgreSQL):
@@ -301,9 +304,10 @@ docker run -d \
pgvector/pgvector:pg16
# Run continuous learning test
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
Both examples demonstrate **proactive memory workflows**:
@@ -311,9 +315,9 @@ Both examples demonstrate **proactive memory workflows**:
2. **Auto-Extraction**: Immediate memory creation
3. **Proactive Retrieval**: Context-aware memory surfacing
-See [`tests/test_inmemory.py`](../tests/test_inmemory.py) and [`tests/test_postgres.py`](../tests/test_postgres.py) for implementation details.
-
----
+See [`tests/test_inmemory.py`](../tests/test_inmemory.py), [`tests/test_sqlite.py`](../tests/test_sqlite.py),
+and [`tests/test_postgres.py`](../tests/test_postgres.py) for implementation details. The in-memory and SQLite
+live LLM checks are opt-in with `MEMU_RUN_LIVE_LLM_TESTS=1`.
### Custom LLM and Embedding Providers
@@ -326,14 +330,14 @@ service = MemUService(
# Default profile for LLM operations
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" or "http"
+ "client_backend": "sdk" # "sdk", "httpx", or "lazyllm_backend"
},
# Separate profile for embeddings
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -341,6 +345,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### OpenRouter Integration
@@ -357,7 +374,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # Any OpenRouter model
"embed_model": "openai/text-embedding-3-small", # Embedding model
},
@@ -385,15 +402,10 @@ service = MemoryService(
#### Running OpenRouter Tests
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# Full workflow test (memorize + retrieve)
-python tests/test_openrouter.py
-
-# Embedding-specific tests
-python tests/test_openrouter_embedding.py
-
-# Vision-specific tests
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
See [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py) for a complete working example.
@@ -464,8 +476,8 @@ Deep **anticipatory reasoning** for complex contexts:
# Proactive retrieval with context history
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "What are their preferences?"}},
- {"role": "user", "content": {"text": "Tell me about work habits"}}
+ {"role": "user", "content": "What are their preferences?"},
+ {"role": "user", "content": "Tell me about work habits"}
],
where={"user_id": "123"}, # Optional: scope filter
method="rag" # or "llm" for deeper reasoning
@@ -480,12 +492,14 @@ result = await service.retrieve(
}
```
+For a single user query, Python callers can also pass `queries=["What are their preferences?"]`; MemU normalizes it to a user message before retrieval.
+
**Proactive Filtering**: Use `where` to scope continuous monitoring:
- `where={"user_id": "123"}` - User-specific context
- `where={"agent_id__in": ["1", "2"]}` - Multi-agent coordination
- Omit `where` for global context awareness
-> 📚 **For complete API documentation**, see [SERVICE_API.md](../docs/SERVICE_API.md) - includes proactive workflow patterns, pipeline configuration, and real-time update handling.
+> 📚 **For complete API documentation**, see [memu.pro/docs](https://memu.pro/docs) - includes proactive workflow patterns, pipeline configuration, and real-time update handling.
---
@@ -559,7 +573,7 @@ View detailed experimental data: [memU-experiment](https://github.com/NevaMind-A
| Repository | Description | Proactive Features |
|------------|-------------|-------------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | Core proactive memory engine | 7×24 learning pipeline, auto-categorization |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | Core proactive memory engine | 7×24 learning pipeline, auto-categorization |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend with continuous sync | Real-time memory updates, webhook triggers |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Visual memory dashboard | Live memory evolution monitoring |
@@ -596,7 +610,7 @@ We welcome contributions from the community! Whether you're fixing bugs, adding
To start contributing to MemU, you'll need to set up your development environment:
#### Prerequisites
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv) (Python package manager)
- Git
@@ -649,7 +663,7 @@ For detailed contribution guidelines, code standards, and development practices,
## 🌍 Community
-- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**: [Join the community](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Follow @memU_ai](https://x.com/memU_ai)
- **Contact**: info@nevamind.ai
diff --git a/readme/README_es.md b/readme/README_es.md
index a0edff7d..2d300873 100644
--- a/readme/README_es.md
+++ b/readme/README_es.md
@@ -8,7 +8,7 @@
[](https://badge.fury.io/py/memu-py)
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](https://discord.gg/memu)
[](https://x.com/memU_ai)
@@ -28,7 +28,7 @@ memU **captura y comprende continuamente la intención del usuario**. Incluso si
## 🤖 [OpenClaw (Moltbot, Clawdbot) Alternative](https://memu.bot)
-
+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ Así como un sistema de archivos convierte bytes crudos en datos organizados, me
## ⭐️ Dale una estrella al repositorio
-
+
Si encuentras memU útil o interesante, te agradeceríamos mucho una estrella en GitHub ⭐️.
---
@@ -95,10 +95,14 @@ Si encuentras memU útil o interesante, te agradeceríamos mucho una estrella en
## 🔄 Cómo Funciona la Memoria Proactiva
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -275,18 +279,17 @@ Para despliegue empresarial con flujos de trabajo proactivos personalizados, con
#### Instalación
```bash
-pip install -e .
+pip install memu-py
```
#### Ejemplo Básico
-> **Requisitos**: Python 3.13+ y una clave API de OpenAI
+> **Requisitos**: Python 3.12+ y una clave API de OpenAI
**Probar Aprendizaje Continuo** (en memoria):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**Probar con Almacenamiento Persistente** (PostgreSQL):
@@ -301,9 +304,10 @@ docker run -d \
pgvector/pgvector:pg16
# Ejecutar prueba de aprendizaje continuo
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
Ambos ejemplos demuestran **flujos de trabajo de memoria proactiva**:
@@ -311,7 +315,9 @@ Ambos ejemplos demuestran **flujos de trabajo de memoria proactiva**:
2. **Auto-Extracción**: Creación inmediata de memoria
3. **Recuperación Proactiva**: Presentación de memoria consciente del contexto
-Ver [`tests/test_inmemory.py`](../tests/test_inmemory.py) y [`tests/test_postgres.py`](../tests/test_postgres.py) para detalles de implementación.
+Ver [`tests/test_inmemory.py`](../tests/test_inmemory.py), [`tests/test_sqlite.py`](../tests/test_sqlite.py)
+y [`tests/test_postgres.py`](../tests/test_postgres.py) para detalles de implementación. Las pruebas live LLM
+de in-memory y SQLite son opt-in con `MEMU_RUN_LIVE_LLM_TESTS=1`.
---
@@ -326,14 +332,14 @@ service = MemUService(
# Perfil predeterminado para operaciones LLM
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" o "http"
+ "client_backend": "sdk" # "sdk", "httpx" o "lazyllm_backend"
},
# Perfil separado para embeddings
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -341,6 +347,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### Integración con OpenRouter
@@ -357,7 +376,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # Cualquier modelo de OpenRouter
"embed_model": "openai/text-embedding-3-small", # Modelo de embedding
},
@@ -385,15 +404,10 @@ service = MemoryService(
#### Ejecutar Pruebas de OpenRouter
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# Prueba de flujo completo (memorize + retrieve)
-python tests/test_openrouter.py
-
-# Pruebas específicas de embedding
-python tests/test_openrouter_embedding.py
-
-# Pruebas específicas de visión
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
Ver [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py) para un ejemplo completo funcional.
@@ -464,8 +478,8 @@ MemU soporta tanto **carga proactiva de contexto** como **consultas reactivas**:
# Recuperación proactiva con historial de contexto
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "¿Cuáles son sus preferencias?"}},
- {"role": "user", "content": {"text": "Cuéntame sobre los hábitos de trabajo"}}
+ {"role": "user", "content": "¿Cuáles son sus preferencias?"},
+ {"role": "user", "content": "Cuéntame sobre los hábitos de trabajo"}
],
where={"user_id": "123"}, # Opcional: filtro de alcance
method="rag" # o "llm" para razonamiento más profundo
@@ -485,7 +499,7 @@ result = await service.retrieve(
- `where={"agent_id__in": ["1", "2"]}` - Coordinación multi-agente
- Omitir `where` para conciencia de contexto global
-> 📚 **Para documentación completa de API**, ver [SERVICE_API.md](../docs/SERVICE_API.md) - incluye patrones de flujo de trabajo proactivo, configuración de pipeline y manejo de actualizaciones en tiempo real.
+> 📚 **Para documentación completa de API**, ver [memu.pro/docs](https://memu.pro/docs) - incluye patrones de flujo de trabajo proactivo, configuración de pipeline y manejo de actualizaciones en tiempo real.
---
@@ -559,7 +573,7 @@ Ver datos experimentales detallados: [memU-experiment](https://github.com/NevaMi
| Repositorio | Descripción | Características Proactivas |
|-------------|-------------|---------------------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | Motor principal de memoria proactiva | Pipeline de aprendizaje 7×24, auto-categorización |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | Motor principal de memoria proactiva | Pipeline de aprendizaje 7×24, auto-categorización |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend con sincronización continua | Actualizaciones de memoria en tiempo real, triggers de webhook |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Dashboard visual de memoria | Monitoreo de evolución de memoria en vivo |
@@ -596,7 +610,7 @@ Ver datos experimentales detallados: [memU-experiment](https://github.com/NevaMi
Para empezar a contribuir a MemU, necesitarás configurar tu entorno de desarrollo:
#### Prerrequisitos
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv) (gestor de paquetes Python)
- Git
@@ -649,7 +663,7 @@ Para guías detalladas de contribución, estándares de código y prácticas de
## 🌍 Comunidad
-- **GitHub Issues**: [Reportar bugs y solicitar características](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**: [Reportar bugs y solicitar características](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**: [Unirse a la comunidad](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Seguir @memU_ai](https://x.com/memU_ai)
- **Contacto**: info@nevamind.ai
diff --git a/readme/README_fr.md b/readme/README_fr.md
index fd1e1308..cf5a0b84 100644
--- a/readme/README_fr.md
+++ b/readme/README_fr.md
@@ -8,7 +8,7 @@
[](https://badge.fury.io/py/memu-py)
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](https://discord.gg/memu)
[](https://x.com/memU_ai)
@@ -28,7 +28,7 @@ memU **capture et comprend continuellement l'intention de l'utilisateur**. Même
## 🤖 [OpenClaw (Moltbot, Clawdbot) Alternative](https://memu.bot)
-
+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ Tout comme un système de fichiers transforme des octets bruts en données organ
## ⭐️ Mettez une étoile au dépôt
-
+
Si vous trouvez memU utile ou intéressant, une étoile GitHub ⭐️ serait grandement appréciée.
---
@@ -95,10 +95,14 @@ Si vous trouvez memU utile ou intéressant, une étoile GitHub ⭐️ serait gra
## 🔄 Comment Fonctionne la Mémoire Proactive
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -275,18 +279,17 @@ Pour un déploiement entreprise avec des workflows proactifs personnalisés, con
#### Installation
```bash
-pip install -e .
+pip install memu-py
```
#### Exemple de Base
-> **Prérequis**: Python 3.13+ et une clé API OpenAI
+> **Prérequis**: Python 3.12+ et une clé API OpenAI
**Tester l'Apprentissage Continu** (en mémoire):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**Tester avec Stockage Persistant** (PostgreSQL):
@@ -301,9 +304,10 @@ docker run -d \
pgvector/pgvector:pg16
# Exécuter le test d'apprentissage continu
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
Les deux exemples démontrent **les workflows de mémoire proactive**:
@@ -311,7 +315,9 @@ Les deux exemples démontrent **les workflows de mémoire proactive**:
2. **Auto-Extraction**: Création immédiate de mémoire
3. **Récupération Proactive**: Affichage de mémoire contextuel
-Voir [`tests/test_inmemory.py`](../tests/test_inmemory.py) et [`tests/test_postgres.py`](../tests/test_postgres.py) pour les détails d'implémentation.
+Voir [`tests/test_inmemory.py`](../tests/test_inmemory.py), [`tests/test_sqlite.py`](../tests/test_sqlite.py)
+et [`tests/test_postgres.py`](../tests/test_postgres.py) pour les détails d'implémentation. Les vérifications live LLM
+in-memory et SQLite sont opt-in avec `MEMU_RUN_LIVE_LLM_TESTS=1`.
---
@@ -326,14 +332,14 @@ service = MemUService(
# Profil par défaut pour les opérations LLM
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" ou "http"
+ "client_backend": "sdk" # "sdk", "httpx" ou "lazyllm_backend"
},
# Profil séparé pour les embeddings
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -341,6 +347,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### Intégration OpenRouter
@@ -357,7 +376,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # N'importe quel modèle OpenRouter
"embed_model": "openai/text-embedding-3-small", # Modèle d'embedding
},
@@ -385,15 +404,10 @@ service = MemoryService(
#### Exécuter les Tests OpenRouter
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# Test de workflow complet (memorize + retrieve)
-python tests/test_openrouter.py
-
-# Tests spécifiques aux embeddings
-python tests/test_openrouter_embedding.py
-
-# Tests spécifiques à la vision
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
Voir [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py) pour un exemple complet fonctionnel.
@@ -464,8 +478,8 @@ MemU supporte à la fois **le chargement proactif de contexte** et **les requêt
# Récupération proactive avec historique de contexte
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "Quelles sont leurs préférences?"}},
- {"role": "user", "content": {"text": "Parle-moi des habitudes de travail"}}
+ {"role": "user", "content": "Quelles sont leurs préférences?"},
+ {"role": "user", "content": "Parle-moi des habitudes de travail"}
],
where={"user_id": "123"}, # Optionnel: filtre de portée
method="rag" # ou "llm" pour raisonnement plus profond
@@ -485,7 +499,7 @@ result = await service.retrieve(
- `where={"agent_id__in": ["1", "2"]}` - Coordination multi-agent
- Omettre `where` pour conscience de contexte globale
-> 📚 **Pour la documentation API complète**, voir [SERVICE_API.md](../docs/SERVICE_API.md) - inclut les patterns de workflow proactif, configuration de pipeline et gestion des mises à jour en temps réel.
+> 📚 **Pour la documentation API complète**, voir [memu.pro/docs](https://memu.pro/docs) - inclut les patterns de workflow proactif, configuration de pipeline et gestion des mises à jour en temps réel.
---
@@ -559,7 +573,7 @@ Voir les données expérimentales détaillées: [memU-experiment](https://github
| Dépôt | Description | Fonctionnalités Proactives |
|-------|-------------|---------------------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | Moteur principal de mémoire proactive | Pipeline d'apprentissage 7×24, auto-catégorisation |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | Moteur principal de mémoire proactive | Pipeline d'apprentissage 7×24, auto-catégorisation |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend avec synchronisation continue | Mises à jour de mémoire en temps réel, déclencheurs webhook |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Dashboard visuel de mémoire | Surveillance de l'évolution de la mémoire en direct |
@@ -596,7 +610,7 @@ Nous accueillons les contributions de la communauté! Que vous corrigiez des bug
Pour commencer à contribuer à MemU, vous devrez configurer votre environnement de développement:
#### Prérequis
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv) (gestionnaire de paquets Python)
- Git
@@ -649,7 +663,7 @@ Pour des directives de contribution détaillées, standards de code et pratiques
## 🌍 Communauté
-- **GitHub Issues**: [Signaler des bugs & demander des fonctionnalités](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**: [Signaler des bugs & demander des fonctionnalités](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**: [Rejoindre la communauté](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Suivre @memU_ai](https://x.com/memU_ai)
- **Contact**: info@nevamind.ai
diff --git a/readme/README_ja.md b/readme/README_ja.md
index 24aa97b5..fab93e5d 100644
--- a/readme/README_ja.md
+++ b/readme/README_ja.md
@@ -8,7 +8,7 @@
[](https://badge.fury.io/py/memu-py)
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](https://discord.gg/memu)
[](https://x.com/memU_ai)
@@ -28,7 +28,7 @@ memUは**ユーザーの意図を継続的にキャプチャして理解**しま
## 🤖 [OpenClaw (Moltbot, Clawdbot) Alternative](https://memu.bot)
-
+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ memory/
## ⭐️ リポジトリにスターを
-
+
memUが役立つまたは興味深いと思われた場合は、GitHub Star ⭐️をいただけると大変嬉しいです。
---
@@ -95,10 +95,14 @@ memUが役立つまたは興味深いと思われた場合は、GitHub Star ⭐
## 🔄 プロアクティブメモリの仕組み
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -275,18 +279,17 @@ MemUの3層システムは、**リアクティブクエリ**と**プロアクテ
#### インストール
```bash
-pip install -e .
+pip install memu-py
```
#### 基本例
-> **要件**:Python 3.13+ と OpenAI APIキー
+> **要件**:Python 3.12+ と OpenAI APIキー
**継続学習をテスト**(インメモリ):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**永続ストレージでテスト**(PostgreSQL):
@@ -301,9 +304,10 @@ docker run -d \
pgvector/pgvector:pg16
# 継続学習テストを実行
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
両方の例は**プロアクティブメモリワークフロー**を示しています:
@@ -311,7 +315,9 @@ python test_postgres.py
2. **自動抽出**:即座のメモリ作成
3. **プロアクティブ検索**:コンテキストに応じたメモリ表示
-実装の詳細については [`tests/test_inmemory.py`](../tests/test_inmemory.py) と [`tests/test_postgres.py`](../tests/test_postgres.py) を参照してください。
+実装の詳細については [`tests/test_inmemory.py`](../tests/test_inmemory.py)、[`tests/test_sqlite.py`](../tests/test_sqlite.py)、
+および [`tests/test_postgres.py`](../tests/test_postgres.py) を参照してください。in-memory と SQLite の live LLM チェックは
+`MEMU_RUN_LIVE_LLM_TESTS=1` による opt-in です。
---
@@ -326,14 +332,14 @@ service = MemUService(
# LLM操作のデフォルトプロファイル
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" または "http"
+ "client_backend": "sdk" # "sdk"、"httpx"、または "lazyllm_backend"
},
# 埋め込み用の別プロファイル
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -341,6 +347,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### OpenRouter統合
@@ -357,7 +376,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # 任意のOpenRouterモデル
"embed_model": "openai/text-embedding-3-small", # 埋め込みモデル
},
@@ -385,15 +404,10 @@ service = MemoryService(
#### OpenRouterテストの実行
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# フルワークフローテスト(メモリ化 + 検索)
-python tests/test_openrouter.py
-
-# 埋め込み固有のテスト
-python tests/test_openrouter_embedding.py
-
-# ビジョン固有のテスト
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
完全な動作例については [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py) を参照してください。
@@ -464,8 +478,8 @@ MemUは**プロアクティブコンテキストロード**と**リアクティ
# コンテキスト履歴を含むプロアクティブ検索
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "彼らの好みは何ですか?"}},
- {"role": "user", "content": {"text": "仕事の習慣について教えて"}}
+ {"role": "user", "content": "彼らの好みは何ですか?"},
+ {"role": "user", "content": "仕事の習慣について教えて"}
],
where={"user_id": "123"}, # オプション:スコープフィルター
method="rag" # または "llm" でより深い推論
@@ -485,7 +499,7 @@ result = await service.retrieve(
- `where={"agent_id__in": ["1", "2"]}` - マルチエージェント調整
- `where`を省略してグローバルコンテキスト認識
-> 📚 **完全なAPIドキュメント**については、[SERVICE_API.md](../docs/SERVICE_API.md) を参照 - プロアクティブワークフローパターン、パイプライン設定、リアルタイム更新処理を含む。
+> 📚 **完全なAPIドキュメント**については、[memu.pro/docs](https://memu.pro/docs) を参照 - プロアクティブワークフローパターン、パイプライン設定、リアルタイム更新処理を含む。
---
@@ -559,7 +573,7 @@ MemUは、すべての推論タスクでLocomoベンチマークで**92.09%の
| リポジトリ | 説明 | プロアクティブ機能 |
|-----------|------|------------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | コアプロアクティブメモリエンジン | 7×24学習パイプライン、自動分類 |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | コアプロアクティブメモリエンジン | 7×24学習パイプライン、自動分類 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | 継続同期を備えたバックエンド | リアルタイムメモリ更新、webhookトリガー |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | ビジュアルメモリダッシュボード | ライブメモリ進化モニタリング |
@@ -596,7 +610,7 @@ MemUは、すべての推論タスクでLocomoベンチマークで**92.09%の
MemUへのコントリビュートを開始するには、開発環境をセットアップする必要があります:
#### 前提条件
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv)(Pythonパッケージマネージャー)
- Git
@@ -649,7 +663,7 @@ make check
## 🌍 コミュニティ
-- **GitHub Issues**:[バグを報告 & 機能をリクエスト](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**:[バグを報告 & 機能をリクエスト](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**:[コミュニティに参加](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**:[@memU_ai をフォロー](https://x.com/memU_ai)
- **お問い合わせ**:info@nevamind.ai
diff --git a/readme/README_ko.md b/readme/README_ko.md
index d114897f..24ca00fa 100644
--- a/readme/README_ko.md
+++ b/readme/README_ko.md
@@ -8,7 +8,7 @@
[](https://badge.fury.io/py/memu-py)
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](https://discord.gg/memu)
[](https://x.com/memU_ai)
@@ -28,7 +28,7 @@ memU는 **사용자 의도를 지속적으로 캡처하고 이해**합니다.
## 🤖 [OpenClaw (Moltbot, Clawdbot) Alternative](https://memu.bot)
-
+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ memory/
## ⭐️ 리포지토리에 스타를
-
+
MemU가 유용하거나 흥미롭다면, GitHub Star ⭐️를 눌러주시면 큰 힘이 됩니다.
---
@@ -95,10 +95,14 @@ MemU가 유용하거나 흥미롭다면, GitHub Star ⭐️를 눌러주시면
## 🔄 프로액티브 메모리 작동 방식
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -275,18 +279,17 @@ MemU의 3계층 시스템은 **반응적 쿼리**와 **프로액티브 컨텍스
#### 설치
```bash
-pip install -e .
+pip install memu-py
```
#### 기본 예제
-> **요구사항**: Python 3.13+ 및 OpenAI API 키
+> **요구사항**: Python 3.12+ 및 OpenAI API 키
**지속 학습 테스트** (인메모리):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**영구 저장소로 테스트** (PostgreSQL):
@@ -301,9 +304,10 @@ docker run -d \
pgvector/pgvector:pg16
# 지속 학습 테스트 실행
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
두 예제 모두 **프로액티브 메모리 워크플로우**를 보여줍니다:
@@ -311,7 +315,9 @@ python test_postgres.py
2. **자동 추출**: 즉각적인 메모리 생성
3. **프로액티브 검색**: 컨텍스트 인식 메모리 표시
-구현 세부사항은 [`tests/test_inmemory.py`](../tests/test_inmemory.py)와 [`tests/test_postgres.py`](../tests/test_postgres.py)를 참조하세요.
+구현 세부사항은 [`tests/test_inmemory.py`](../tests/test_inmemory.py), [`tests/test_sqlite.py`](../tests/test_sqlite.py),
+그리고 [`tests/test_postgres.py`](../tests/test_postgres.py)를 참조하세요. in-memory 및 SQLite live LLM 검사는
+`MEMU_RUN_LIVE_LLM_TESTS=1`로 opt-in해야 합니다.
---
@@ -326,14 +332,14 @@ service = MemUService(
# LLM 작업용 기본 프로필
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" 또는 "http"
+ "client_backend": "sdk" # "sdk", "httpx" 또는 "lazyllm_backend"
},
# 임베딩용 별도 프로필
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -341,6 +347,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### OpenRouter 통합
@@ -357,7 +376,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # 모든 OpenRouter 모델
"embed_model": "openai/text-embedding-3-small", # 임베딩 모델
},
@@ -385,15 +404,10 @@ service = MemoryService(
#### OpenRouter 테스트 실행
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# 전체 워크플로우 테스트 (메모라이즈 + 검색)
-python tests/test_openrouter.py
-
-# 임베딩 특화 테스트
-python tests/test_openrouter_embedding.py
-
-# 비전 특화 테스트
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
완전한 작동 예제는 [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py)를 참조하세요.
@@ -464,8 +478,8 @@ MemU는 **프로액티브 컨텍스트 로딩**과 **반응적 쿼리**를 모
# 컨텍스트 히스토리를 포함한 프로액티브 검색
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "그들의 선호도가 무엇입니까?"}},
- {"role": "user", "content": {"text": "업무 습관에 대해 알려주세요"}}
+ {"role": "user", "content": "그들의 선호도가 무엇입니까?"},
+ {"role": "user", "content": "업무 습관에 대해 알려주세요"}
],
where={"user_id": "123"}, # 선택: 범위 필터
method="rag" # 또는 "llm"으로 더 깊은 추론
@@ -485,7 +499,7 @@ result = await service.retrieve(
- `where={"agent_id__in": ["1", "2"]}` - 다중 에이전트 조정
- `where` 생략으로 전역 컨텍스트 인식
-> 📚 **전체 API 문서**는 [SERVICE_API.md](../docs/SERVICE_API.md) 참조 - 프로액티브 워크플로우 패턴, 파이프라인 구성, 실시간 업데이트 처리 포함.
+> 📚 **전체 API 문서**는 [memu.pro/docs](https://memu.pro/docs) 참조 - 프로액티브 워크플로우 패턴, 파이프라인 구성, 실시간 업데이트 처리 포함.
---
@@ -559,7 +573,7 @@ MemU는 모든 추론 작업에서 Locomo 벤치마크에서 **92.09% 평균 정
| 리포지토리 | 설명 | 프로액티브 기능 |
|-----------|------|----------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | 핵심 프로액티브 메모리 엔진 | 7×24 학습 파이프라인, 자동 분류 |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | 핵심 프로액티브 메모리 엔진 | 7×24 학습 파이프라인, 자동 분류 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | 지속 동기화가 포함된 백엔드 | 실시간 메모리 업데이트, 웹훅 트리거 |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | 시각적 메모리 대시보드 | 라이브 메모리 진화 모니터링 |
@@ -596,7 +610,7 @@ MemU는 모든 추론 작업에서 Locomo 벤치마크에서 **92.09% 평균 정
MemU에 기여하려면 개발 환경을 설정해야 합니다:
#### 사전 요구사항
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv) (Python 패키지 관리자)
- Git
@@ -649,7 +663,7 @@ make check
## 🌍 커뮤니티
-- **GitHub Issues**: [버그 보고 및 기능 요청](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**: [버그 보고 및 기능 요청](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**: [커뮤니티 참여](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [@memU_ai 팔로우](https://x.com/memU_ai)
- **연락처**: info@nevamind.ai
diff --git a/readme/README_zh.md b/readme/README_zh.md
index d6b3db1b..d86b9207 100644
--- a/readme/README_zh.md
+++ b/readme/README_zh.md
@@ -8,7 +8,7 @@
[](https://badge.fury.io/py/memu-py)
[](https://opensource.org/licenses/Apache-2.0)
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](https://discord.gg/memu)
[](https://x.com/memU_ai)
@@ -28,7 +28,7 @@ memU **持续捕获并理解用户意图**。即使没有明确指令,智能
## 🤖 [OpenClaw (Moltbot, Clawdbot) Alternative](https://memu.bot)
-
+
- **Download-and-use and simple** to get started.
- Builds long-term memory to **understand user intent** and act proactively.
@@ -77,7 +77,7 @@ memory/
## ⭐️ 给项目点个星
-
+
如果你觉得 memU 有用或有趣,请给项目点个星 ⭐️,这将是对我们最大的支持!
---
@@ -95,10 +95,14 @@ memory/
## 🔄 主动记忆工作原理
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -273,18 +277,17 @@ MemU 的三层系统同时支持**响应式查询**和**主动上下文加载**
#### 安装
```bash
-pip install -e .
+pip install memu-py
```
#### 基础示例
-> **要求**:Python 3.13+ 和 OpenAI API 密钥
+> **要求**:Python 3.12+ 和 OpenAI API 密钥
**测试持续学习**(内存模式):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
**测试持久化存储**(PostgreSQL):
@@ -299,9 +302,10 @@ docker run -d \
pgvector/pgvector:pg16
# 运行持续学习测试
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
两个示例都演示了**主动记忆工作流**:
@@ -309,7 +313,9 @@ python test_postgres.py
2. **自动提取**:即时创建记忆
3. **主动检索**:上下文感知的记忆呈现
-查看 [`tests/test_inmemory.py`](../tests/test_inmemory.py) 和 [`tests/test_postgres.py`](../tests/test_postgres.py) 了解实现细节。
+查看 [`tests/test_inmemory.py`](../tests/test_inmemory.py)、[`tests/test_sqlite.py`](../tests/test_sqlite.py)
+和 [`tests/test_postgres.py`](../tests/test_postgres.py) 了解实现细节。in-memory 和 SQLite live LLM 检查需要通过
+`MEMU_RUN_LIVE_LLM_TESTS=1` 显式 opt-in。
---
@@ -324,14 +330,14 @@ service = MemUService(
# LLM 操作的默认配置
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" 或 "http"
+ "client_backend": "sdk" # "sdk"、"httpx" 或 "lazyllm_backend"
},
# 嵌入的单独配置
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -339,6 +345,19 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
---
### OpenRouter 集成
@@ -355,7 +374,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # 任何 OpenRouter 模型
"embed_model": "openai/text-embedding-3-small", # 嵌入模型
},
@@ -383,15 +402,10 @@ service = MemoryService(
#### 运行 OpenRouter 测试
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# 完整工作流测试(记忆 + 检索)
-python tests/test_openrouter.py
-
-# 嵌入专项测试
-python tests/test_openrouter_embedding.py
-
-# 视觉专项测试
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
查看 [`examples/example_4_openrouter_memory.py`](../examples/example_4_openrouter_memory.py) 获取完整示例。
@@ -462,8 +476,8 @@ MemU 同时支持**主动上下文加载**和**响应式查询**:
# 带上下文历史的主动检索
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "他们的偏好是什么?"}},
- {"role": "user", "content": {"text": "告诉我工作习惯"}}
+ {"role": "user", "content": "他们的偏好是什么?"},
+ {"role": "user", "content": "告诉我工作习惯"}
],
where={"user_id": "123"}, # 可选:范围过滤
method="rag" # 或 "llm" 用于更深入的推理
@@ -483,7 +497,7 @@ result = await service.retrieve(
- `where={"agent_id__in": ["1", "2"]}` - 多智能体协调
- 省略 `where` 以获取全局上下文感知
-> 📚 **完整 API 文档**,请参阅 [SERVICE_API.md](../docs/SERVICE_API.md) - 包含主动工作流模式、管道配置和实时更新处理。
+> 📚 **完整 API 文档**,请参阅 [memu.pro/docs](https://memu.pro/docs) - 包含主动工作流模式、管道配置和实时更新处理。
---
@@ -557,7 +571,7 @@ MemU 在 Locomo 基准测试中,在所有推理任务上实现了 **92.09% 的
| 仓库 | 描述 | 主动功能 |
|------|------|----------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | 核心主动记忆引擎 | 7×24 学习管道、自动分类 |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | 核心主动记忆引擎 | 7×24 学习管道、自动分类 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | 带持续同步的后端 | 实时记忆更新、webhook 触发 |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | 可视化记忆仪表板 | 实时记忆演化监控 |
@@ -594,7 +608,7 @@ MemU 在 Locomo 基准测试中,在所有推理任务上实现了 **92.09% 的
要开始为 MemU 做贡献,您需要设置开发环境:
#### 先决条件
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv)(Python 包管理器)
- Git
@@ -647,7 +661,7 @@ make check
## 🌍 社区
-- **GitHub Issues**:[报告错误和请求功能](https://github.com/NevaMind-AI/memU/issues)
+- **GitHub Issues**:[报告错误和请求功能](https://github.com/NevaMind-AI/MemU/issues)
- **Discord**:[加入社区](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**:[关注 @memU_ai](https://x.com/memU_ai)
- **联系方式**:info@nevamind.ai
diff --git a/src/memu/app/crud.py b/src/memu/app/crud.py
index 50d63d4c..47d96ef8 100644
--- a/src/memu/app/crud.py
+++ b/src/memu/app/crud.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel
+from memu.app.scope import concrete_scope_from_where, normalize_scope_where, record_matches_scope
from memu.database.models import MemoryCategory, MemoryType
from memu.prompts.category_patch import CATEGORY_PATCH_PROMPT
from memu.workflow.step import WorkflowState, WorkflowStep
@@ -63,6 +64,9 @@ async def list_memory_categories(
ctx = self._get_context()
store = self._get_database()
where_filters = self._normalize_where(where)
+ bootstrap_scope = concrete_scope_from_where(where_filters)
+ if bootstrap_scope is not None:
+ await self._ensure_categories_ready(ctx, store, bootstrap_scope)
state: WorkflowState = {
"ctx": ctx,
@@ -149,6 +153,14 @@ def _build_list_memory_categories_workflow(self) -> list[WorkflowStep]:
def _build_clear_memory_workflow(self) -> list[WorkflowStep]:
steps = [
+ WorkflowStep(
+ step_id="clear_category_item_relations",
+ role="delete_memories",
+ handler=self._crud_clear_category_item_relations,
+ requires={"ctx", "store", "where"},
+ produces={"deleted_relations"},
+ capabilities={"db"},
+ ),
WorkflowStep(
step_id="clear_memory_categories",
role="delete_memories",
@@ -177,7 +189,14 @@ def _build_clear_memory_workflow(self) -> list[WorkflowStep]:
step_id="build_response",
role="emit",
handler=self._crud_build_clear_memory_response,
- requires={"ctx", "store", "deleted_categories", "deleted_items", "deleted_resources"},
+ requires={
+ "ctx",
+ "store",
+ "deleted_relations",
+ "deleted_categories",
+ "deleted_items",
+ "deleted_resources",
+ },
produces={"response"},
capabilities=set(),
),
@@ -194,22 +213,14 @@ def _list_clear_memories_initial_keys() -> set[str]:
def _normalize_where(self, where: Mapping[str, Any] | None) -> dict[str, Any]:
"""Validate and clean the `where` scope filters against the configured user model."""
- if not where:
- return {}
-
- valid_fields = set(getattr(self.user_model, "model_fields", {}).keys())
- cleaned: dict[str, Any] = {}
-
- for raw_key, value in where.items():
- if value is None:
- continue
- field = raw_key.split("__", 1)[0]
- if field not in valid_fields:
- msg = f"Unknown filter field '{field}' for current user scope"
- raise ValueError(msg)
- cleaned[raw_key] = value
+ return normalize_scope_where(self.user_model, where)
- return cleaned
+ @staticmethod
+ def _ensure_item_matches_user_scope(item: Any, user_scope: Mapping[str, Any] | None, memory_id: str) -> None:
+ if record_matches_scope(item, user_scope):
+ return
+ msg = f"Memory item with id {memory_id} not found"
+ raise ValueError(msg)
def _crud_list_memory_items(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
@@ -243,6 +254,13 @@ def _crud_build_list_categories_response(self, state: WorkflowState, step_contex
state["response"] = response
return state
+ def _crud_clear_category_item_relations(self, state: WorkflowState, step_context: Any) -> WorkflowState:
+ where_filters = state.get("where") or {}
+ store = state["store"]
+ deleted = store.category_item_repo.clear_relations(where_filters)
+ state["deleted_relations"] = deleted
+ return state
+
def _crud_clear_memory_categories(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
@@ -265,10 +283,12 @@ def _crud_clear_memory_resources(self, state: WorkflowState, step_context: Any)
return state
def _crud_build_clear_memory_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
+ deleted_relations = state.get("deleted_relations", [])
deleted_categories = state.get("deleted_categories", {})
deleted_items = state.get("deleted_items", {})
deleted_resources = state.get("deleted_resources", {})
response = {
+ "deleted_relations": [self._model_dump_without_embeddings(rel) for rel in deleted_relations],
"deleted_categories": [self._model_dump_without_embeddings(cat) for cat in deleted_categories.values()],
"deleted_items": [self._model_dump_without_embeddings(item) for item in deleted_items.values()],
"deleted_resources": [self._model_dump_without_embeddings(res) for res in deleted_resources.values()],
@@ -285,9 +305,9 @@ async def create_memory_item(
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
- if memory_type not in get_args(MemoryType):
- msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
- raise ValueError(msg)
+ memory_type = _normalize_memory_type(memory_type)
+ memory_content = _normalize_memory_content(memory_content, field_name="memory_content")
+ memory_categories = _normalize_memory_categories(memory_categories, field_name="memory_categories")
ctx = self._get_context()
store = self._get_database()
@@ -327,9 +347,13 @@ async def update_memory_item(
if all((memory_type is None, memory_content is None, memory_categories is None)):
msg = "At least one of memory type, memory content, or memory categories is required for UPDATE operation"
raise ValueError(msg)
- if memory_type and memory_type not in get_args(MemoryType):
- msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
- raise ValueError(msg)
+ memory_id = _normalize_memory_id(memory_id)
+ if memory_type is not None:
+ memory_type = _normalize_memory_type(memory_type)
+ if memory_content is not None:
+ memory_content = _normalize_memory_content(memory_content, field_name="memory_content")
+ if memory_categories is not None:
+ memory_categories = _normalize_memory_categories(memory_categories, field_name="memory_categories")
ctx = self._get_context()
store = self._get_database()
@@ -364,6 +388,8 @@ async def delete_memory_item(
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
+ memory_id = _normalize_memory_id(memory_id)
+
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
@@ -517,6 +543,7 @@ async def _patch_create_memory_item(self, state: WorkflowState, step_context: An
content_embedding = (await self._get_step_embedding_client(step_context).embed(embed_payload))[0]
item = store.memory_item_repo.create_item(
+ resource_id=None,
memory_type=memory_payload["type"],
summary=memory_payload["content"],
embedding=content_embedding,
@@ -548,6 +575,7 @@ async def _patch_update_memory_item(self, state: WorkflowState, step_context: An
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
+ self._ensure_item_matches_user_scope(item, user, memory_id)
old_content = item.summary
old_item_categories = store.category_item_repo.get_item_categories(memory_id)
mapped_old_cat_ids = [cat.category_id for cat in old_item_categories]
@@ -566,7 +594,10 @@ async def _patch_update_memory_item(self, state: WorkflowState, step_context: An
embedding=content_embedding,
)
new_cat_names = memory_payload["categories"]
- mapped_new_cat_ids = self._map_category_names_to_ids(new_cat_names, ctx)
+ if new_cat_names is None:
+ mapped_new_cat_ids = mapped_old_cat_ids
+ else:
+ mapped_new_cat_ids = self._map_category_names_to_ids(new_cat_names, ctx)
cats_to_remove = set(mapped_old_cat_ids) - set(mapped_new_cat_ids)
cats_to_add = set(mapped_new_cat_ids) - set(mapped_old_cat_ids)
@@ -599,7 +630,8 @@ async def _patch_delete_memory_item(self, state: WorkflowState, step_context: An
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
- item_categories = store.category_item_repo.get_item_categories(memory_id)
+ self._ensure_item_matches_user_scope(item, state["user"], memory_id)
+ item_categories = store.category_item_repo.clear_relations({"item_id": memory_id})
if propagate:
for cat in item_categories:
category_memory_updates[cat.category_id] = (item.summary, None)
@@ -724,3 +756,36 @@ def _parse_category_patch_response(self, response: str) -> tuple[bool, str]:
if updated_content == "empty":
updated_content = ""
return need_update, updated_content
+
+
+def _normalize_memory_id(value: Any) -> str:
+ return _normalize_non_empty_string(value, field_name="memory_id")
+
+
+def _normalize_memory_type(value: Any) -> MemoryType:
+ memory_type = _normalize_non_empty_string(value, field_name="memory_type")
+ if memory_type not in get_args(MemoryType):
+ msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
+ raise ValueError(msg)
+ return cast(MemoryType, memory_type)
+
+
+def _normalize_memory_content(value: Any, *, field_name: str) -> str:
+ return _normalize_non_empty_string(value, field_name=field_name)
+
+
+def _normalize_memory_categories(value: Any, *, field_name: str) -> list[str]:
+ if not isinstance(value, list):
+ msg = f"'{field_name}' must be a list of non-empty strings"
+ raise ValueError(msg)
+ normalized: list[str] = []
+ for index, item in enumerate(value):
+ normalized.append(_normalize_non_empty_string(item, field_name=f"{field_name}[{index}]"))
+ return normalized
+
+
+def _normalize_non_empty_string(value: Any, *, field_name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ msg = f"'{field_name}' must be a non-empty string"
+ raise ValueError(msg)
+ return value.strip()
diff --git a/src/memu/app/memorize.py b/src/memu/app/memorize.py
index 0f2a06fc..b6e7c7ed 100644
--- a/src/memu/app/memorize.py
+++ b/src/memu/app/memorize.py
@@ -12,6 +12,7 @@
import defusedxml.ElementTree as ET
from pydantic import BaseModel
+from memu.app.scope import scope_key_from_user
from memu.app.settings import CategoryConfig, CustomPrompt
from memu.database.models import CategoryItem, MemoryCategory, MemoryItem, MemoryType, Resource
from memu.prompts.category_summary import (
@@ -32,6 +33,7 @@
)
from memu.prompts.preprocess import PROMPTS as PREPROCESS_PROMPTS
from memu.utils.conversation import format_conversation_for_preprocess
+from memu.utils.dedupe import dedupe_resource_plans
from memu.utils.video import VideoFrameExtractor
from memu.workflow.step import WorkflowState, WorkflowStep
@@ -227,8 +229,7 @@ async def _memorize_extract_items(self, state: WorkflowState, step_context: Any)
return state
def _memorize_dedupe_merge(self, state: WorkflowState, step_context: Any) -> WorkflowState:
- # Placeholder for future dedup/merge logic
- state["resource_plans"] = state.get("resource_plans", [])
+ state["resource_plans"] = dedupe_resource_plans(state.get("resource_plans", []))
return state
async def _memorize_categorize_items(self, state: WorkflowState, step_context: Any) -> WorkflowState:
@@ -301,7 +302,7 @@ def _memorize_build_response(self, state: WorkflowState, step_context: Any) -> W
store = state["store"]
resources = [self._model_dump_without_embeddings(r) for r in state.get("resources", [])]
items = [self._model_dump_without_embeddings(item) for item in state.get("items", [])]
- relations = [rel.model_dump() for rel in state.get("relations", [])]
+ relations = [self._model_dump_without_embeddings(rel) for rel in state.get("relations", [])]
category_ids = state.get("category_ids") or list(ctx.category_ids)
categories = [
self._model_dump_without_embeddings(store.memory_category_repo.categories[c]) for c in category_ids
@@ -637,20 +638,48 @@ def _start_category_initialization(self, ctx: Context, store: Database) -> None:
async def _ensure_categories_ready(
self, ctx: Context, store: Database, user_scope: Mapping[str, Any] | None = None
) -> None:
- if ctx.categories_ready:
+ scope_key = scope_key_from_user(user_scope)
+ if ctx.categories_ready and ctx.category_scope_key == scope_key:
+ return
+ cached = ctx.category_cache.get(scope_key)
+ if cached is not None:
+ ctx.category_ids = list(cached[0])
+ ctx.category_name_to_id = dict(cached[1])
+ ctx.category_scope_key = scope_key
+ ctx.categories_ready = True
return
if ctx.category_init_task:
await ctx.category_init_task
ctx.category_init_task = None
- return
+ if ctx.categories_ready and ctx.category_scope_key == scope_key:
+ return
+ cached = ctx.category_cache.get(scope_key)
+ if cached is not None:
+ ctx.category_ids = list(cached[0])
+ ctx.category_name_to_id = dict(cached[1])
+ ctx.category_scope_key = scope_key
+ ctx.categories_ready = True
+ return
await self._initialize_categories(ctx, store, user_scope)
async def _initialize_categories(
self, ctx: Context, store: Database, user: Mapping[str, Any] | None = None
) -> None:
- if ctx.categories_ready:
+ scope_key = scope_key_from_user(user)
+ if ctx.categories_ready and ctx.category_scope_key == scope_key:
+ return
+ cached = ctx.category_cache.get(scope_key)
+ if cached is not None:
+ ctx.category_ids = list(cached[0])
+ ctx.category_name_to_id = dict(cached[1])
+ ctx.category_scope_key = scope_key
+ ctx.categories_ready = True
return
if not self.category_configs:
+ ctx.category_ids = []
+ ctx.category_name_to_id = {}
+ ctx.category_scope_key = scope_key
+ ctx.category_cache[scope_key] = ([], {})
ctx.categories_ready = True
return
cat_texts = [self._category_embedding_text(cfg) for cfg in self.category_configs]
@@ -665,6 +694,8 @@ async def _initialize_categories(
)
ctx.category_ids.append(cat.id)
ctx.category_name_to_id[name.lower()] = cat.id
+ ctx.category_scope_key = scope_key
+ ctx.category_cache[scope_key] = (list(ctx.category_ids), dict(ctx.category_name_to_id))
ctx.categories_ready = True
@staticmethod
diff --git a/src/memu/app/patch.py b/src/memu/app/patch.py
index c1796478..b0123cef 100644
--- a/src/memu/app/patch.py
+++ b/src/memu/app/patch.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel
+from memu.app.scope import record_matches_scope
from memu.database.models import MemoryCategory, MemoryType
from memu.prompts.category_patch import CATEGORY_PATCH_PROMPT
from memu.workflow.step import WorkflowState, WorkflowStep
@@ -43,9 +44,9 @@ async def create_memory_item(
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
- if memory_type not in get_args(MemoryType):
- msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
- raise ValueError(msg)
+ memory_type = _normalize_memory_type(memory_type)
+ memory_content = _normalize_memory_content(memory_content, field_name="memory_content")
+ memory_categories = _normalize_memory_categories(memory_categories, field_name="memory_categories")
ctx = self._get_context()
store = self._get_database()
@@ -85,9 +86,13 @@ async def update_memory_item(
if all((memory_type is None, memory_content is None, memory_categories is None)):
msg = "At least one of memory type, memory content, or memory categories is required for UPDATE operation"
raise ValueError(msg)
- if memory_type and memory_type not in get_args(MemoryType):
- msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
- raise ValueError(msg)
+ memory_id = _normalize_memory_id(memory_id)
+ if memory_type is not None:
+ memory_type = _normalize_memory_type(memory_type)
+ if memory_content is not None:
+ memory_content = _normalize_memory_content(memory_content, field_name="memory_content")
+ if memory_categories is not None:
+ memory_categories = _normalize_memory_categories(memory_categories, field_name="memory_categories")
ctx = self._get_context()
store = self._get_database()
@@ -122,6 +127,8 @@ async def delete_memory_item(
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
+ memory_id = _normalize_memory_id(memory_id)
+
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
@@ -258,6 +265,13 @@ def _list_delete_memory_item_initial_keys() -> set[str]:
"user",
}
+ @staticmethod
+ def _ensure_item_matches_user_scope(item: Any, user_scope: Mapping[str, Any] | None, memory_id: str) -> None:
+ if record_matches_scope(item, user_scope):
+ return
+ msg = f"Memory item with id {memory_id} not found"
+ raise ValueError(msg)
+
async def _patch_create_memory_item(self, state: WorkflowState, step_context: Any) -> WorkflowState:
memory_payload = state["memory_payload"]
ctx = state["ctx"]
@@ -270,6 +284,7 @@ async def _patch_create_memory_item(self, state: WorkflowState, step_context: An
content_embedding = (await self._get_llm_client().embed(embed_payload))[0]
item = store.memory_item_repo.create_item(
+ resource_id=None,
memory_type=memory_payload["type"],
summary=memory_payload["content"],
embedding=content_embedding,
@@ -301,6 +316,7 @@ async def _patch_update_memory_item(self, state: WorkflowState, step_context: An
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
+ self._ensure_item_matches_user_scope(item, user, memory_id)
old_content = item.summary
old_item_categories = store.category_item_repo.get_item_categories(memory_id)
mapped_old_cat_ids = [cat.category_id for cat in old_item_categories]
@@ -319,7 +335,10 @@ async def _patch_update_memory_item(self, state: WorkflowState, step_context: An
embedding=content_embedding,
)
new_cat_names = memory_payload["categories"]
- mapped_new_cat_ids = self._map_category_names_to_ids(new_cat_names, ctx)
+ if new_cat_names is None:
+ mapped_new_cat_ids = mapped_old_cat_ids
+ else:
+ mapped_new_cat_ids = self._map_category_names_to_ids(new_cat_names, ctx)
cats_to_remove = set(mapped_old_cat_ids) - set(mapped_new_cat_ids)
cats_to_add = set(mapped_new_cat_ids) - set(mapped_old_cat_ids)
@@ -352,7 +371,8 @@ async def _patch_delete_memory_item(self, state: WorkflowState, step_context: An
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
- item_categories = store.category_item_repo.get_item_categories(memory_id)
+ self._ensure_item_matches_user_scope(item, state["user"], memory_id)
+ item_categories = store.category_item_repo.clear_relations({"item_id": memory_id})
if propagate:
for cat in item_categories:
category_memory_updates[cat.category_id] = (item.summary, None)
@@ -477,3 +497,36 @@ def _parse_category_patch_response(self, response: str) -> tuple[bool, str]:
if updated_content == "empty":
updated_content = ""
return need_update, updated_content
+
+
+def _normalize_memory_id(value: Any) -> str:
+ return _normalize_non_empty_string(value, field_name="memory_id")
+
+
+def _normalize_memory_type(value: Any) -> MemoryType:
+ memory_type = _normalize_non_empty_string(value, field_name="memory_type")
+ if memory_type not in get_args(MemoryType):
+ msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(MemoryType)}"
+ raise ValueError(msg)
+ return cast(MemoryType, memory_type)
+
+
+def _normalize_memory_content(value: Any, *, field_name: str) -> str:
+ return _normalize_non_empty_string(value, field_name=field_name)
+
+
+def _normalize_memory_categories(value: Any, *, field_name: str) -> list[str]:
+ if not isinstance(value, list):
+ msg = f"'{field_name}' must be a list of non-empty strings"
+ raise ValueError(msg)
+ normalized: list[str] = []
+ for index, item in enumerate(value):
+ normalized.append(_normalize_non_empty_string(item, field_name=f"{field_name}[{index}]"))
+ return normalized
+
+
+def _normalize_non_empty_string(value: Any, *, field_name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ msg = f"'{field_name}' must be a non-empty string"
+ raise ValueError(msg)
+ return value.strip()
diff --git a/src/memu/app/retrieve.py b/src/memu/app/retrieve.py
index a7cbff5c..3e4ec7e5 100644
--- a/src/memu/app/retrieve.py
+++ b/src/memu/app/retrieve.py
@@ -8,12 +8,14 @@
from pydantic import BaseModel
+from memu.app.scope import concrete_scope_from_where, normalize_scope_where
from memu.database.inmemory.vector import cosine_topk
from memu.prompts.retrieve.llm_category_ranker import PROMPT as LLM_CATEGORY_RANKER_PROMPT
from memu.prompts.retrieve.llm_item_ranker import PROMPT as LLM_ITEM_RANKER_PROMPT
from memu.prompts.retrieve.llm_resource_ranker import PROMPT as LLM_RESOURCE_RANKER_PROMPT
from memu.prompts.retrieve.pre_retrieval_decision import SYSTEM_PROMPT as PRE_RETRIEVAL_SYSTEM_PROMPT
from memu.prompts.retrieve.pre_retrieval_decision import USER_PROMPT as PRE_RETRIEVAL_USER_PROMPT
+from memu.utils.retrieve import RetrieveMethod, RetrieveRanking, normalize_retrieve_method, normalize_retrieve_ranking
from memu.workflow.step import WorkflowState, WorkflowStep
logger = logging.getLogger(__name__)
@@ -30,7 +32,7 @@ class RetrieveMixin:
_run_workflow: Callable[..., Awaitable[WorkflowState]]
_get_context: Callable[[], Context]
_get_database: Callable[[], Database]
- _ensure_categories_ready: Callable[[Context, Database], Awaitable[None]]
+ _ensure_categories_ready: Callable[[Context, Database, Mapping[str, Any] | None], Awaitable[None]]
_get_step_llm_client: Callable[[Mapping[str, Any] | None], Any]
_get_step_embedding_client: Callable[[Mapping[str, Any] | None], Any]
_get_llm_client: Callable[..., Any]
@@ -41,36 +43,48 @@ class RetrieveMixin:
async def retrieve(
self,
- queries: list[dict[str, Any]],
+ queries: list[str | Mapping[str, Any]],
where: dict[str, Any] | None = None,
+ method: RetrieveMethod | str | None = None,
+ ranking: RetrieveRanking | str | None = None,
) -> dict[str, Any]:
+ if not isinstance(queries, list):
+ msg = "queries must be a non-empty list of strings or query objects"
+ raise TypeError(msg)
if not queries:
- raise ValueError("empty_queries")
+ msg = "queries must be a non-empty list of strings or query objects"
+ raise ValueError(msg)
+ normalized_queries = [self._normalize_query_item(query, index=index) for index, query in enumerate(queries)]
ctx = self._get_context()
store = self._get_database()
- original_query = self._extract_query_text(queries[-1])
- # await self._ensure_categories_ready(ctx, store)
+ original_query = self._extract_query_text(normalized_queries[-1])
where_filters = self._normalize_where(where)
- context_queries_objs = queries[:-1] if len(queries) > 1 else []
+ context_queries_objs: list[dict[str, Any]] = normalized_queries[:-1] if len(normalized_queries) > 1 else []
route_intention = self.retrieve_config.route_intention
retrieve_category = self.retrieve_config.category.enabled
retrieve_item = self.retrieve_config.item.enabled
retrieve_resource = self.retrieve_config.resource.enabled
sufficiency_check = self.retrieve_config.sufficiency_check
+ retrieve_method = normalize_retrieve_method(method, default=self.retrieve_config.method)
+ item_ranking = normalize_retrieve_ranking(ranking, default=self.retrieve_config.item.ranking)
+ bootstrap_scope = concrete_scope_from_where(where_filters)
+ if retrieve_category and bootstrap_scope is not None:
+ await self._ensure_categories_ready(ctx, store, bootstrap_scope)
- workflow_name = "retrieve_llm" if self.retrieve_config.method == "llm" else "retrieve_rag"
+ workflow_name = "retrieve_llm" if retrieve_method == "llm" else "retrieve_rag"
state: WorkflowState = {
- "method": self.retrieve_config.method,
+ "method": retrieve_method,
"original_query": original_query,
"context_queries": context_queries_objs,
"route_intention": route_intention,
- "skip_rewrite": len(queries) == 1,
+ "skip_rewrite": len(normalized_queries) == 1,
"retrieve_category": retrieve_category,
"retrieve_item": retrieve_item,
"retrieve_resource": retrieve_resource,
+ "item_ranking": item_ranking,
"sufficiency_check": sufficiency_check,
"ctx": ctx,
"store": store,
@@ -86,22 +100,38 @@ async def retrieve(
def _normalize_where(self, where: Mapping[str, Any] | None) -> dict[str, Any]:
"""Validate and clean the `where` scope filters against the configured user model."""
- if not where:
- return {}
+ return normalize_scope_where(self.user_model, where)
- valid_fields = set(getattr(self.user_model, "model_fields", {}).keys())
- cleaned: dict[str, Any] = {}
+ @staticmethod
+ def _normalize_query_item(query: str | Mapping[str, Any], *, index: int) -> dict[str, Any]:
+ if isinstance(query, str):
+ text = query.strip()
+ if not text:
+ raise ValueError(f"queries[{index}] must not be empty")
+ return {"role": "user", "content": text}
- for raw_key, value in where.items():
- if value is None:
- continue
- field = raw_key.split("__", 1)[0]
- if field not in valid_fields:
- msg = f"Unknown filter field '{field}' for current user scope"
- raise ValueError(msg)
- cleaned[raw_key] = value
+ if not isinstance(query, Mapping):
+ raise TypeError(f"queries[{index}] must be a string or query object")
+
+ role = query.get("role", "user")
+ if not isinstance(role, str) or not role.strip():
+ raise ValueError(f"queries[{index}].role must be a non-empty string")
+
+ content = query.get("content")
+ if isinstance(content, str):
+ text = content.strip()
+ if not text:
+ raise ValueError(f"queries[{index}].content must not be empty")
+ normalized_content: str | dict[str, str] = text
+ elif isinstance(content, Mapping):
+ text = content.get("text")
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError(f"queries[{index}].content.text must be a non-empty string")
+ normalized_content = {"text": text.strip()}
+ else:
+ raise TypeError(f"queries[{index}].content must be a string or object with text")
- return cleaned
+ return {"role": role.strip(), "content": normalized_content}
def _build_rag_retrieve_workflow(self) -> list[WorkflowStep]:
steps = [
@@ -112,7 +142,7 @@ def _build_rag_retrieve_workflow(self) -> list[WorkflowStep]:
requires={"route_intention", "original_query", "context_queries", "skip_rewrite"},
produces={"needs_retrieval", "rewritten_query", "active_query", "next_step_query"},
capabilities={"llm"},
- config={"chat_llm_profile": self.retrieve_config.sufficiency_check_llm_profile},
+ config={"chat_llm_profile": self.retrieve_config.route_intention_llm_profile},
),
WorkflowStep(
step_id="route_category",
@@ -156,6 +186,7 @@ def _build_rag_retrieve_workflow(self) -> list[WorkflowStep]:
"where",
"active_query",
"query_vector",
+ "item_ranking",
},
produces={"item_hits", "query_vector"},
capabilities={"vector"},
@@ -219,6 +250,7 @@ def _list_retrieve_initial_keys(self) -> set[str]:
"retrieve_category",
"retrieve_item",
"retrieve_resource",
+ "item_ranking",
"sufficiency_check",
"ctx",
"store",
@@ -321,14 +353,15 @@ async def _rag_category_sufficiency(self, state: WorkflowState, step_context: An
state["query_vector"] = (await embed_client.embed([state["active_query"]]))[0]
return state
- def _extract_referenced_item_ids(self, state: WorkflowState) -> set[str]:
- """Extract item IDs from category summary references."""
+ def _extract_referenced_item_ids(self, state: WorkflowState) -> list[str]:
+ """Extract ordered, deduplicated ref IDs from category summary references."""
from memu.utils.references import extract_references
category_hits = state.get("category_hits") or []
summary_lookup = state.get("category_summary_lookup", {})
category_pool = state.get("category_pool") or {}
- referenced_item_ids: set[str] = set()
+ referenced_item_ids: list[str] = []
+ seen: set[str] = set()
for cid, _score in category_hits:
# Get summary from lookup or category
@@ -339,7 +372,11 @@ def _extract_referenced_item_ids(self, state: WorkflowState) -> set[str]:
summary = cat.summary
if summary:
refs = extract_references(summary)
- referenced_item_ids.update(refs)
+ for ref_id in refs:
+ if ref_id in seen:
+ continue
+ referenced_item_ids.append(ref_id)
+ seen.add(ref_id)
return referenced_item_ids
@@ -360,12 +397,34 @@ async def _rag_recall_items(self, state: WorkflowState, step_context: Any) -> Wo
qvec,
self.retrieve_config.item.top_k,
where=where_filters,
- ranking=self.retrieve_config.item.ranking,
+ ranking=state.get("item_ranking", self.retrieve_config.item.ranking),
recency_decay_days=self.retrieve_config.item.recency_decay_days,
)
+ if getattr(self.retrieve_config.item, "use_category_references", False):
+ ref_ids = self._extract_referenced_item_ids(state)
+ if ref_ids:
+ referenced_items = store.memory_item_repo.list_items_by_ref_ids(ref_ids, where_filters)
+ items_pool.update(referenced_items)
+ state["item_hits"] = self._merge_referenced_item_hits(
+ state["item_hits"],
+ referenced_items,
+ )
state["item_pool"] = items_pool
return state
+ @staticmethod
+ def _merge_referenced_item_hits(
+ item_hits: Sequence[tuple[str, float]],
+ referenced_items: Mapping[str, Any],
+ ) -> list[tuple[str, float]]:
+ merged = list(item_hits)
+ seen = {item_id for item_id, _score in merged}
+ for item_id in referenced_items:
+ if item_id not in seen:
+ merged.append((item_id, 1.0))
+ seen.add(item_id)
+ return merged
+
async def _rag_item_sufficiency(self, state: WorkflowState, step_context: Any) -> WorkflowState:
if not state.get("needs_retrieval"):
state["proceed_to_resources"] = False
@@ -457,16 +516,16 @@ def _build_llm_retrieve_workflow(self) -> list[WorkflowStep]:
step_id="route_intention",
role="route_intention",
handler=self._llm_route_intention,
- requires={"original_query", "context_queries", "skip_rewrite"},
+ requires={"route_intention", "original_query", "context_queries", "skip_rewrite"},
produces={"needs_retrieval", "rewritten_query", "active_query", "next_step_query"},
capabilities={"llm"},
- config={"llm_profile": self.retrieve_config.sufficiency_check_llm_profile},
+ config={"llm_profile": self.retrieve_config.route_intention_llm_profile},
),
WorkflowStep(
step_id="route_category",
role="route_category",
handler=self._llm_route_category,
- requires={"needs_retrieval", "active_query", "ctx", "store", "where"},
+ requires={"retrieve_category", "needs_retrieval", "active_query", "ctx", "store", "where"},
produces={"category_hits"},
capabilities={"llm"},
config={"llm_profile": self.retrieve_config.llm_ranking_llm_profile},
@@ -487,6 +546,7 @@ def _build_llm_retrieve_workflow(self) -> list[WorkflowStep]:
requires={
"needs_retrieval",
"proceed_to_items",
+ "retrieve_item",
"ctx",
"store",
"where",
@@ -513,6 +573,7 @@ def _build_llm_retrieve_workflow(self) -> list[WorkflowStep]:
requires={
"needs_retrieval",
"proceed_to_resources",
+ "retrieve_resource",
"active_query",
"ctx",
"store",
@@ -568,7 +629,7 @@ async def _llm_route_intention(self, state: WorkflowState, step_context: Any) ->
return state
async def _llm_route_category(self, state: WorkflowState, step_context: Any) -> WorkflowState:
- if not state.get("needs_retrieval"):
+ if not state.get("retrieve_category") or not state.get("needs_retrieval"):
state["category_hits"] = []
return state
llm_client = self._get_step_llm_client(step_context)
@@ -613,7 +674,7 @@ async def _llm_category_sufficiency(self, state: WorkflowState, step_context: An
return state
async def _llm_recall_items(self, state: WorkflowState, step_context: Any) -> WorkflowState:
- if not state.get("needs_retrieval") or not state.get("proceed_to_items"):
+ if not state.get("retrieve_item") or not state.get("needs_retrieval") or not state.get("proceed_to_items"):
state["item_hits"] = []
return state
@@ -682,7 +743,11 @@ async def _llm_item_sufficiency(self, state: WorkflowState, step_context: Any) -
return state
async def _llm_recall_resources(self, state: WorkflowState, step_context: Any) -> WorkflowState:
- if not state.get("needs_retrieval") or not state.get("proceed_to_resources"):
+ if (
+ not state.get("needs_retrieval")
+ or not state.get("retrieve_resource")
+ or not state.get("proceed_to_resources")
+ ):
state["resource_hits"] = []
return state
@@ -746,7 +811,7 @@ async def _rank_categories_by_summary(
async def _decide_if_retrieval_needed(
self,
query: str,
- context_queries: list[dict[str, Any]] | None,
+ context_queries: Sequence[str | Mapping[str, Any]] | None,
retrieved_content: str | None = None,
system_prompt: str | None = None,
llm_client: Any | None = None,
@@ -756,7 +821,7 @@ async def _decide_if_retrieval_needed(
Args:
query: The current query string
- context_queries: List of previous query objects with role and content
+ context_queries: Previous query strings or objects with role and content
retrieved_content: Content retrieved so far (if checking for sufficiency)
system_prompt: Optional system prompt override
@@ -783,7 +848,7 @@ async def _decide_if_retrieval_needed(
return decision == "RETRIEVE", rewritten
- def _format_query_context(self, queries: list[dict[str, Any]] | None) -> str:
+ def _format_query_context(self, queries: Sequence[str | Mapping[str, Any]] | None) -> str:
"""Format query context for prompts, including role information"""
if not queries:
return "No query context."
@@ -793,7 +858,7 @@ def _format_query_context(self, queries: list[dict[str, Any]] | None) -> str:
if isinstance(q, str):
# Backward compatibility
lines.append(f"- {q}")
- elif isinstance(q, dict):
+ elif isinstance(q, Mapping):
role = q.get("role", "user")
content = q.get("content")
if isinstance(content, dict):
@@ -809,32 +874,39 @@ def _format_query_context(self, queries: list[dict[str, Any]] | None) -> str:
return "\n".join(lines)
@staticmethod
- def _extract_query_text(query: dict[str, Any]) -> str:
+ def _extract_query_text(query: str | Mapping[str, Any]) -> str:
"""
Extract text content from query message structure.
Args:
- query: Query in format {"role": "user", "content": {"text": "..."}}
+ query: Query string or message in format {"role": "user", "content": "..."}.
+ The legacy {"content": {"text": "..."}} shape is also accepted.
Returns:
The extracted text string
"""
if isinstance(query, str):
# Backward compatibility: if it's already a string, return it
- return query
+ text = query.strip()
+ if not text:
+ raise ValueError("EMPTY")
+ return text
- if not isinstance(query, dict):
+ if not isinstance(query, Mapping):
raise TypeError("INVALID")
content = query.get("content")
if isinstance(content, dict):
text = content.get("text", "")
- if not text:
+ if not isinstance(text, str) or not text.strip():
raise ValueError("EMPTY")
- return str(text)
+ return text.strip()
elif isinstance(content, str):
# Also support {"role": "user", "content": "text"} format
- return content
+ text = content.strip()
+ if not text:
+ raise ValueError("EMPTY")
+ return text
else:
raise TypeError("INVALID")
@@ -868,7 +940,7 @@ async def _embedding_based_retrieve(
self,
query: str,
top_k: int,
- context_queries: list[dict[str, Any]] | None,
+ context_queries: Sequence[str | Mapping[str, Any]] | None,
ctx: Context,
store: Database,
llm_client: Any | None = None,
@@ -1022,7 +1094,7 @@ async def _llm_based_retrieve(
self,
query: str,
top_k: int,
- context_queries: list[dict[str, Any]] | None,
+ context_queries: Sequence[str | Mapping[str, Any]] | None,
ctx: Context,
store: Database,
llm_client: Any | None = None,
@@ -1253,7 +1325,7 @@ async def _llm_rank_items(
) -> list[dict[str, Any]]:
"""Use LLM to rank memory items from relevant categories"""
if not category_ids:
- print("[LLM Rank Items] No category_ids provided")
+ logger.debug("Skipping LLM item ranking because no category IDs were provided")
return []
item_pool = items if items is not None else store.memory_item_repo.items
diff --git a/src/memu/app/scope.py b/src/memu/app/scope.py
new file mode 100644
index 00000000..28423027
--- /dev/null
+++ b/src/memu/app/scope.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from typing import Annotated, Any
+
+from pydantic import BaseModel, TypeAdapter, ValidationError
+
+from memu.utils.filtering import build_filter_key, normalize_filter_value, split_filter_key
+
+
+def normalize_scope_where(user_model: type[BaseModel], where: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Validate and clean API `where` filters against the configured user model."""
+
+ if not where:
+ return {}
+
+ valid_fields = set(getattr(user_model, "model_fields", {}).keys())
+ cleaned: dict[str, Any] = {}
+
+ for raw_key, value in where.items():
+ if value is None:
+ continue
+ field, operator = split_filter_key(raw_key)
+ if field not in valid_fields:
+ msg = f"Unknown filter field '{field}' for current user scope"
+ raise ValueError(msg)
+ normalized_value = normalize_filter_value(field, operator, value)
+ cleaned[build_filter_key(field, operator)] = _validate_scope_filter_value(
+ user_model,
+ field,
+ operator,
+ normalized_value,
+ )
+
+ return cleaned
+
+
+def exact_scope_from_where(where: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Extract exact equality scope fields that can be used as write-time user data."""
+
+ if not where:
+ return {}
+ exact: dict[str, Any] = {}
+ for raw_key, value in where.items():
+ if value is None:
+ continue
+ field, operator = split_filter_key(raw_key)
+ if operator is None:
+ exact[field] = value
+ return exact
+
+
+def concrete_scope_from_where(where: Mapping[str, Any] | None) -> dict[str, Any] | None:
+ """Return a concrete write scope when a read filter targets exactly one scope."""
+
+ if not where:
+ return {}
+ concrete: dict[str, Any] = {}
+ for raw_key, value in where.items():
+ if value is None:
+ continue
+ field, operator = split_filter_key(raw_key)
+ if operator is not None:
+ return None
+ concrete[field] = value
+ return concrete
+
+
+def record_matches_scope(record: Any, user_scope: Mapping[str, Any] | None) -> bool:
+ """Return whether a record belongs to a concrete user scope."""
+
+ if not user_scope:
+ return True
+ for field, expected in user_scope.items():
+ if expected is None:
+ continue
+ if getattr(record, str(field), None) != expected:
+ return False
+ return True
+
+
+def scope_key_from_user(user_scope: Mapping[str, Any] | None) -> tuple[tuple[str, str], ...]:
+ """Build a stable cache key for a concrete user/category scope."""
+
+ if not user_scope:
+ return ()
+ return tuple(
+ sorted(
+ (str(field), _scope_value_key(value))
+ for field, value in user_scope.items()
+ if value is not None
+ )
+ )
+
+
+def _scope_value_key(value: Any) -> str:
+ try:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
+ except TypeError:
+ return str(value)
+
+
+def _validate_scope_filter_value(
+ user_model: type[BaseModel],
+ field: str,
+ operator: str | None,
+ value: Any,
+) -> Any:
+ if operator == "in":
+ values = (value,) if isinstance(value, str) else tuple(value)
+ return tuple(_validate_scope_field_value(user_model, field, item) for item in values)
+ return _validate_scope_field_value(user_model, field, value)
+
+
+def _validate_scope_field_value(user_model: type[BaseModel], field: str, value: Any) -> Any:
+ try:
+ model_field = user_model.model_fields[field]
+ annotation = model_field.annotation
+ if model_field.metadata:
+ annotation = Annotated[annotation, *model_field.metadata]
+ validated = TypeAdapter(annotation).validate_python(value)
+ except ValidationError as exc:
+ detail = exc.errors()[0]["msg"] if exc.errors() else "invalid value"
+ msg = f"Invalid filter value for field '{field}': {detail}"
+ raise ValueError(msg) from exc
+ return validated
+
+
+__all__ = [
+ "concrete_scope_from_where",
+ "exact_scope_from_where",
+ "normalize_scope_where",
+ "record_matches_scope",
+ "scope_key_from_user",
+]
diff --git a/src/memu/app/service.py b/src/memu/app/service.py
index 4e2dea04..bcd05601 100644
--- a/src/memu/app/service.py
+++ b/src/memu/app/service.py
@@ -19,6 +19,7 @@
MemorizeConfig,
RetrieveConfig,
UserConfig,
+ resolve_api_key,
)
from memu.blob.local_fs import LocalFS
from memu.database.factory import build_database
@@ -30,6 +31,7 @@
LLMInterceptorHandle,
LLMInterceptorRegistry,
)
+from memu.utils.serialization import model_dump_without_embeddings
from memu.workflow.interceptor import WorkflowInterceptorHandle, WorkflowInterceptorRegistry
from memu.workflow.pipeline import PipelineManager
from memu.workflow.runner import WorkflowRunner, resolve_workflow_runner
@@ -43,6 +45,8 @@ class Context:
categories_ready: bool = False
category_ids: list[str] = field(default_factory=list)
category_name_to_id: dict[str, str] = field(default_factory=dict)
+ category_scope_key: tuple[tuple[str, str], ...] = field(default_factory=tuple)
+ category_cache: dict[tuple[tuple[str, str], ...], tuple[list[str], dict[str, str]]] = field(default_factory=dict)
category_init_task: asyncio.Task | None = None
@@ -103,7 +107,7 @@ def _init_llm_client(self, config: LLMConfig | None = None) -> Any:
return OpenAISDKClient(
base_url=cfg.base_url,
- api_key=cfg.api_key,
+ api_key=resolve_api_key(cfg.api_key),
chat_model=cfg.chat_model,
embed_model=cfg.embed_model,
embed_batch_size=cfg.embed_batch_size,
@@ -111,7 +115,7 @@ def _init_llm_client(self, config: LLMConfig | None = None) -> Any:
elif backend == "httpx":
return HTTPLLMClient(
base_url=cfg.base_url,
- api_key=cfg.api_key,
+ api_key=resolve_api_key(cfg.api_key),
chat_model=cfg.chat_model,
provider=cfg.provider,
endpoint_overrides=cfg.endpoint_overrides,
@@ -373,8 +377,7 @@ def _escape_prompt_value(value: str) -> str:
return value.replace("{", "{{").replace("}", "}}")
def _model_dump_without_embeddings(self, obj: BaseModel) -> dict[str, Any]:
- data = obj.model_dump(exclude={"embedding"})
- return data
+ return model_dump_without_embeddings(obj)
@staticmethod
def _validate_config(
diff --git a/src/memu/app/settings.py b/src/memu/app/settings.py
index adcb4f16..6b4a2c1d 100644
--- a/src/memu/app/settings.py
+++ b/src/memu/app/settings.py
@@ -1,3 +1,4 @@
+import os
from collections.abc import Mapping
from typing import Annotated, Any, Literal
@@ -24,7 +25,23 @@ def normalize_value(v: str) -> str:
return v
+def resolve_api_key(value: str | None) -> str:
+ """Resolve an API key config value that may be a literal key or an environment variable name."""
+ if not value:
+ return ""
+ resolved = os.getenv(value, value)
+ return resolved.strip()
+
+
+def default_api_key_env(provider: str) -> str:
+ """Return the default API key environment variable for a provider."""
+ if provider.strip().lower() == "grok":
+ return "XAI_API_KEY"
+ return "OPENAI_API_KEY"
+
+
Normalize = BeforeValidator(normalize_value)
+ProfileName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
def _default_memory_types() -> list[str]:
@@ -67,7 +84,7 @@ def complete_prompt_blocks(prompt: CustomPrompt, default_blocks: Mapping[str, in
class CategoryConfig(BaseModel):
name: str
description: str = ""
- target_length: int | None = None
+ target_length: int | None = Field(default=None, ge=1)
summary_prompt: str | Annotated[CustomPrompt, CompleteCategoryPrompt] | None = None
@@ -100,16 +117,16 @@ class LazyLLMSource(BaseModel):
class LLMConfig(BaseModel):
- provider: str = Field(
+ provider: Annotated[str, Normalize] = Field(
default="openai",
description="Identifier for the LLM provider implementation (used by HTTP client backend).",
)
base_url: str = Field(default="https://api.openai.com/v1")
api_key: str = Field(default="OPENAI_API_KEY")
chat_model: str = Field(default="gpt-4o-mini")
- client_backend: str = Field(
+ client_backend: Annotated[Literal["httpx", "sdk", "lazyllm_backend"], Normalize] = Field(
default="sdk",
- description="Which LLM client backend to use: 'httpx' (httpx), 'sdk' (official OpenAI), or 'lazyllm_backend' (for more LLM source like Qwen, Doubao, SIliconflow, etc.)",
+ description="Which LLM client backend to use: 'httpx' (httpx), 'sdk' (official OpenAI), or 'lazyllm_backend' (for more LLM source like Qwen, Doubao, SiliconFlow, etc.)",
)
lazyllm_source: LazyLLMSource = Field(default=LazyLLMSource())
endpoint_overrides: dict[str, str] = Field(
@@ -122,6 +139,7 @@ class LLMConfig(BaseModel):
)
embed_batch_size: int = Field(
default=1,
+ ge=1,
description="Maximum batch size for embedding API calls (used by SDK client backends).",
)
@@ -131,8 +149,8 @@ def set_provider_defaults(self) -> "LLMConfig":
# If values match the OpenAI defaults, switch them to Grok defaults
if self.base_url == "https://api.openai.com/v1":
self.base_url = "https://api.x.ai/v1"
- if self.api_key == "OPENAI_API_KEY":
- self.api_key = "XAI_API_KEY"
+ if self.api_key == default_api_key_env("openai"):
+ self.api_key = default_api_key_env("grok")
if self.chat_model == "gpt-4o-mini":
self.chat_model = "grok-2-latest"
return self
@@ -145,12 +163,12 @@ class BlobConfig(BaseModel):
class RetrieveCategoryConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable category retrieval.")
- top_k: int = Field(default=5, description="Total number of categories to retrieve.")
+ top_k: int = Field(default=5, ge=1, description="Total number of categories to retrieve.")
class RetrieveItemConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable item retrieval.")
- top_k: int = Field(default=5, description="Total number of items to retrieve.")
+ top_k: int = Field(default=5, ge=1, description="Total number of items to retrieve.")
# Reference-aware retrieval
use_category_references: bool = Field(
default=False,
@@ -163,13 +181,14 @@ class RetrieveItemConfig(BaseModel):
)
recency_decay_days: float = Field(
default=30.0,
+ gt=0,
description="Half-life in days for recency decay in salience scoring. After this many days, recency factor is ~0.5.",
)
class RetrieveResourceConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable resource retrieval.")
- top_k: int = Field(default=5, description="Total number of resources to retrieve.")
+ top_k: int = Field(default=5, ge=1, description="Total number of resources to retrieve.")
class RetrieveConfig(BaseModel):
@@ -191,32 +210,41 @@ class RetrieveConfig(BaseModel):
default=True, description="Whether to route intention (judge needs retrieval & rewrite query)."
)
# route_intention_prompt: str = Field(default="", description="User prompt for route intention.")
- # route_intention_llm_profile: str = Field(default="default", description="LLM profile for route intention.")
+ route_intention_llm_profile: ProfileName = Field(
+ default="default",
+ description="LLM profile for route intention.",
+ )
category: RetrieveCategoryConfig = Field(default=RetrieveCategoryConfig())
item: RetrieveItemConfig = Field(default=RetrieveItemConfig())
resource: RetrieveResourceConfig = Field(default=RetrieveResourceConfig())
sufficiency_check: bool = Field(default=True, description="Whether to check sufficiency after each tier.")
sufficiency_check_prompt: str = Field(default="", description="User prompt for sufficiency check.")
- sufficiency_check_llm_profile: str = Field(default="default", description="LLM profile for sufficiency check.")
- llm_ranking_llm_profile: str = Field(default="default", description="LLM profile for LLM ranking.")
+ sufficiency_check_llm_profile: ProfileName = Field(
+ default="default",
+ description="LLM profile for sufficiency check.",
+ )
+ llm_ranking_llm_profile: ProfileName = Field(default="default", description="LLM profile for LLM ranking.")
class MemorizeConfig(BaseModel):
- category_assign_threshold: float = Field(default=0.25)
+ category_assign_threshold: float = Field(default=0.25, ge=0, le=1)
multimodal_preprocess_prompts: dict[str, str | CustomPrompt] = Field(
default_factory=dict,
description="Optional mapping of modality -> preprocess system prompt.",
)
- preprocess_llm_profile: str = Field(default="default", description="LLM profile for preprocess.")
+ preprocess_llm_profile: ProfileName = Field(default="default", description="LLM profile for preprocess.")
memory_types: list[str] = Field(
default_factory=_default_memory_types,
- description="Ordered list of memory types (profile/event/knowledge/behavior by default).",
+ description="Ordered list of memory types (profile/event/knowledge/behavior/skill/tool by default).",
)
memory_type_prompts: dict[str, str | Annotated[CustomPrompt, CompleteMemoryTypePrompt]] = Field(
default_factory=_default_memory_type_prompts,
description="User prompt overrides for each memory type extraction.",
)
- memory_extract_llm_profile: str = Field(default="default", description="LLM profile for memory extract.")
+ memory_extract_llm_profile: ProfileName = Field(
+ default="default",
+ description="LLM profile for memory extract.",
+ )
memory_categories: list[CategoryConfig] = Field(
default_factory=_default_memory_categories,
description="Global memory category definitions embedded at service startup.",
@@ -228,9 +256,13 @@ class MemorizeConfig(BaseModel):
)
default_category_summary_target_length: int = Field(
default=400,
+ ge=1,
description="Target max length for auto-generated category summaries.",
)
- category_update_llm_profile: str = Field(default="default", description="LLM profile for category summary.")
+ category_update_llm_profile: ProfileName = Field(
+ default="default",
+ description="LLM profile for category summary.",
+ )
# Reference tracking for category summaries
enable_item_references: bool = Field(
default=False,
@@ -257,10 +289,7 @@ class UserConfig(BaseModel):
model: type[BaseModel] = Field(default=DefaultUserModel)
-Key = Annotated[str, StringConstraints(min_length=1)]
-
-
-class LLMProfilesConfig(RootModel[dict[Key, LLMConfig]]):
+class LLMProfilesConfig(RootModel[dict[ProfileName, LLMConfig]]):
root: dict[str, LLMConfig] = Field(default_factory=lambda: {"default": LLMConfig()})
def get(self, key: str, default: LLMConfig | None = None) -> LLMConfig | None:
@@ -278,7 +307,7 @@ def ensure_default(cls, data: Any) -> Any:
if data is None:
data = {}
elif isinstance(data, dict):
- data = dict(data)
+ data = {key.strip() if isinstance(key, str) else key: value for key, value in data.items()}
else:
return data
if "default" not in data:
@@ -299,7 +328,10 @@ def default(self) -> LLMConfig:
class MetadataStoreConfig(BaseModel):
provider: Annotated[Literal["inmemory", "postgres", "sqlite"], Normalize] = "inmemory"
ddl_mode: Annotated[Literal["create", "validate"], Normalize] = "create"
- dsn: str | None = Field(default=None, description="Database connection string (required for postgres/sqlite).")
+ dsn: str | None = Field(
+ default=None,
+ description="Database connection string. Required for postgres; optional for sqlite.",
+ )
class VectorIndexConfig(BaseModel):
diff --git a/src/memu/client/openai_wrapper.py b/src/memu/client/openai_wrapper.py
index 5c295f88..878c841a 100644
--- a/src/memu/client/openai_wrapper.py
+++ b/src/memu/client/openai_wrapper.py
@@ -8,12 +8,56 @@
from __future__ import annotations
import asyncio
+import inspect
from typing import TYPE_CHECKING, Any
+from memu.utils.retrieve import normalize_retrieve_ranking
+
if TYPE_CHECKING:
from memu.app.service import MemoryService
+def _normalize_top_k(top_k: int) -> int:
+ if isinstance(top_k, bool) or not isinstance(top_k, int) or top_k <= 0:
+ msg = "top_k must be a positive integer"
+ raise ValueError(msg)
+ return top_k
+
+
+def _copy_user_data(user_data: dict[str, Any]) -> dict[str, Any]:
+ return dict(user_data)
+
+
+def _extract_text_from_message_content(content: Any) -> str:
+ if isinstance(content, str):
+ return content
+ if isinstance(content, dict):
+ text = content.get("text")
+ return text if isinstance(text, str) else ""
+ if isinstance(content, list):
+ chunks: list[str] = []
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ text = part.get("text")
+ if isinstance(text, str) and text:
+ chunks.append(text)
+ return "\n".join(chunks)
+ return ""
+
+
+def _append_recall_context(content: Any, recall_context: str) -> str | list[Any]:
+ if isinstance(content, str):
+ return content + recall_context
+ if isinstance(content, list):
+ copied_parts = [dict(part) if isinstance(part, dict) else part for part in content]
+ copied_parts.append({"type": "text", "text": recall_context.lstrip("\n")})
+ return copied_parts
+ if content is None:
+ return recall_context.lstrip("\n")
+ return f"{content}{recall_context}"
+
+
class MemuChatCompletions:
"""Wrapper for chat.completions that injects recalled memories."""
@@ -27,22 +71,15 @@ def __init__(
):
self._original = original_completions
self._service = service
- self._user_data = user_data
- self._ranking = ranking
- self._top_k = top_k
+ self._user_data = _copy_user_data(user_data)
+ self._ranking = normalize_retrieve_ranking(ranking, default="salience")
+ self._top_k = _normalize_top_k(top_k)
def _extract_user_query(self, messages: list[dict]) -> str:
"""Extract the most recent user message."""
for msg in reversed(messages):
if msg.get("role") == "user":
- content = msg.get("content", "")
- if isinstance(content, str):
- return content
- # Handle content as list (vision models)
- if isinstance(content, list):
- for part in content:
- if isinstance(part, dict) and part.get("type") == "text":
- return part.get("text", "")
+ return _extract_text_from_message_content(msg.get("content", ""))
return ""
def _inject_memories(self, messages: list[dict], memories: list[dict]) -> list[dict]:
@@ -64,7 +101,7 @@ def _inject_memories(self, messages: list[dict], memories: list[dict]) -> list[d
# Inject into system message or create one
if messages and messages[0].get("role") == "system":
- messages[0]["content"] = messages[0]["content"] + recall_context
+ messages[0]["content"] = _append_recall_context(messages[0].get("content"), recall_context)
else:
messages.insert(0, {"role": "system", "content": recall_context.lstrip("\n")})
@@ -75,9 +112,13 @@ async def _retrieve_memories(self, query: str) -> list[dict]:
try:
result = await self._service.retrieve(
queries=[{"role": "user", "content": query}],
- where=self._user_data,
+ where=_copy_user_data(self._user_data),
+ ranking=self._ranking,
)
- return result.get("items", [])
+ items = result.get("items", [])
+ if not isinstance(items, list):
+ return []
+ return items[: self._top_k]
except Exception:
# Fail silently - don't break the LLM call
return []
@@ -119,8 +160,12 @@ async def acreate(self, **kwargs) -> Any:
# Call original async method if available
if hasattr(self._original, "acreate"):
- return await self._original.acreate(**kwargs)
- return self._original.create(**kwargs)
+ result = self._original.acreate(**kwargs)
+ else:
+ result = self._original.create(**kwargs)
+ if inspect.isawaitable(result):
+ return await result
+ return result
def __getattr__(self, name: str) -> Any:
"""Proxy all other attributes to original."""
@@ -192,21 +237,21 @@ def __init__(
service: memU MemoryService instance
user_data: User scope data (user_id, agent_id, session_id, etc.)
ranking: Retrieval ranking strategy ("similarity" or "salience")
- top_k: Number of memories to retrieve
+ top_k: Maximum number of recalled memory items to inject
"""
self._client = client
self._service = service
- self._user_data = user_data
- self._ranking = ranking
- self._top_k = top_k
+ self._user_data = _copy_user_data(user_data)
+ self._ranking = normalize_retrieve_ranking(ranking, default="salience")
+ self._top_k = _normalize_top_k(top_k)
# Wrap chat namespace
self.chat = MemuChat(
client.chat,
service,
- user_data,
- ranking,
- top_k,
+ self._user_data,
+ self._ranking,
+ self._top_k,
)
def __getattr__(self, name: str) -> Any:
@@ -235,7 +280,7 @@ def wrap_openai(
agent_id: Agent identifier (for multi-agent scoping)
session_id: Session identifier
ranking: Retrieval ranking ("similarity" or "salience")
- top_k: Number of memories to retrieve
+ top_k: Maximum number of recalled memory items to inject
Returns:
Wrapped client with auto-recall enabled
@@ -257,12 +302,14 @@ def wrap_openai(
)
"""
if user_data is None:
- user_data = {}
+ scope: dict[str, Any] = {}
+ else:
+ scope = _copy_user_data(user_data)
if user_id:
- user_data["user_id"] = user_id
+ scope["user_id"] = user_id
if agent_id:
- user_data["agent_id"] = agent_id
+ scope["agent_id"] = agent_id
if session_id:
- user_data["session_id"] = session_id
+ scope["session_id"] = session_id
- return MemuOpenAIWrapper(client, service, user_data, ranking, top_k)
+ return MemuOpenAIWrapper(client, service, scope, ranking, top_k)
diff --git a/src/memu/database/inmemory/repositories/category_item_repo.py b/src/memu/database/inmemory/repositories/category_item_repo.py
index 32e03fb2..203e1471 100644
--- a/src/memu/database/inmemory/repositories/category_item_repo.py
+++ b/src/memu/database/inmemory/repositories/category_item_repo.py
@@ -21,6 +21,14 @@ def list_relations(self, where: Mapping[str, Any] | None = None) -> list[Categor
return list(self.relations)
return [rel for rel in self.relations if matches_where(rel, where)]
+ def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[CategoryItem]:
+ deleted = self.list_relations(where)
+ if not deleted:
+ return []
+ deleted_ids = {rel.id for rel in deleted}
+ self.relations[:] = [rel for rel in self.relations if rel.id not in deleted_ids]
+ return deleted
+
def link_item_category(self, item_id: str, cat_id: str, user_data: dict[str, Any]) -> CategoryItem:
_ = item_id # enforced by caller via existing state
for rel in self.relations:
@@ -39,7 +47,9 @@ def get_item_categories(self, item_id: str) -> list[CategoryItem]:
@override
def unlink_item_category(self, item_id: str, cat_id: str) -> None:
- self.relations = [rel for rel in self.relations if not (rel.item_id == item_id and rel.category_id == cat_id)]
+ self.relations[:] = [
+ rel for rel in self.relations if not (rel.item_id == item_id and rel.category_id == cat_id)
+ ]
__all__ = ["InMemoryCategoryItemRepository"]
diff --git a/src/memu/database/inmemory/repositories/filter.py b/src/memu/database/inmemory/repositories/filter.py
index ad245cc7..71faa4ad 100644
--- a/src/memu/database/inmemory/repositories/filter.py
+++ b/src/memu/database/inmemory/repositories/filter.py
@@ -3,6 +3,8 @@
from collections.abc import Mapping
from typing import Any
+from memu.utils.filtering import normalize_filter_value, split_filter_key
+
def matches_where(obj: Any, where: Mapping[str, Any] | None) -> bool:
"""Basic field/`__in` matcher for in-memory repos."""
@@ -11,7 +13,8 @@ def matches_where(obj: Any, where: Mapping[str, Any] | None) -> bool:
for raw_key, expected in where.items():
if expected is None:
continue
- field, op = [*raw_key.split("__", 1), None][:2]
+ field, op = split_filter_key(raw_key)
+ expected = normalize_filter_value(field, op, expected)
actual = getattr(obj, str(field), None)
if op == "in":
if isinstance(expected, str):
diff --git a/src/memu/database/inmemory/repositories/memory_category_repo.py b/src/memu/database/inmemory/repositories/memory_category_repo.py
index bb07ec10..04ac1b1e 100644
--- a/src/memu/database/inmemory/repositories/memory_category_repo.py
+++ b/src/memu/database/inmemory/repositories/memory_category_repo.py
@@ -29,7 +29,8 @@ def clear_categories(self, where: Mapping[str, Any] | None = None) -> dict[str,
self.categories.clear()
return matches
matches = {cid: cat for cid, cat in self.categories.items() if matches_where(cat, where)}
- self.categories = {cid: cat for cid, cat in self.categories.items() if cid not in matches}
+ for cat_id in matches:
+ self.categories.pop(cat_id, None)
return matches
def get_or_create_category(
diff --git a/src/memu/database/inmemory/repositories/memory_item_repo.py b/src/memu/database/inmemory/repositories/memory_item_repo.py
index da28e14f..c67fe7a7 100644
--- a/src/memu/database/inmemory/repositories/memory_item_repo.py
+++ b/src/memu/database/inmemory/repositories/memory_item_repo.py
@@ -56,7 +56,8 @@ def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, Memor
self.items.clear()
return matches
matches = {mid: item for mid, item in self.items.items() if matches_where(item, where)}
- self.items = {mid: item for mid, item in self.items.items() if mid not in matches}
+ for item_id in matches:
+ self.items.pop(item_id, None)
return matches
def _find_by_hash(self, content_hash: str, user_data: dict[str, Any]) -> MemoryItem | None:
@@ -79,7 +80,7 @@ def _find_by_hash(self, content_hash: str, user_data: dict[str, Any]) -> MemoryI
def create_item(
self,
*,
- resource_id: str,
+ resource_id: str | None = None,
memory_type: MemoryType,
summary: str,
embedding: list[float],
@@ -122,7 +123,7 @@ def create_item(
def create_item_reinforce(
self,
*,
- resource_id: str,
+ resource_id: str | None = None,
memory_type: MemoryType,
summary: str,
embedding: list[float],
diff --git a/src/memu/database/inmemory/repositories/resource_repo.py b/src/memu/database/inmemory/repositories/resource_repo.py
index ba60e52b..26c4f266 100644
--- a/src/memu/database/inmemory/repositories/resource_repo.py
+++ b/src/memu/database/inmemory/repositories/resource_repo.py
@@ -27,7 +27,8 @@ def clear_resources(self, where: Mapping[str, Any] | None = None) -> dict[str, R
self.resources.clear()
return matches
matches = {rid: res for rid, res in self.resources.items() if matches_where(res, where)}
- self.resources = {rid: res for rid, res in self.resources.items() if rid not in matches}
+ for res_id in matches:
+ self.resources.pop(res_id, None)
return matches
def create_resource(
diff --git a/src/memu/database/inmemory/vector.py b/src/memu/database/inmemory/vector.py
index cd5355c7..001b53e5 100644
--- a/src/memu/database/inmemory/vector.py
+++ b/src/memu/database/inmemory/vector.py
@@ -58,6 +58,9 @@ def cosine_topk(
corpus: Iterable[tuple[str, list[float] | None]],
k: int = 5,
) -> list[tuple[str, float]]:
+ if k <= 0:
+ return []
+
# Filter out None vectors and collect valid entries
ids: list[str] = []
vecs: list[list[float]] = []
@@ -111,6 +114,9 @@ def cosine_topk_salience(
Returns:
List of (id, salience_score) tuples, sorted by score descending
"""
+ if k <= 0:
+ return []
+
q = np.array(query_vec, dtype=np.float32)
scored: list[tuple[str, float]] = []
diff --git a/src/memu/database/models.py b/src/memu/database/models.py
index 0124b784..bc7a8cb2 100644
--- a/src/memu/database/models.py
+++ b/src/memu/database/models.py
@@ -9,32 +9,16 @@
import pendulum
from pydantic import BaseModel, ConfigDict, Field
-MemoryType = Literal["profile", "event", "knowledge", "behavior", "skill", "tool"]
-
-
-def compute_content_hash(summary: str, memory_type: str) -> str:
- """
- Generate unique hash for memory deduplication.
-
- Operates on post-summary content. Normalizes whitespace to handle
- minor formatting differences like "I love coffee" vs "I love coffee".
+from memu.utils.dedupe import compute_content_hash
- Args:
- summary: The memory summary text
- memory_type: The type of memory (profile, event, etc.)
-
- Returns:
- A 16-character hex hash string
- """
- # Normalize: lowercase, strip, collapse whitespace
- normalized = " ".join(summary.lower().split())
- content = f"{memory_type}:{normalized}"
- return hashlib.sha256(content.encode()).hexdigest()[:16]
+MemoryType = Literal["profile", "event", "knowledge", "behavior", "skill", "tool"]
class BaseRecord(BaseModel):
"""Backend-agnostic record interface."""
+ model_config = ConfigDict(extra="allow")
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
created_at: datetime = Field(default_factory=lambda: pendulum.now("UTC"))
updated_at: datetime = Field(default_factory=lambda: pendulum.now("UTC"))
@@ -79,7 +63,7 @@ class MemoryItem(BaseRecord):
summary: str
embedding: list[float] | None = None
happened_at: datetime | None = None
- extra: dict[str, Any] = {}
+ extra: dict[str, Any] = Field(default_factory=dict)
# extra may contain:
# # reinforcement tracking fields
# - content_hash: str
diff --git a/src/memu/database/postgres/models.py b/src/memu/database/postgres/models.py
index e83797a2..4e083e0f 100644
--- a/src/memu/database/postgres/models.py
+++ b/src/memu/database/postgres/models.py
@@ -6,11 +6,12 @@
import pendulum
+from memu.database.postgres.optional import postgres_extra_import_error
+
try:
from pgvector.sqlalchemy import VECTOR as Vector
except ImportError as exc:
- msg = "pgvector is required for Postgres vector support"
- raise ImportError(msg) from exc
+ raise postgres_extra_import_error() from exc
from pydantic import BaseModel
from sqlalchemy import ForeignKey, MetaData, String, Text
@@ -57,7 +58,7 @@ class MemoryItemModel(BaseModelMixin, MemoryItem):
summary: str = Field(sa_column=Column(Text, nullable=False))
embedding: list[float] | None = Field(default=None, sa_column=Column(Vector(), nullable=True))
happened_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
- extra: dict[str, Any] = Field(default={}, sa_column=Column(JSONB, nullable=True))
+ extra: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSONB, nullable=True))
class MemoryCategoryModel(BaseModelMixin, MemoryCategory):
diff --git a/src/memu/database/postgres/optional.py b/src/memu/database/postgres/optional.py
new file mode 100644
index 00000000..c7d5bde9
--- /dev/null
+++ b/src/memu/database/postgres/optional.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+POSTGRES_EXTRA_INSTALL_HINT = (
+ "Postgres storage requires the optional Postgres dependencies. "
+ "Install them with `pip install 'memu-py[postgres]'`, or run "
+ "`uv sync --extra postgres` from a source checkout."
+)
+
+
+def postgres_extra_import_error() -> ImportError:
+ return ImportError(POSTGRES_EXTRA_INSTALL_HINT)
+
+
+__all__ = ["POSTGRES_EXTRA_INSTALL_HINT", "postgres_extra_import_error"]
diff --git a/src/memu/database/postgres/repositories/base.py b/src/memu/database/postgres/repositories/base.py
index 0823dbf8..32745933 100644
--- a/src/memu/database/postgres/repositories/base.py
+++ b/src/memu/database/postgres/repositories/base.py
@@ -7,6 +7,7 @@
import pendulum
from memu.database.postgres.session import SessionManager
+from memu.utils.filtering import normalize_filter_value, split_filter_key
from memu.database.state import DatabaseState
logger = logging.getLogger(__name__)
@@ -71,7 +72,8 @@ def _build_filters(self, model: Any, where: Mapping[str, Any] | None) -> list[An
for raw_key, expected in where.items():
if expected is None:
continue
- field, op = [*raw_key.split("__", 1), None][:2]
+ field, op = split_filter_key(raw_key)
+ expected = normalize_filter_value(field, op, expected)
column = getattr(model, str(field), None)
if column is None:
msg = f"Unknown filter field '{field}' for model '{model.__name__}'"
@@ -92,7 +94,8 @@ def _matches_where(obj: Any, where: Mapping[str, Any] | None) -> bool:
for raw_key, expected in where.items():
if expected is None:
continue
- field, op = [*raw_key.split("__", 1), None][:2]
+ field, op = split_filter_key(raw_key)
+ expected = normalize_filter_value(field, op, expected)
actual = getattr(obj, str(field), None)
if op == "in":
if isinstance(expected, str):
diff --git a/src/memu/database/postgres/repositories/category_item_repo.py b/src/memu/database/postgres/repositories/category_item_repo.py
index 90409807..1affd21a 100644
--- a/src/memu/database/postgres/repositories/category_item_repo.py
+++ b/src/memu/database/postgres/repositories/category_item_repo.py
@@ -32,6 +32,24 @@ def list_relations(self, where: Mapping[str, Any] | None = None) -> list[Categor
rows = session.scalars(select(self._sqla_models.CategoryItem).where(*filters)).all()
return [self._cache_relation(row) for row in rows]
+ def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[CategoryItem]:
+ from sqlmodel import delete, select
+
+ filters = self._build_filters(self._sqla_models.CategoryItem, where)
+ with self._sessions.session() as session:
+ rows = session.scalars(select(self._sqla_models.CategoryItem).where(*filters)).all()
+ deleted = list(rows)
+
+ if not deleted:
+ return []
+
+ session.exec(delete(self._sqla_models.CategoryItem).where(*filters))
+ session.commit()
+
+ deleted_ids = {rel.id for rel in deleted}
+ self.relations[:] = [rel for rel in self.relations if rel.id not in deleted_ids]
+ return deleted
+
def link_item_category(self, item_id: str, cat_id: str, user_data: dict[str, Any]) -> CategoryItem:
from sqlmodel import select
@@ -76,6 +94,9 @@ def unlink_item_category(self, item_id: str, cat_id: str) -> None:
)
)
session.commit()
+ self.relations[:] = [
+ rel for rel in self.relations if not (rel.item_id == item_id and rel.category_id == cat_id)
+ ]
def get_item_categories(self, item_id: str) -> list[CategoryItem]:
from sqlmodel import select
@@ -95,6 +116,10 @@ def load_existing(self) -> None:
self._cache_relation(row)
def _cache_relation(self, rel: CategoryItem) -> CategoryItem:
+ for idx, existing in enumerate(self.relations):
+ if existing.id == rel.id:
+ self.relations[idx] = rel
+ return rel
self.relations.append(rel)
return rel
diff --git a/src/memu/database/postgres/repositories/memory_category_repo.py b/src/memu/database/postgres/repositories/memory_category_repo.py
index 229cd200..614a2cb3 100644
--- a/src/memu/database/postgres/repositories/memory_category_repo.py
+++ b/src/memu/database/postgres/repositories/memory_category_repo.py
@@ -57,8 +57,12 @@ def clear_categories(self, where: Mapping[str, Any] | None = None) -> dict[str,
session.commit()
# Clean up cache
- for cat_id in deleted:
+ deleted_category_ids = set(deleted)
+ for cat_id in deleted_category_ids:
self.categories.pop(cat_id, None)
+ self._state.relations[:] = [
+ rel for rel in self._state.relations if rel.category_id not in deleted_category_ids
+ ]
return deleted
diff --git a/src/memu/database/postgres/repositories/memory_item_repo.py b/src/memu/database/postgres/repositories/memory_item_repo.py
index 6d04f61b..5b9953c6 100644
--- a/src/memu/database/postgres/repositories/memory_item_repo.py
+++ b/src/memu/database/postgres/repositories/memory_item_repo.py
@@ -105,8 +105,10 @@ def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, Memor
session.commit()
# Clean up cache
- for item_id in deleted:
+ deleted_item_ids = set(deleted)
+ for item_id in deleted_item_ids:
self.items.pop(item_id, None)
+ self._drop_relation_cache_for_items(deleted_item_ids)
return deleted
@@ -276,6 +278,8 @@ def delete_item(self, item_id: str) -> None:
with self._sessions.session() as session:
session.exec(delete(self._sqla_models.MemoryItem).where(self._sqla_models.MemoryItem.id == item_id))
session.commit()
+ self.items.pop(item_id, None)
+ self._drop_relation_cache_for_items({item_id})
def vector_search_items(
self,
@@ -286,6 +290,8 @@ def vector_search_items(
ranking: str = "similarity",
recency_decay_days: float = 30.0,
) -> list[tuple[str, float]]:
+ if top_k <= 0:
+ return []
if not self._use_vector or ranking == "salience":
# For salience ranking or when pgvector is not available, use local search
return self._vector_search_local(
@@ -326,11 +332,10 @@ def _vector_search_local(
recency_decay_days: float = 30.0,
) -> list[tuple[str, float]]:
scored: list[tuple[str, float]] = []
- for item in self.items.values():
+ pool = self.list_items(where)
+ for item in pool.values():
if item.embedding is None:
continue
- if not self._matches_where(item, where):
- continue
similarity = self._cosine(query_vec, item.embedding)
@@ -376,6 +381,13 @@ def _cache_item(self, item: MemoryItem) -> MemoryItem:
self.items[item.id] = item
return item
+ def _drop_relation_cache_for_items(self, item_ids: set[str]) -> None:
+ if not item_ids:
+ return
+ self._state.relations[:] = [
+ rel for rel in self._state.relations if rel.item_id not in item_ids
+ ]
+
@staticmethod
def _parse_datetime(dt_str: str | None) -> datetime | None:
"""Parse ISO datetime string from extra dict."""
diff --git a/src/memu/database/postgres/repositories/resource_repo.py b/src/memu/database/postgres/repositories/resource_repo.py
index d358febc..961afff0 100644
--- a/src/memu/database/postgres/repositories/resource_repo.py
+++ b/src/memu/database/postgres/repositories/resource_repo.py
@@ -57,8 +57,20 @@ def clear_resources(self, where: Mapping[str, Any] | None = None) -> dict[str, R
session.commit()
# Clean up cache
- for res_id in deleted:
+ deleted_resource_ids = set(deleted)
+ for res_id in deleted_resource_ids:
self.resources.pop(res_id, None)
+ deleted_item_ids = {
+ item_id
+ for item_id, item in self._state.items.items()
+ if item.resource_id in deleted_resource_ids
+ }
+ for item_id in deleted_item_ids:
+ self._state.items.pop(item_id, None)
+ if deleted_item_ids:
+ self._state.relations[:] = [
+ rel for rel in self._state.relations if rel.item_id not in deleted_item_ids
+ ]
return deleted
diff --git a/src/memu/database/postgres/schema.py b/src/memu/database/postgres/schema.py
index ac6e8b52..eca21237 100644
--- a/src/memu/database/postgres/schema.py
+++ b/src/memu/database/postgres/schema.py
@@ -5,6 +5,8 @@
from pydantic import BaseModel
+from memu.database.postgres.optional import postgres_extra_import_error
+
try:
from sqlmodel import SQLModel
except ImportError as exc:
@@ -20,8 +22,7 @@
try:
from pgvector.sqlalchemy import VECTOR as Vector
except ImportError as exc:
- msg = "pgvector is required for Postgres vector support"
- raise ImportError(msg) from exc
+ raise postgres_extra_import_error() from exc
from memu.database.postgres.models import (
CategoryItemModel,
@@ -72,6 +73,7 @@ def get_sqlalchemy_models(*, scope_model: type[BaseModel] | None = None) -> SQLA
MemoryCategoryModel,
tablename="memory_categories",
metadata=metadata_obj,
+ unique_with_scope=["name"],
)
memory_item_model = build_table_model(
scope,
diff --git a/src/memu/database/repositories/category_item.py b/src/memu/database/repositories/category_item.py
index 582a2845..49001a64 100644
--- a/src/memu/database/repositories/category_item.py
+++ b/src/memu/database/repositories/category_item.py
@@ -14,6 +14,8 @@ class CategoryItemRepo(Protocol):
def list_relations(self, where: Mapping[str, Any] | None = None) -> list[CategoryItem]: ...
+ def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[CategoryItem]: ...
+
def link_item_category(self, item_id: str, cat_id: str, user_data: dict[str, Any]) -> CategoryItem: ...
def unlink_item_category(self, item_id: str, cat_id: str) -> None: ...
diff --git a/src/memu/database/repositories/memory_item.py b/src/memu/database/repositories/memory_item.py
index 39bb856b..a2124005 100644
--- a/src/memu/database/repositories/memory_item.py
+++ b/src/memu/database/repositories/memory_item.py
@@ -21,7 +21,7 @@ def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, Memor
def create_item(
self,
*,
- resource_id: str,
+ resource_id: str | None = None,
memory_type: MemoryType,
summary: str,
embedding: list[float],
diff --git a/src/memu/database/sqlite/models.py b/src/memu/database/sqlite/models.py
index 6cdaed49..aa405d0d 100644
--- a/src/memu/database/sqlite/models.py
+++ b/src/memu/database/sqlite/models.py
@@ -84,7 +84,7 @@ class SQLiteMemoryItemModel(SQLiteBaseModelMixin, MemoryItem):
# Store embedding as JSON string since SQLite doesn't have native vector type
embedding_json: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
happened_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
- extra: dict[str, Any] = Field(default={}, sa_column=Column(JSON, nullable=True))
+ extra: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=True))
@property
def embedding(self) -> list[float] | None:
diff --git a/src/memu/database/sqlite/repositories/base.py b/src/memu/database/sqlite/repositories/base.py
index 44099859..6dc1834f 100644
--- a/src/memu/database/sqlite/repositories/base.py
+++ b/src/memu/database/sqlite/repositories/base.py
@@ -10,6 +10,7 @@
import pendulum
from memu.database.sqlite.session import SQLiteSessionManager
+from memu.utils.filtering import normalize_filter_value, split_filter_key
from memu.database.state import DatabaseState
logger = logging.getLogger(__name__)
@@ -85,7 +86,8 @@ def _build_filters(self, model: Any, where: Mapping[str, Any] | None) -> list[An
for raw_key, expected in where.items():
if expected is None:
continue
- field, op = [*raw_key.split("__", 1), None][:2]
+ field, op = split_filter_key(raw_key)
+ expected = normalize_filter_value(field, op, expected)
column = getattr(model, str(field), None)
if column is None:
msg = f"Unknown filter field '{field}' for model '{model.__name__}'"
@@ -107,7 +109,8 @@ def _matches_where(obj: Any, where: Mapping[str, Any] | None) -> bool:
for raw_key, expected in where.items():
if expected is None:
continue
- field, op = [*raw_key.split("__", 1), None][:2]
+ field, op = split_filter_key(raw_key)
+ expected = normalize_filter_value(field, op, expected)
actual = getattr(obj, str(field), None)
if op == "in":
if isinstance(expected, str):
diff --git a/src/memu/database/sqlite/repositories/category_item_repo.py b/src/memu/database/sqlite/repositories/category_item_repo.py
index f6996650..cc0de064 100644
--- a/src/memu/database/sqlite/repositories/category_item_repo.py
+++ b/src/memu/database/sqlite/repositories/category_item_repo.py
@@ -6,7 +6,7 @@
from collections.abc import Mapping
from typing import Any
-from sqlmodel import select
+from sqlmodel import delete, select
from memu.database.models import CategoryItem
from memu.database.repositories.category_item import CategoryItemRepo
@@ -66,21 +66,35 @@ def list_relations(self, where: Mapping[str, Any] | None = None) -> list[Categor
result: list[CategoryItem] = []
for row in rows:
- rel = CategoryItem(
- id=row.id,
- item_id=row.item_id,
- category_id=row.category_id,
- created_at=row.created_at,
- updated_at=row.updated_at,
- **self._scope_kwargs_from(row),
- )
+ rel = self._relation_from_row(row)
result.append(rel)
- # Update cache
- if not any(r.id == rel.id for r in self.relations):
- self.relations.append(rel)
+ self._cache_relation(rel)
return result
+ def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[CategoryItem]:
+ """Clear category-item relations matching the where clause."""
+ filters = self._build_filters(self._category_item_model, where)
+ with self._sessions.session() as session:
+ stmt = select(self._category_item_model)
+ if filters:
+ stmt = stmt.where(*filters)
+ rows = session.exec(stmt).all()
+ deleted = [self._relation_from_row(row) for row in rows]
+
+ if not deleted:
+ return []
+
+ del_stmt = delete(self._category_item_model)
+ if filters:
+ del_stmt = del_stmt.where(*filters)
+ session.exec(del_stmt)
+ session.commit()
+
+ deleted_ids = {rel.id for rel in deleted}
+ self.relations[:] = [rel for rel in self.relations if rel.id not in deleted_ids]
+ return deleted
+
def link_item_category(self, item_id: str, category_id: str, user_data: dict[str, Any]) -> CategoryItem:
"""Create a link between an item and a category.
@@ -106,15 +120,7 @@ def link_item_category(self, item_id: str, category_id: str, user_data: dict[str
existing = session.exec(stmt).first()
if existing:
- rel = CategoryItem(
- id=existing.id,
- item_id=existing.item_id,
- category_id=existing.category_id,
- created_at=existing.created_at,
- updated_at=existing.updated_at,
- **self._scope_kwargs_from(existing),
- )
- return rel
+ return self._cache_relation(self._relation_from_row(existing))
# Create new relation
now = self._now()
@@ -129,16 +135,7 @@ def link_item_category(self, item_id: str, category_id: str, user_data: dict[str
session.commit()
session.refresh(row)
- rel = CategoryItem(
- id=row.id,
- item_id=row.item_id,
- category_id=row.category_id,
- created_at=row.created_at,
- updated_at=row.updated_at,
- **user_data,
- )
- self.relations.append(rel)
- return rel
+ return self._cache_relation(self._relation_from_row(row))
def unlink_item_category(self, item_id: str, category_id: str) -> None:
"""Remove a link between an item and a category.
@@ -176,5 +173,23 @@ def load_existing(self) -> None:
"""Load all existing relations from database into cache."""
self.list_relations()
+ def _relation_from_row(self, row: Any) -> CategoryItem:
+ return CategoryItem(
+ id=row.id,
+ item_id=row.item_id,
+ category_id=row.category_id,
+ created_at=row.created_at,
+ updated_at=row.updated_at,
+ **self._scope_kwargs_from(row),
+ )
+
+ def _cache_relation(self, rel: CategoryItem) -> CategoryItem:
+ for idx, existing in enumerate(self.relations):
+ if existing.id == rel.id:
+ self.relations[idx] = rel
+ return rel
+ self.relations.append(rel)
+ return rel
+
__all__ = ["SQLiteCategoryItemRepo"]
diff --git a/src/memu/database/sqlite/repositories/memory_item_repo.py b/src/memu/database/sqlite/repositories/memory_item_repo.py
index 0bff124e..1ca32481 100644
--- a/src/memu/database/sqlite/repositories/memory_item_repo.py
+++ b/src/memu/database/sqlite/repositories/memory_item_repo.py
@@ -211,7 +211,7 @@ def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, Memor
def create_item(
self,
*,
- resource_id: str,
+ resource_id: str | None = None,
memory_type: MemoryType,
summary: str,
embedding: list[float],
@@ -285,7 +285,7 @@ def create_item(
def create_item_reinforce(
self,
*,
- resource_id: str,
+ resource_id: str | None = None,
memory_type: MemoryType,
summary: str,
embedding: list[float],
diff --git a/src/memu/database/sqlite/schema.py b/src/memu/database/sqlite/schema.py
index 63291cb1..24b97b2c 100644
--- a/src/memu/database/sqlite/schema.py
+++ b/src/memu/database/sqlite/schema.py
@@ -60,6 +60,7 @@ def get_sqlite_sqlalchemy_models(*, scope_model: type[BaseModel] | None = None)
SQLiteMemoryCategoryModel,
tablename="sqlite_memory_categories",
metadata=metadata_obj,
+ unique_with_scope=["name"],
)
memory_item_model = build_sqlite_table_model(
scope,
diff --git a/src/memu/embedding/http_client.py b/src/memu/embedding/http_client.py
index 0c3066a7..bef5f608 100644
--- a/src/memu/embedding/http_client.py
+++ b/src/memu/embedding/http_client.py
@@ -97,9 +97,10 @@ async def embed_multimodal(
List of embedding vectors
Example:
+ >>> import os
>>> client = HTTPEmbeddingClient(
... base_url="https://ark.cn-beijing.volces.com",
- ... api_key="your-api-key",
+ ... api_key=os.environ["DOUBAO_API_KEY"],
... embed_model="doubao-embedding-vision-250615",
... provider="doubao",
... )
diff --git a/src/memu/integrations/langgraph.py b/src/memu/integrations/langgraph.py
index 2e24ddc6..212a2e59 100644
--- a/src/memu/integrations/langgraph.py
+++ b/src/memu/integrations/langgraph.py
@@ -7,19 +7,26 @@
import os
import tempfile
import uuid
-from typing import Any
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Annotated, Any
-# MUST explicitly import langgraph to satisfy DEP002
-import langgraph
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, StringConstraints
-from memu.app.service import MemoryService
+if TYPE_CHECKING:
+ from langchain_core.tools import BaseTool, StructuredTool
+ from memu.app.service import MemoryService
try:
+ # Explicit import keeps the optional integration dependency visible to tooling.
+ import langgraph
from langchain_core.tools import BaseTool, StructuredTool
-except ImportError as e:
- msg = "Please install 'langchain-core' (and 'langgraph') to use the LangGraph integration."
- raise ImportError(msg) from e
+except ImportError as exc: # pragma: no cover - covered by optional-dependency smoke tests.
+ langgraph = None # type: ignore[assignment]
+ BaseTool = Any # type: ignore[misc, assignment]
+ StructuredTool = None # type: ignore[assignment]
+ _LANGGRAPH_IMPORT_ERROR: ImportError | None = exc
+else:
+ _LANGGRAPH_IMPORT_ERROR = None
# Setup logger
@@ -30,24 +37,55 @@ class MemUIntegrationError(Exception):
"""Base exception for MemU integration issues."""
+NonEmptyString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+
+
class SaveRecallInput(BaseModel):
"""Input schema for the save_memory tool."""
- content: str = Field(description="The text content or information to save/remember.")
- user_id: str = Field(description="The unique identifier of the user.")
+ content: NonEmptyString = Field(description="The text content or information to save/remember.")
+ user_id: NonEmptyString = Field(description="The unique identifier of the user.")
metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata related to the memory.")
class SearchRecallInput(BaseModel):
"""Input schema for the search_memory tool."""
- query: str = Field(description="The search query to retrieve relevant memories.")
- user_id: str = Field(description="The unique identifier of the user.")
- limit: int = Field(default=5, description="Number of memories to retrieve.")
+ query: NonEmptyString = Field(description="The search query to retrieve relevant memories.")
+ user_id: NonEmptyString = Field(description="The unique identifier of the user.")
+ limit: int = Field(default=5, ge=1, description="Number of memories to retrieve.")
metadata_filter: dict[str, Any] | None = Field(
default=None, description="Optional filter for memory metadata (e.g., {'category': 'work'})."
)
- min_relevance_score: float = Field(default=0.0, description="Minimum relevance score (0.0 to 1.0) for results.")
+ min_relevance_score: float = Field(
+ default=0.0,
+ ge=0.0,
+ le=1.0,
+ description="Minimum relevance score (0.0 to 1.0) for results.",
+ )
+
+
+def _ensure_langgraph_dependencies() -> None:
+ if _LANGGRAPH_IMPORT_ERROR is None:
+ return
+ msg = (
+ "Please install the LangGraph integration dependencies with "
+ "`pip install 'memu-py[langgraph]'`, or run `uv sync --extra langgraph` "
+ "from a source checkout."
+ )
+ raise ImportError(msg) from _LANGGRAPH_IMPORT_ERROR
+
+
+def _scope_with_user(user_id: str, metadata: Mapping[str, Any] | None = None) -> dict[str, Any]:
+ if not isinstance(user_id, str) or not user_id.strip():
+ msg = "user_id must be a non-empty string"
+ raise ValueError(msg)
+ if metadata is not None and not isinstance(metadata, Mapping):
+ msg = "metadata must be an object"
+ raise ValueError(msg)
+ scope = dict(metadata or {})
+ scope["user_id"] = user_id.strip()
+ return scope
class MemULangGraphTools:
@@ -59,6 +97,7 @@ class MemULangGraphTools:
def __init__(self, memory_service: MemoryService):
"""Initializes the MemULangGraphTools with a memory service."""
+ _ensure_langgraph_dependencies()
self.memory_service = memory_service
# Expose the langgraph module to ensure it's "used" even if just by reference in this class
self._graph_backend = langgraph
@@ -75,6 +114,8 @@ def save_memory_tool(self) -> StructuredTool:
async def _save(content: str, user_id: str, metadata: dict | None = None) -> str:
logger.info("Entering save_memory_tool for user_id: %s", user_id)
+ content = content.strip()
+ user_scope = _scope_with_user(user_id, metadata)
filename = f"memu_input_{uuid.uuid4()}.txt"
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, filename)
@@ -87,9 +128,9 @@ async def _save(content: str, user_id: str, metadata: dict | None = None) -> str
await self.memory_service.memorize(
resource_url=file_path,
modality="conversation",
- user={"user_id": user_id, **(metadata or {})},
+ user=user_scope,
)
- logger.info("Successfully saved memory for user_id: %s", user_id)
+ logger.info("Successfully saved memory for user_id: %s", user_scope["user_id"])
except Exception as e:
error_msg = f"Failed to save memory for user {user_id}: {e!s}"
logger.exception(error_msg)
@@ -122,10 +163,9 @@ async def _search(
) -> str:
logger.info("Entering search_memory_tool for user_id: %s, query: '%s'", user_id, query)
try:
+ query = query.strip()
queries = [{"role": "user", "content": query}]
- where_filter = {"user_id": user_id}
- if metadata_filter:
- where_filter.update(metadata_filter)
+ where_filter = _scope_with_user(user_id, metadata_filter)
logger.debug("Calling memory_service.retrieve with where_filter: %s", where_filter)
result = await self.memory_service.retrieve(
diff --git a/src/memu/llm/lazyllm_client.py b/src/memu/llm/lazyllm_client.py
index 8446b6a5..aae06204 100644
--- a/src/memu/llm/lazyllm_client.py
+++ b/src/memu/llm/lazyllm_client.py
@@ -1,9 +1,28 @@
import asyncio
import functools
+import logging
from typing import Any, cast
-import lazyllm
-from lazyllm import LOG
+try:
+ import lazyllm
+ from lazyllm import LOG
+except ImportError as exc: # pragma: no cover - covered by optional-dependency smoke tests.
+ lazyllm = None # type: ignore[assignment]
+ LOG = logging.getLogger("memu.llm.lazyllm")
+ _LAZYLLM_IMPORT_ERROR: ImportError | None = exc
+else:
+ _LAZYLLM_IMPORT_ERROR = None
+
+
+def _lazyllm_module() -> Any:
+ if _LAZYLLM_IMPORT_ERROR is None:
+ return lazyllm
+ msg = (
+ "Please install the LazyLLM backend dependencies with "
+ "`pip install 'memu-py[lazyllm]'`, or run `uv sync --extra lazyllm` "
+ "from a source checkout."
+ )
+ raise ImportError(msg) from _LAZYLLM_IMPORT_ERROR
class LazyLLMClient:
@@ -23,6 +42,7 @@ def __init__(
embed_model: str | None = None,
stt_model: str | None = None,
):
+ _lazyllm_module()
self.llm_source = llm_source or self.DEFAULT_SOURCE
self.vlm_source = vlm_source or self.DEFAULT_SOURCE
self.embed_source = embed_source or self.DEFAULT_SOURCE
@@ -59,7 +79,9 @@ async def chat(
Return:
The generated summary text as a string.
"""
- client = lazyllm.namespace("MEMU").OnlineModule(source=self.llm_source, model=self.chat_model, type="llm")
+ client = _lazyllm_module().namespace("MEMU").OnlineModule(
+ source=self.llm_source, model=self.chat_model, type="llm"
+ )
prompt = f"{system_prompt}\n\n" if system_prompt else ""
full_prompt = f"{prompt}text:\n{text}"
LOG.debug(f"Summarizing text with {self.llm_source}/{self.chat_model}")
@@ -83,7 +105,9 @@ async def summarize(
Return:
The generated summary text as a string.
"""
- client = lazyllm.namespace("MEMU").OnlineModule(source=self.llm_source, model=self.chat_model, type="llm")
+ client = _lazyllm_module().namespace("MEMU").OnlineModule(
+ source=self.llm_source, model=self.chat_model, type="llm"
+ )
prompt = system_prompt or "Summarize the text in one short paragraph."
full_prompt = f"{prompt}\n\ntext:\n{text}"
LOG.debug(f"Summarizing text with {self.llm_source}/{self.chat_model}")
@@ -110,7 +134,9 @@ async def vision(
Return:
A tuple containing the generated text response and None (reserved for metadata).
"""
- client = lazyllm.namespace("MEMU").OnlineModule(source=self.vlm_source, model=self.vlm_model, type="vlm")
+ client = _lazyllm_module().namespace("MEMU").OnlineModule(
+ source=self.vlm_source, model=self.vlm_model, type="vlm"
+ )
LOG.debug(f"Processing image with {self.vlm_source}/{self.vlm_model}: {image_path}")
# LazyLLM VLM accepts prompt as first positional argument and image_path as keyword argument
response = await self._call_async(client, prompt, lazyllm_files=image_path)
@@ -130,7 +156,7 @@ async def embed(
Return:
A list of embedding vectors (list of floats), one for each input text.
"""
- client = lazyllm.namespace("MEMU").OnlineModule(
+ client = _lazyllm_module().namespace("MEMU").OnlineModule(
source=self.embed_source, model=self.embed_model, type="embed", batch_size=batch_size
)
LOG.debug(f"embed {len(texts)} texts with {self.embed_source}/{self.embed_model}")
@@ -153,7 +179,12 @@ async def transcribe(
Return:
The transcribed text as a string.
"""
- client = lazyllm.namespace("MEMU").OnlineModule(source=self.stt_source, model=self.stt_model, type="stt")
+ client = _lazyllm_module().namespace("MEMU").OnlineModule(
+ source=self.stt_source, model=self.stt_model, type="stt"
+ )
LOG.debug(f"Transcribing audio with {self.stt_source}/{self.stt_model}: {audio_path}")
response = await self._call_async(client, audio_path)
return cast(str, response)
+
+
+__all__ = ["LazyLLMClient"]
diff --git a/src/memu/llm/openai_sdk.py b/src/memu/llm/openai_sdk.py
index 38c6c8bb..2bd7e9e7 100644
--- a/src/memu/llm/openai_sdk.py
+++ b/src/memu/llm/openai_sdk.py
@@ -152,22 +152,23 @@ async def vision(
logger.debug("OpenAI vision response: %s", response)
return content or "", response
- async def embed(self, inputs: list[str]) -> tuple[list[list[float]], CreateEmbeddingResponse | None]:
+ async def embed(
+ self, inputs: list[str]
+ ) -> tuple[list[list[float]], CreateEmbeddingResponse | list[CreateEmbeddingResponse] | None]:
"""Create text embeddings via the official SDK."""
if len(inputs) <= self.embed_batch_size:
response = await self.client.embeddings.create(model=self.embed_model, input=inputs)
return [cast(list[float], d.embedding) for d in response.data], response
- # For batched requests, we aggregate embeddings but only return the last response for usage
all_embeddings: list[list[float]] = []
- last_response: CreateEmbeddingResponse | None = None
+ raw_responses: list[CreateEmbeddingResponse] = []
for idx in range(0, len(inputs), self.embed_batch_size):
batch = inputs[idx : idx + self.embed_batch_size]
response = await self.client.embeddings.create(model=self.embed_model, input=batch)
all_embeddings.extend([cast(list[float], d.embedding) for d in response.data])
- last_response = response
+ raw_responses.append(response)
- return all_embeddings, last_response
+ return all_embeddings, raw_responses
async def transcribe(
self,
diff --git a/src/memu/llm/wrapper.py b/src/memu/llm/wrapper.py
index 175b78fc..85f41cd4 100644
--- a/src/memu/llm/wrapper.py
+++ b/src/memu/llm/wrapper.py
@@ -8,6 +8,8 @@
import uuid
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
+from datetime import date, datetime
+from enum import Enum
from pathlib import Path
from typing import Any
@@ -602,6 +604,14 @@ def _get_attr_or_key(obj: Any, key: str) -> Any:
return None
+def _get_first_attr_or_key(obj: Any, *keys: str) -> Any:
+ for key in keys:
+ value = _get_attr_or_key(obj, key)
+ if value is not None:
+ return value
+ return None
+
+
def _extract_finish_reason(raw_response: Any) -> str | None:
"""Extract finish_reason from choices[0] if available."""
choices = _get_attr_or_key(raw_response, "choices")
@@ -623,29 +633,66 @@ def _get_usage_object(raw_response: Any) -> Any:
def _convert_to_dict(obj: Any) -> dict[str, Any] | None:
"""Convert object to dict using available methods."""
if hasattr(obj, "model_dump"):
- result: dict[str, Any] = obj.model_dump()
- return result
+ result = _model_dump(obj)
+ return _json_safe_dict(result)
if hasattr(obj, "__dict__"):
- return dict(obj.__dict__)
+ return _json_safe_dict(dict(obj.__dict__))
if isinstance(obj, dict):
- return obj
+ return _json_safe_dict(obj)
return None
+def _json_safe_dict(value: Any) -> dict[str, Any] | None:
+ if not isinstance(value, dict):
+ return None
+ return {str(key): _json_safe_value(item) for key, item in value.items()}
+
+
+def _json_safe_value(value: Any) -> Any:
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, (datetime, date)):
+ return value.isoformat()
+ if isinstance(value, Enum):
+ return value.value
+ if hasattr(value, "model_dump"):
+ return _json_safe_value(_model_dump(value))
+ if isinstance(value, Mapping):
+ return {str(key): _json_safe_value(item) for key, item in value.items()}
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return [_json_safe_value(item) for item in value]
+ return str(value)
+
+
+def _model_dump(value: Any) -> Any:
+ try:
+ return value.model_dump(mode="json")
+ except TypeError:
+ return value.model_dump()
+
+
def _extract_token_details(usage_obj: Any, usage_data: dict[str, Any]) -> None:
"""Extract token breakdown and cached tokens from usage object."""
- completion_tokens_details = _get_attr_or_key(usage_obj, "completion_tokens_details")
- if completion_tokens_details is not None:
- breakdown = _convert_to_dict(completion_tokens_details)
+ output_tokens_details = _get_first_attr_or_key(
+ usage_obj,
+ "output_tokens_details",
+ "completion_tokens_details",
+ )
+ if output_tokens_details is not None:
+ breakdown = _convert_to_dict(output_tokens_details)
if breakdown is not None:
usage_data["tokens_breakdown"] = breakdown
- reasoning_tokens = _get_attr_or_key(completion_tokens_details, "reasoning_tokens")
+ reasoning_tokens = _get_attr_or_key(output_tokens_details, "reasoning_tokens")
if reasoning_tokens is not None:
usage_data["reasoning_tokens"] = reasoning_tokens
- prompt_tokens_details = _get_attr_or_key(usage_obj, "prompt_tokens_details")
- if prompt_tokens_details is not None:
- cached_tokens = _get_attr_or_key(prompt_tokens_details, "cached_tokens")
+ input_tokens_details = _get_first_attr_or_key(
+ usage_obj,
+ "input_tokens_details",
+ "prompt_tokens_details",
+ )
+ if input_tokens_details is not None:
+ cached_tokens = _get_attr_or_key(input_tokens_details, "cached_tokens")
if cached_tokens is not None:
usage_data["cached_input_tokens"] = cached_tokens
@@ -665,6 +712,8 @@ def _extract_usage_from_raw_response(kind: str, raw_response: Any) -> dict[str,
if raw_response is None:
return usage_data
+ if _is_raw_response_batch(raw_response):
+ return _extract_usage_from_raw_response_batch(kind, raw_response)
try:
finish_reason = _extract_finish_reason(raw_response)
@@ -675,17 +724,15 @@ def _extract_usage_from_raw_response(kind: str, raw_response: Any) -> dict[str,
if usage_obj is None:
return usage_data
- # Map prompt_tokens -> input_tokens
- prompt_tokens = _get_attr_or_key(usage_obj, "prompt_tokens")
- if prompt_tokens is not None:
- usage_data["input_tokens"] = prompt_tokens
+ # Normalize OpenAI-compatible chat-completions and responses-style usage names.
+ input_tokens = _get_first_attr_or_key(usage_obj, "input_tokens", "prompt_tokens")
+ if input_tokens is not None:
+ usage_data["input_tokens"] = input_tokens
- # Map completion_tokens -> output_tokens
- completion_tokens = _get_attr_or_key(usage_obj, "completion_tokens")
- if completion_tokens is not None:
- usage_data["output_tokens"] = completion_tokens
+ output_tokens = _get_first_attr_or_key(usage_obj, "output_tokens", "completion_tokens")
+ if output_tokens is not None:
+ usage_data["output_tokens"] = output_tokens
- # total_tokens stays the same
total_tokens = _get_attr_or_key(usage_obj, "total_tokens")
if total_tokens is not None:
usage_data["total_tokens"] = total_tokens
@@ -703,6 +750,44 @@ def _extract_usage_from_raw_response(kind: str, raw_response: Any) -> dict[str,
return usage_data
+def _is_raw_response_batch(raw_response: Any) -> bool:
+ return isinstance(raw_response, Sequence) and not isinstance(raw_response, (str, bytes, bytearray, dict))
+
+
+def _extract_usage_from_raw_response_batch(kind: str, raw_responses: Sequence[Any]) -> dict[str, Any]:
+ aggregated: dict[str, Any] = {}
+ token_fields = (
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "cached_input_tokens",
+ "reasoning_tokens",
+ )
+
+ for raw_response in raw_responses:
+ usage = _extract_usage_from_raw_response(kind, raw_response)
+ for field_name in token_fields:
+ value = usage.get(field_name)
+ if isinstance(value, int | float):
+ aggregated[field_name] = aggregated.get(field_name, 0) + value
+ if usage.get("finish_reason") is not None:
+ aggregated["finish_reason"] = usage["finish_reason"]
+ breakdown = usage.get("tokens_breakdown")
+ if isinstance(breakdown, dict):
+ _sum_token_breakdown(aggregated, breakdown)
+
+ return aggregated
+
+
+def _sum_token_breakdown(aggregated: dict[str, Any], breakdown: dict[str, Any]) -> None:
+ current = aggregated.setdefault("tokens_breakdown", {})
+ if not isinstance(current, dict):
+ return
+ for key, value in breakdown.items():
+ if isinstance(value, int | float):
+ current[key] = current.get(key, 0) + value
+
+
def _coerce_filter(
where: LLMCallFilter | Callable[[LLMCallContext, str | None], bool] | Mapping[str, Any] | None,
) -> LLMCallFilter | Callable[[LLMCallContext, str | None], bool] | None:
diff --git a/src/memu/utils/dedupe.py b/src/memu/utils/dedupe.py
new file mode 100644
index 00000000..aad6d988
--- /dev/null
+++ b/src/memu/utils/dedupe.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import hashlib
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+
+def compute_content_hash(summary: str, memory_type: str) -> str:
+ """
+ Generate a stable hash for memory deduplication.
+
+ The normalization intentionally matches the salience/reinforcement content
+ hash used by storage backends: lowercase, trim, and collapse whitespace.
+ """
+ normalized = " ".join(summary.lower().split())
+ content = f"{memory_type}:{normalized}"
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
+
+
+def dedupe_resource_plans(resource_plans: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
+ """Remove duplicate extracted memory entries while preserving first-seen order."""
+ seen_categories: dict[str, list[str]] = {}
+ deduped_plans: list[dict[str, Any]] = []
+
+ for plan in resource_plans:
+ next_plan = dict(plan)
+ next_entries: list[tuple[str, str, list[str]]] = []
+ for raw_entry in plan.get("entries") or []:
+ normalized_entry = normalize_extracted_entry(raw_entry)
+ if normalized_entry is None:
+ continue
+ memory_type, content, categories = normalized_entry
+ key = compute_content_hash(content, memory_type)
+ if key in seen_categories:
+ seen_categories[key][:] = merge_category_names(seen_categories[key], categories)
+ continue
+ next_entries.append((memory_type, content, categories))
+ seen_categories[key] = categories
+ next_plan["entries"] = next_entries
+ deduped_plans.append(next_plan)
+
+ return deduped_plans
+
+
+def normalize_extracted_entry(raw_entry: Any) -> tuple[str, str, list[str]] | None:
+ if not isinstance(raw_entry, tuple) or len(raw_entry) != 3:
+ return None
+ raw_memory_type, raw_content, raw_categories = raw_entry
+ if not isinstance(raw_memory_type, str) or not isinstance(raw_content, str):
+ return None
+ content = raw_content.strip()
+ if not content:
+ return None
+ categories = [
+ category.strip()
+ for category in (raw_categories or [])
+ if isinstance(category, str) and category.strip()
+ ]
+ return raw_memory_type, content, merge_category_names([], categories)
+
+
+def merge_category_names(existing: Sequence[str], incoming: Sequence[str]) -> list[str]:
+ merged: list[str] = []
+ seen: set[str] = set()
+ for category in [*existing, *incoming]:
+ key = category.strip().lower()
+ if not key or key in seen:
+ continue
+ merged.append(category.strip())
+ seen.add(key)
+ return merged
+
+
+__all__ = [
+ "compute_content_hash",
+ "dedupe_resource_plans",
+ "merge_category_names",
+ "normalize_extracted_entry",
+]
diff --git a/src/memu/utils/filtering.py b/src/memu/utils/filtering.py
new file mode 100644
index 00000000..bf8aa809
--- /dev/null
+++ b/src/memu/utils/filtering.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from typing import Any
+
+
+SUPPORTED_FILTER_OPERATORS = frozenset({"in"})
+
+
+def split_filter_key(raw_key: Any) -> tuple[str, str | None]:
+ """Split and validate a filter key.
+
+ Supported filters are equality (`field`) and membership (`field__in`).
+ """
+
+ if not isinstance(raw_key, str) or not raw_key.strip():
+ msg = "Filter field must be a non-empty string"
+ raise ValueError(msg)
+
+ key = raw_key.strip()
+ field, separator, operator = key.partition("__")
+ if not field:
+ msg = "Filter field must be a non-empty string"
+ raise ValueError(msg)
+ if not separator:
+ return field, None
+ if operator not in SUPPORTED_FILTER_OPERATORS:
+ msg = f"Unsupported filter operator '__{operator}' for field '{field}'"
+ raise ValueError(msg)
+ return field, operator
+
+
+def normalize_filter_value(field: str, operator: str | None, expected: Any) -> Any:
+ """Normalize a filter value after its key has been validated."""
+
+ if operator != "in":
+ return expected
+ if isinstance(expected, str):
+ return expected
+ if isinstance(expected, Mapping):
+ msg = f"Filter '{field}__in' must be a string or an iterable of values"
+ raise ValueError(msg)
+ if not isinstance(expected, Iterable):
+ msg = f"Filter '{field}__in' must be a string or an iterable of values"
+ raise ValueError(msg)
+ return tuple(expected)
+
+
+def build_filter_key(field: str, operator: str | None) -> str:
+ return field if operator is None else f"{field}__{operator}"
+
+
+__all__ = [
+ "SUPPORTED_FILTER_OPERATORS",
+ "build_filter_key",
+ "normalize_filter_value",
+ "split_filter_key",
+]
diff --git a/src/memu/utils/retrieve.py b/src/memu/utils/retrieve.py
new file mode 100644
index 00000000..01126001
--- /dev/null
+++ b/src/memu/utils/retrieve.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Literal, cast
+
+RetrieveMethod = Literal["rag", "llm"]
+RetrieveRanking = Literal["similarity", "salience"]
+
+
+def normalize_retrieve_method(method: str | None, *, default: str) -> RetrieveMethod:
+ """Resolve and validate the retrieval method for a single request."""
+
+ raw_method = default if method is None else method
+ if not isinstance(raw_method, str) or not raw_method.strip():
+ msg = "retrieve method must be 'rag' or 'llm'"
+ raise ValueError(msg)
+ normalized = raw_method.strip().lower()
+ if normalized not in {"rag", "llm"}:
+ msg = "retrieve method must be 'rag' or 'llm'"
+ raise ValueError(msg)
+ return cast(RetrieveMethod, normalized)
+
+
+def normalize_retrieve_ranking(ranking: str | None, *, default: str) -> RetrieveRanking:
+ """Resolve and validate the item ranking strategy for a single retrieve request."""
+
+ raw_ranking = default if ranking is None else ranking
+ if not isinstance(raw_ranking, str) or not raw_ranking.strip():
+ msg = "retrieve ranking must be 'similarity' or 'salience'"
+ raise ValueError(msg)
+ normalized = raw_ranking.strip().lower()
+ if normalized not in {"similarity", "salience"}:
+ msg = "retrieve ranking must be 'similarity' or 'salience'"
+ raise ValueError(msg)
+ return cast(RetrieveRanking, normalized)
+
+
+__all__ = ["RetrieveMethod", "RetrieveRanking", "normalize_retrieve_method", "normalize_retrieve_ranking"]
diff --git a/src/memu/utils/serialization.py b/src/memu/utils/serialization.py
new file mode 100644
index 00000000..717ee16a
--- /dev/null
+++ b/src/memu/utils/serialization.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel
+
+
+def model_dump_without_embeddings(obj: BaseModel) -> dict[str, Any]:
+ """Dump a Pydantic model into a JSON-safe public response shape."""
+
+ return obj.model_dump(mode="json", exclude={"embedding"})
+
+
+__all__ = ["model_dump_without_embeddings"]
diff --git a/src/memu/utils/tool.py b/src/memu/utils/tool.py
index aa1c0067..5167f90b 100644
--- a/src/memu/utils/tool.py
+++ b/src/memu/utils/tool.py
@@ -48,7 +48,7 @@ def add_tool_call(item: MemoryItem, tool_call: ToolCallResult) -> None:
raise ValueError(msg)
tool_call.ensure_hash()
tool_calls = get_tool_calls(item)
- tool_calls.append(tool_call.model_dump())
+ tool_calls.append(tool_call.model_dump(mode="json"))
set_tool_calls(item, tool_calls)
diff --git a/src/memu/workflow/pipeline.py b/src/memu/workflow/pipeline.py
index ddb5a5af..63a5d907 100644
--- a/src/memu/workflow/pipeline.py
+++ b/src/memu/workflow/pipeline.py
@@ -8,6 +8,8 @@
from memu.workflow.step import WorkflowStep
+LLM_PROFILE_CONFIG_KEYS = ("llm_profile", "chat_llm_profile", "embed_llm_profile")
+
@dataclass
class PipelineRevision:
@@ -144,11 +146,17 @@ def _validate_steps(self, steps: list[WorkflowStep], *, initial_state_keys: set[
msg = f"Step '{step.step_id}' requests unavailable capabilities: {', '.join(sorted(unknown_caps))}"
raise ValueError(msg)
- if getattr(step, "config", None):
- profile_name = step.config.get("llm_profile")
- if profile_name and profile_name not in self.llm_profiles:
+ for profile_key in LLM_PROFILE_CONFIG_KEYS:
+ profile_name = (getattr(step, "config", None) or {}).get(profile_key)
+ if profile_name is None:
+ continue
+ if not isinstance(profile_name, str) or not profile_name.strip():
+ msg = f"Step '{step.step_id}' references invalid {profile_key}; profile name must be non-empty"
+ raise ValueError(msg)
+ profile_name = profile_name.strip()
+ if profile_name not in self.llm_profiles:
msg = (
- f"Step '{step.step_id}' references unknown llm_profile '{profile_name}'. "
+ f"Step '{step.step_id}' references unknown {profile_key} '{profile_name}'. "
f"Available profiles: {', '.join(sorted(self.llm_profiles))}"
)
raise ValueError(msg)
diff --git a/tests/test_client_wrapper.py b/tests/test_client_wrapper.py
index 4ada1107..999be91f 100644
--- a/tests/test_client_wrapper.py
+++ b/tests/test_client_wrapper.py
@@ -4,9 +4,46 @@
from __future__ import annotations
+import asyncio
from unittest.mock import MagicMock
+class FakeMemoryService:
+ def __init__(self) -> None:
+ self.retrieve_calls = []
+
+ async def retrieve(self, queries, where=None, ranking=None):
+ self.retrieve_calls.append({"queries": queries, "where": where, "ranking": ranking})
+ return {
+ "items": [
+ {"summary": "one"},
+ {"summary": "two"},
+ {"summary": "three"},
+ ]
+ }
+
+
+class AsyncCreateCompletions:
+ def __init__(self) -> None:
+ self.kwargs = {}
+
+ async def create(self, **kwargs):
+ self.kwargs = kwargs
+ return {"ok": True, "method": "create"}
+
+
+class AsyncAcreateCompletions:
+ def __init__(self) -> None:
+ self.kwargs = {}
+
+ async def acreate(self, **kwargs):
+ self.kwargs = kwargs
+ return {"ok": True, "method": "acreate"}
+
+ def create(self, **kwargs):
+ raise AssertionError("acreate should be preferred when present")
+
+
class TestMemuOpenAIWrapper:
"""Tests for OpenAI client wrapper."""
@@ -39,6 +76,27 @@ def test_extract_user_query_multiple_turns(self):
query = completions._extract_user_query(messages)
assert query == "What's my name?"
+ def test_extract_user_query_from_multiple_text_parts(self):
+ """Should concatenate text parts from multimodal user content."""
+ from memu.client.openai_wrapper import MemuChatCompletions
+
+ completions = MemuChatCompletions(MagicMock(), MagicMock(), {}, "salience", 5)
+
+ messages = [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Remember that I like coffee."},
+ {"type": "image_url", "image_url": {"url": "https://example.test/photo.png"}},
+ {"type": "text", "text": "What should I order today?"},
+ {"type": "text", "text": 123},
+ ],
+ },
+ ]
+
+ query = completions._extract_user_query(messages)
+ assert query == "Remember that I like coffee.\nWhat should I order today?"
+
def test_inject_memories_into_existing_system(self):
"""Should append memories to existing system message."""
from memu.client.openai_wrapper import MemuChatCompletions
@@ -63,6 +121,46 @@ def test_inject_memories_into_existing_system(self):
assert "User is named Alex" in result[0]["content"]
assert result[0]["content"].startswith("You are helpful.")
+ def test_inject_memories_does_not_mutate_original_messages(self):
+ """Should leave caller-owned message dictionaries untouched."""
+ from memu.client.openai_wrapper import MemuChatCompletions
+
+ completions = MemuChatCompletions(MagicMock(), MagicMock(), {}, "salience", 5)
+ messages = [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Hi"},
+ ]
+
+ result = completions._inject_memories(messages, [{"summary": "User loves coffee"}])
+
+ assert messages == [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Hi"},
+ ]
+ assert result is not messages
+ assert result[0] is not messages[0]
+
+ def test_inject_memories_appends_to_system_content_parts(self):
+ """Should support system messages whose content is a list of text parts."""
+ from memu.client.openai_wrapper import MemuChatCompletions
+
+ completions = MemuChatCompletions(MagicMock(), MagicMock(), {}, "salience", 5)
+ original_part = {"type": "text", "text": "You are helpful."}
+ messages = [
+ {"role": "system", "content": [original_part]},
+ {"role": "user", "content": "Hi"},
+ ]
+
+ result = completions._inject_memories(messages, [{"summary": "User loves coffee"}])
+
+ assert messages[0]["content"] == [original_part]
+ assert result[0]["content"] is not messages[0]["content"]
+ assert result[0]["content"][0] == original_part
+ assert result[0]["content"][0] is not original_part
+ assert result[0]["content"][1]["type"] == "text"
+ assert "
+
**[memU Bot](https://github.com/NevaMind-AI/memUBot)** — Now open source. The enterprise-ready OpenClaw. Your proactive AI assistant that remembers everything.
@@ -79,7 +79,7 @@ Just as a file system turns raw bytes into organized data, memU transforms raw i
## ⭐️ Star the repository
-
+
If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly appreciated.
---
@@ -97,10 +97,14 @@ If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly ap
## 🔄 How Proactive Memory Works
```bash
-
+pip install "memu-py[claude]"
+# From a source checkout, use: uv sync --extra claude
+export OPENAI_API_KEY="..."
+export ANTHROPIC_API_KEY="..."
+# Optional when using memory.platform instead of memory.local:
+export MEMU_API_KEY="..."
cd examples/proactive
python proactive.py
-
```
---
@@ -233,6 +237,8 @@ MemU's three-layer system enables both **reactive queries** and **proactive cont
+
+
| Layer | Reactive Use | Proactive Use |
|-------|--------------|---------------|
| **Resource** | Direct access to original data | Background monitoring for new patterns |
@@ -277,21 +283,34 @@ For enterprise deployment with custom proactive workflows, contact **info@nevami
#### Installation
```bash
-pip install -e .
+pip install memu-py
+```
+
+For local source development, clone this repository and install the editable
+workspace:
+
+```bash
+make install
```
#### Basic Example
-> **Requirements**: Python 3.13+ and an OpenAI API key
+> **Requirements**: Python 3.12+ and an OpenAI API key
+
+Run the getting-started example:
-**Test Continuous Learning** (in-memory):
```bash
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_inmemory.py
+python examples/getting_started_robust.py
```
-**Test with Persistent Storage** (PostgreSQL):
+The example initializes `MemoryService`, creates a memory item, and retrieves it
+with a natural-language query. See
+[`examples/getting_started_robust.py`](examples/getting_started_robust.py) for
+the full script.
+
+**Optional PostgreSQL integration check**:
+
```bash
# Start PostgreSQL with pgvector
docker run -d \
@@ -302,18 +321,94 @@ docker run -d \
-p 5432:5432 \
pgvector/pgvector:pg16
-# Run continuous learning test
+# Run the opt-in PostgreSQL integration test
+uv sync --extra postgres
export OPENAI_API_KEY=your_api_key
-cd tests
-python test_postgres.py
+export MEMU_RUN_POSTGRES_TESTS=1
+uv run python -m pytest tests/test_postgres.py
```
-Both examples demonstrate **proactive memory workflows**:
+These flows demonstrate **proactive memory workflows**:
1. **Continuous Ingestion**: Process multiple files sequentially
2. **Auto-Extraction**: Immediate memory creation
3. **Proactive Retrieval**: Context-aware memory surfacing
-See [`tests/test_inmemory.py`](tests/test_inmemory.py) and [`tests/test_postgres.py`](tests/test_postgres.py) for implementation details.
+See [`tests/test_inmemory.py`](tests/test_inmemory.py), [`tests/test_sqlite.py`](tests/test_sqlite.py),
+and [`tests/test_postgres.py`](tests/test_postgres.py) for implementation details. The
+in-memory and SQLite live LLM checks are opt-in with `MEMU_RUN_LIVE_LLM_TESTS=1`.
+
+### Context Harness: Folder to Markdown Memory
+
+For local agents that need inspectable context files, memU can compile a folder
+of raw data into a Markdown-backed memory repository:
+
+
+
+```text
+memory_repo/
+ AGENTS.md
+ raw_data/
+ memory.md
+ memory/
+ soul.md
+ soul/
+ skill.md
+ skill/
+ .memu/
+ harness.json
+ evolution/
+```
+
+Quick CLI workflow:
+
+```bash
+memu-harness init memory_repo --source-folder path/to/uploaded-folder
+memu-harness doctor memory_repo --json
+memu-harness status memory_repo --json
+memu-harness refresh memory_repo --query "current agent task"
+memu-harness review-evolution memory_repo
+memu-harness refresh memory_repo --exclude "node_modules/**" --exclude "*.tmp"
+memu-harness promote-skill memory_repo \
+ --title "Validate Context Packs" \
+ --lesson "Inspect promoted skills before relying on generated context"
+memu-harness suggest-skills memory_repo --json
+memu-harness context memory_repo --query "current agent task"
+memu-harness context memory_repo --query "current agent task" --format summary
+memu-harness context memory_repo --query "current agent task" --format messages
+memu-harness context memory_repo --bucket-max soul=1000 --bucket-max skill=2000
+memu-harness context memory_repo --format system --output context.system.md
+```
+
+This flow preserves multimodal files in `raw_data/`, supports sidecar captions,
+summaries, notes, and transcripts such as `screenshot.caption.md` or
+`report.summary.md`, updates changed files incrementally, and keeps manual skill
+notes outside generated blocks. Raw logs, creator feedback, uploads, and new
+observations do not edit `memory.md`, `soul.md`, or `skill.md` directly; memU
+first turns them into Evolution Instructions, Patch Proposals, and review
+decisions, with audit records under `.memu/evolution/`. Exclude noisy files
+explicitly with `--exclude` or a `.memuignore` file. `init` also creates
+`.memu/harness.json`, where the
+repository can persist non-secret defaults such as exclude globs, text evidence
+limits, context budgets, and context output format. Both `memu-harness context`
+and standalone `memu-context` read those context defaults. Skill traces can be
+turned into promotion suggestions with `suggest-skills`; promoted skills are
+also stored as stable cards under `skill/promoted/`. New harness repositories
+include an `AGENTS.md` bootstrap file so local coding agents can discover the
+memory, soul, skill, raw data, and skill-evolution conventions directly from
+the repository. Python callers can use `ContextHarness.from_repo("memory_repo")`
+to refresh and build context from `memory_repo/raw_data` with the same repo
+defaults.
+
+
+
+Run the no-API-key demo:
+
+```bash
+python examples/context_harness_demo.py
+```
+
+See [`docs/folder_memory_compiler.md`](docs/folder_memory_compiler.md) for the
+full harness API, CLI, watcher, status report, and self-evolving skill workflow.
---
@@ -328,14 +423,14 @@ service = MemUService(
# Default profile for LLM operations
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
- "api_key": "your_api_key",
+ "api_key": "MEMU_QWEN_API_KEY",
"chat_model": "qwen3-max",
- "client_backend": "sdk" # "sdk" or "http"
+ "client_backend": "sdk" # "sdk", "httpx", or "lazyllm_backend"
},
# Separate profile for embeddings
"embedding": {
"base_url": "https://api.voyageai.com/v1",
- "api_key": "your_voyage_api_key",
+ "api_key": "VOYAGE_API_KEY",
"embed_model": "voyage-3.5-lite"
}
},
@@ -343,6 +438,24 @@ service = MemUService(
)
```
+The `lazyllm_backend` adapter is optional. Install it with
+`pip install "memu-py[lazyllm]"` or, from a source checkout,
+`uv sync --extra lazyllm`.
+
+Optional LazyLLM live check:
+
+```bash
+uv sync --extra lazyllm
+export MEMU_QWEN_API_KEY=your_api_key
+export MEMU_RUN_LAZYLLM_TESTS=1
+uv run python -m pytest tests/test_lazyllm.py
+```
+
+Retrieve routing can also use distinct profiles: set
+`route_intention_llm_profile`, `sufficiency_check_llm_profile`, and
+`llm_ranking_llm_profile` in `retrieve_config` to split cheap routing from
+heavier ranking or judging models.
+
---
### OpenRouter Integration
@@ -359,7 +472,7 @@ service = MemoryService(
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
- "api_key": "your_openrouter_api_key",
+ "api_key": "OPENROUTER_API_KEY",
"chat_model": "anthropic/claude-3.5-sonnet", # Any OpenRouter model
"embed_model": "openai/text-embedding-3-small", # Embedding model
},
@@ -387,15 +500,10 @@ service = MemoryService(
#### Running OpenRouter Tests
```bash
export OPENROUTER_API_KEY=your_api_key
+export MEMU_RUN_OPENROUTER_TESTS=1
# Full workflow test (memorize + retrieve)
-python tests/test_openrouter.py
-
-# Embedding-specific tests
-python tests/test_openrouter_embedding.py
-
-# Vision-specific tests
-python tests/test_openrouter_vision.py
+uv run python -m pytest tests/test_openrouter.py
```
See [`examples/example_4_openrouter_memory.py`](examples/example_4_openrouter_memory.py) for a complete working example.
@@ -404,6 +512,8 @@ See [`examples/example_4_openrouter_memory.py`](examples/example_4_openrouter_me
## 📖 Core APIs
+
+
### `memorize()` - Continuous Learning Pipeline
Processes inputs in real-time and immediately updates memory:
@@ -466,11 +576,12 @@ Deep **anticipatory reasoning** for complex contexts:
# Proactive retrieval with context history
result = await service.retrieve(
queries=[
- {"role": "user", "content": {"text": "What are their preferences?"}},
- {"role": "user", "content": {"text": "Tell me about work habits"}}
+ {"role": "user", "content": "What are their preferences?"},
+ {"role": "user", "content": "Tell me about work habits"}
],
where={"user_id": "123"}, # Optional: scope filter
- method="rag" # or "llm" for deeper reasoning
+ method="rag", # or "llm" for deeper reasoning
+ ranking="salience", # or "similarity" for RAG item recall
)
# Returns context-aware results:
@@ -482,11 +593,15 @@ result = await service.retrieve(
}
```
+For a single user query, Python callers can also pass `queries=["What are their preferences?"]`; MemU normalizes it to a user message before retrieval.
+
**Proactive Filtering**: Use `where` to scope continuous monitoring:
- `where={"user_id": "123"}` - User-specific context
- `where={"agent_id__in": ["1", "2"]}` - Multi-agent coordination
- Omit `where` for global context awareness
+`where` keys must match fields on your configured `UserConfig.model`, and values are validated/normalized by that model before querying. Validation is field-level, so partial filters do not need to include every field required by the model. Supported filters are equality (`field`) and membership (`field__in`); unsupported operators are rejected before any backend query runs.
+
---
## 💡 Proactive Scenarios
@@ -559,7 +674,7 @@ View detailed experimental data: [memU-experiment](https://github.com/NevaMind-A
| Repository | Description | Proactive Features |
|------------|-------------|-------------------|
-| **[memU](https://github.com/NevaMind-AI/memU)** | Core proactive memory engine | 7×24 learning pipeline, auto-categorization |
+| **[memU](https://github.com/NevaMind-AI/MemU)** | Core proactive memory engine | 7×24 learning pipeline, auto-categorization |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend with continuous sync | Real-time memory updates, webhook triggers |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Visual memory dashboard | Live memory evolution monitoring |
@@ -597,7 +712,7 @@ We welcome contributions from the community! Whether you're fixing bugs, adding
To start contributing to MemU, you'll need to set up your development environment:
#### Prerequisites
-- Python 3.13+
+- Python 3.12+
- [uv](https://github.com/astral-sh/uv) (Python package manager)
- Git
@@ -621,14 +736,31 @@ The `make install` command will:
Before submitting your contribution, ensure your code passes all quality checks:
```bash
make check
+make test
```
The `make check` command runs:
- **Lock file verification**: Ensures `pyproject.toml` consistency
-- **Pre-commit hooks**: Lints code with Ruff, formats with Black
+- **Pre-commit hooks**: Lints and formats code with Ruff
- **Type checking**: Runs `mypy` for static type analysis
- **Dependency analysis**: Uses `deptry` to find obsolete dependencies
+The `make test` command runs the pytest suite with coverage enabled.
+
+#### Documentation Site
+
+Preview the documentation locally with MkDocs:
+
+```bash
+make docs
+```
+
+Build the documentation in strict mode:
+
+```bash
+make docs-build
+```
+
### Contributing Guidelines
For detailed contribution guidelines, code standards, and development practices, please see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -638,7 +770,7 @@ For detailed contribution guidelines, code standards, and development practices,
- Write clear commit messages
- Add tests for new functionality
- Update documentation as needed
-- Run `make check` before pushing
+- Run `make check` and `make test` before pushing
---
@@ -650,10 +782,13 @@ For detailed contribution guidelines, code standards, and development practices,
## 🌍 Community
-- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/memU/issues)
+- **Support**: [Get help and choose the right channel](SUPPORT.md)
+- **Security**: [Report vulnerabilities privately](SECURITY.md)
+- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/MemU/issues)
+- **GitHub Discussions**: [Ask questions and discuss ideas](https://github.com/NevaMind-AI/MemU/discussions)
- **Discord**: [Join the community](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Follow @memU_ai](https://x.com/memU_ai)
-- **Contact**: info@nevamind.ai
+- **Contact**: contact@nevamind.ai
---
diff --git a/assets/memu-overall-algorithm-flow.png b/assets/memu-overall-algorithm-flow.png
new file mode 100644
index 0000000000000000000000000000000000000000..d8e2708182b4a05f06dc48b1413f52067f3f90ab
GIT binary patch
literal 1519861
zcmeFZ1$0zN*Df4|009C7NYGC3gmj!Hhn93qlO5xOlzBbQ6_
z8*EHImmuR{)oc#VPT}bZ@{rHwce)isszZUxRr(?Zq?Jt2_!Vr6U1Xs8&=i-*BXSwd
z+Mv*A H>?La6Dd1;3i{d_W0MkFn9v%i@qCpuN^W-WMQ)e{Ita)yI
zb8TAS9wxPt<;5`72TRzY!ugouzE=d(!@$!zywhD}QP~v%k2PRPA!iaqCMhGiKn1zQ
zi+V{EqUa8Aq01s2zTZOimvYP&ez6>V9x+L+B$g{SQ;lM=v@
z&p+&_)c9#n`JY(#uW5Pp!ha(zaA=FXv&J?c*!0}aOF8`>v>d(k@^r+rt1a#ph3{eK
zmx&8fowgn${Fcm|xWXfohTa(B*l
GaFLEd*&
zt?;W{$D6uHUgQmCW+UE|Mwh=8c*IVNQ4nadx3d8bH*6FG|M;hpQ<6Zisv;(Z$PpLH
zE`r40%JPz(bAwbNAio$4b@?o5RkRkPw23`xd_Kzqjx;J2G}H#VOv84MXMha_1=zfW
zvs1~O*+emcoOLRi&iP6(WZ`EBs&d8AzYSLgv-+D&;uJTP`srjem*XPxzz$|7lwSlN#Jdl~(H~^3z0_NiwR6mAF#3Zg%
zR{n3`lEf_>MmagDkWX+CbLqhO15**6MuVDdD=mG5KKx~}!hwjUr<&IZMccL0T=)#f
zv-h+^Uw}Alr6m{YudYcewRmk|5~kCo@LT}Er*DMQB(gDOCZHm}4341Qn0XP*%8G~o
zFmiv0OcG2{TYOgFZ>7sKZ4ryRb=jDL9A(V!DF0p7Plhvxl@Hh~o<`Yn@Fnt+d>Fz5
zh{#qojclmCFeAb6KZNBfArY+!b3UCp2dG*djGi^)jx-P4=|f#RKi>9+M4A`3{iA2iLMe(@`}Jh*GLW%KGo55D3_
zPkz@cUw-`hMQcn?L`o5WwFE?DX|?N)+_>@Yf99ut{5|jg{yq23EiS;pmDTb1&wu`B
zfBhLxYk&?;#t5LnM-l*_x7PIiNH?!~;#u1^Zg~B_y#M
c3Es36H&=y)LXKtKRQ(Djp6>j(Gk
zx!{_s%*GAFu0L|?)_0uylH=Dej=SD9hv>HZfh7QhR_WEXwG%dN{*~uH`)wcp uT2)fD6hG%3<
z3dB7T>OGODSnBXH7gtb-$dY?xtyQYoar@n0`TE5$XeZY869R0AT(r?;ArK
b`V;Tc;Uo6D49;SP&SQ8vAO{x
zS%tOyLtH%ThdVONe+ba#lXuQGd7qwz3X0XoV=B~$94hJUhzj8cFH$qd;T2N>%vp6$
zc*r*@1n%-AIg|Ym7rxTBiOsnt_>;)oUgZgskzf<3vW8tO$(u{rF;CbjGzjO?sU2k8
zn_ZNcF-m+T9Wef`5(f%u<_wdKQIe71;Y?|lS+!V#dBqt3(}3fxg^i>lU9+eX`POr=
zXqf>k#+VQq02D(pZ5ADy=8Bq6`%?wK5S0{guHOlOT)GNtnM2Ctf$KE{8`EKYy-IRy
zy21pc(h+}}Sz}&>FL0L?1?{34cC#U3&j0{12C;&F6R1K_C&Z~rvaCEJ&a{IoQpTi(
znU_xko{@`?9ZdvbfRbNc7@eBJ