Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d14470d6bb | |||
| 5154174342 | |||
| 577a5a2feb | |||
| d7a34c24dd | |||
| 214d2a1d0d | |||
| f0eb9e091f | |||
| 3cdcb6564c | |||
| 336fbce60a | |||
| 9eb5b9ed29 | |||
| 8230a5dac7 | |||
| 9864584c21 | |||
| 9eea060db9 | |||
| 15218d4a7f | |||
| 69001d7b1f | |||
| 35fe30aabd | |||
| 11a7d8378c | |||
| b4b73deada | |||
| bfe730aa38 | |||
| 82d67430dd | |||
| 8fcf2b0b29 | |||
| 2e5e290434 | |||
| 06ee1b588c | |||
| dc6122ec3d | |||
| df79a43925 | |||
| a6242710df | |||
| 4e1e4c0c5a | |||
| fa5c85f9f6 | |||
| 7c29eb2645 | |||
| 6f079c313f | |||
| e95090e116 | |||
| 861cbb7289 | |||
| 54aa760720 | |||
| 59c3b050bd | |||
| 5b3acf416b | |||
| 63f587c922 |
@@ -0,0 +1,100 @@
|
||||
name: openclaw checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd openclaw && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd openclaw && pnpm exec vitest run --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.node-version == 20
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
flags: openclaw
|
||||
directory: openclaw/coverage
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd openclaw && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -0,0 +1,75 @@
|
||||
name: TypeScript SDK CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'mem0-ts/**'
|
||||
- '.github/workflows/ts-sdk-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'mem0-ts/**'
|
||||
|
||||
jobs:
|
||||
check_changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ts_sdk_changed: ${{ steps.filter.outputs.ts_sdk }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
ts_sdk:
|
||||
- 'mem0-ts/**'
|
||||
|
||||
build_ts_sdk:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.ts_sdk_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: mem0-ts/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mem0-ts
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
working-directory: mem0-ts
|
||||
run: npx prettier --check .
|
||||
|
||||
- name: Build
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run test:ci
|
||||
|
||||
- name: Verify package exports
|
||||
working-directory: mem0-ts
|
||||
run: |
|
||||
node -e "const m = require('./dist/index.js'); console.log('Client exports:', Object.keys(m).length)"
|
||||
node -e "const m = require('./dist/oss/index.js'); console.log('OSS exports:', Object.keys(m).length)"
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.node-version == 20
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: mem0-ts/coverage/
|
||||
@@ -7,6 +7,22 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-03-16" description="v1.0.6">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Fixed telemetry vector store initialization still running when `MEM0_TELEMETRY` is disabled (#4351)
|
||||
- **Core:** Removed destructive `vector_store.reset()` call from `delete_all()` that was wiping the entire vector store instead of deleting only the target memories (#4349)
|
||||
- **OSS:** `OllamaLLM` now respects the configured URL instead of always falling back to localhost (#4320)
|
||||
- **Core:** Fixed `KeyError` when LLM omits the `entities` key in tool call response (#4313)
|
||||
- **Prompts:** Ensured JSON instruction is included in prompts when using `json_object` response format (#4271)
|
||||
- **Core:** Fixed incorrect database parameter handling (#3913)
|
||||
|
||||
**Dependencies:**
|
||||
- Updated LangChain dependencies to v1.0.0 (#4353)
|
||||
- Bumped protobuf dependency to 5.29.6 and extended upper bound to `<7.0.0` (#4326)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-03" description="v1.0.5">
|
||||
- **Telemetry Fix**
|
||||
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
|
||||
@@ -729,6 +745,29 @@ mode: "wide"
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-03-16" description="v2.4.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Fixed code block content extraction — content inside code blocks is now properly extracted instead of being deleted (#4317)
|
||||
|
||||
**Improvements:**
|
||||
- **Code Quality:** Fixed linting issues across the SDK (#4334)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-14" description="v2.4.0">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **OSS Storage:** Fixed `SQLITE_CANTOPEN` errors when running as a LaunchAgent, systemd service, or in containers where `process.cwd()` is read-only (e.g. `/`). Default `vector_store.db` location changed from `process.cwd()/vector_store.db` to `~/.mem0/vector_store.db`.
|
||||
- **OSS Storage:** Fixed `historyDbPath` config being silently ignored — config merging always overwrote it with defaults. Top-level `historyDbPath` is now correctly propagated into `historyStore.config` with proper precedence.
|
||||
- **OSS Storage:** Added `ensureSQLiteDirectory()` — parent directories for SQLite database files are now auto-created before opening, preventing `SQLITE_CANTOPEN` when using nested paths.
|
||||
|
||||
**Improvements:**
|
||||
- **Migration:** Added deprecation warning when an existing `vector_store.db` is found at the old `process.cwd()` location, guiding users to move it or set `vectorStore.config.dbPath` explicitly.
|
||||
- **Config:** Limited default SQLite config spreading to only SQLite history providers, preventing config leaking into Supabase or other providers.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-09" description="v2.3.0">
|
||||
|
||||
**Breaking Changes:**
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
---
|
||||
title: MiroFish Swarm Memory
|
||||
description: "Build a multi-agent swarm simulation with graph-powered memory using Mem0 and MiroFish patterns."
|
||||
---
|
||||
|
||||
<Snippet file="blank-notif.mdx" />
|
||||
|
||||
Build a multi-agent swarm simulation with graph-powered memory using Mem0 OSS and [MiroFish](https://github.com/666ghj/MiroFish) patterns. MiroFish is a graph-centric system — it extracts entities and relationships from documents, builds a knowledge graph, and queries it throughout its pipeline. Mem0's Graph Memory is a natural replacement for its Zep Cloud integration.
|
||||
|
||||
<Note>
|
||||
This cookbook demonstrates the **core memory patterns** using a simplified simulation. MiroFish's actual architecture uses a factory pattern (`memory_factory.py`) with abstract providers, batch buffering with retries in `ZepGraphMemoryUpdater`, and IPC-based agent interviews. This cookbook focuses on the Mem0 API integration points — wrap these calls in your own retry/batch logic for production use.
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
This cookbook implements a **Housing Policy Prediction Simulation** following MiroFish's five-stage workflow:
|
||||
|
||||
1. **Graph Building** — Ingest seed documents, extract entities and relationships
|
||||
2. **Environment Setup** — Query the knowledge graph to enrich agent profiles
|
||||
3. **Simulation** — Track agent interactions with per-agent memory isolation
|
||||
4. **Report Generation** — Semantic search + graph traversal for analysis
|
||||
5. **Deep Interaction** — Query post-simulation memory and relationships (MiroFish also supports live agent interviews via IPC — not covered here)
|
||||
|
||||
Three agents debate a housing policy reform:
|
||||
- **Mayor Chen** — Policy advocate pushing for zoning reform
|
||||
- **Wang (Homeowner)** — Opposition leader organizing resistance
|
||||
- **Professor Li** — Academic providing data-driven analysis
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install "mem0ai[graph]"
|
||||
```
|
||||
|
||||
You need a graph backend. Choose one:
|
||||
|
||||
| Backend | Setup | Best for |
|
||||
|---|---|---|
|
||||
| **Neo4j Aura** (free tier) | [Sign up](https://neo4j.com/product/auradb/), get Bolt URI | Production, closest to Zep |
|
||||
| **Neo4j Docker** | `docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5` | Local development |
|
||||
| **Kuzu** (embedded) | No setup needed — runs in-process | Quick testing, zero dependencies |
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
|
||||
# Option A: Neo4j Docker (local development)
|
||||
docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
|
||||
# Option B: Neo4j Aura (production — free tier available)
|
||||
export NEO4J_URL="neo4j+s://<your-instance>.databases.neo4j.io"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="your-aura-password"
|
||||
|
||||
# Option C: Kuzu (zero setup — auto-detected when NEO4J_URL is not set)
|
||||
# No exports needed
|
||||
```
|
||||
|
||||
## Complete Implementation
|
||||
|
||||
```python
|
||||
"""
|
||||
MiroFish Swarm Prediction Simulation with Mem0 Graph Memory
|
||||
|
||||
MiroFish uses Zep Cloud as its knowledge graph backend. This implementation
|
||||
replaces Zep with Mem0 OSS Graph Memory, which provides:
|
||||
- Automatic entity extraction from text
|
||||
- Relationship mining (source → relationship → destination triples)
|
||||
- Combined vector + graph search returning memories AND relations
|
||||
- Per-agent isolation via run_id
|
||||
- Self-hosted with no node caps
|
||||
|
||||
Follows MiroFish's 5-stage pipeline:
|
||||
1. Graph Building - Ingest seed documents, extract entities
|
||||
2. Environment Setup - Query graph to enrich agent profiles
|
||||
3. Simulation - Track agent actions with per-agent isolation
|
||||
4. Report Generation - Semantic + graph search for analysis
|
||||
5. Deep Interaction - Query post-simulation knowledge graph
|
||||
|
||||
Run:
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from mem0 import Memory
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# MiroFish Agent Action Types (matches OASIS simulation output)
|
||||
# ======================================================================
|
||||
|
||||
# Twitter actions
|
||||
TWITTER_ACTIONS = [
|
||||
"CREATE_POST", "LIKE_POST", "REPOST", "FOLLOW",
|
||||
"DO_NOTHING", "QUOTE_POST",
|
||||
]
|
||||
|
||||
# Reddit actions (superset — includes moderation + discovery)
|
||||
REDDIT_ACTIONS = [
|
||||
"LIKE_POST", "DISLIKE_POST", "CREATE_POST", "CREATE_COMMENT",
|
||||
"LIKE_COMMENT", "DISLIKE_COMMENT", "SEARCH_POSTS", "SEARCH_USER",
|
||||
"TREND", "REFRESH", "DO_NOTHING", "FOLLOW", "MUTE",
|
||||
]
|
||||
|
||||
# Combined (DO_NOTHING is skipped during memory storage)
|
||||
MIROFISH_ACTIONS = list(set(TWITTER_ACTIONS + REDDIT_ACTIONS) - {"DO_NOTHING"})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Graph Memory Configuration
|
||||
# ======================================================================
|
||||
|
||||
def build_config():
|
||||
"""Build Mem0 config with Graph Memory.
|
||||
|
||||
Uses Neo4j if credentials are set, otherwise falls back to Kuzu (embedded).
|
||||
"""
|
||||
neo4j_url = os.environ.get("NEO4J_URL")
|
||||
|
||||
# Shared config for LLM, embedder, and vector store
|
||||
base = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "gpt-4o-mini", "temperature": 0.1}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "text-embedding-3-small", "embedding_dims": 1536}
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "mirofish",
|
||||
"embedding_model_dims": 1536,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
custom_prompt = (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
)
|
||||
|
||||
if neo4j_url:
|
||||
base["graph_store"] = {
|
||||
"provider": "neo4j",
|
||||
"config": {
|
||||
"url": neo4j_url,
|
||||
"username": os.environ.get("NEO4J_USERNAME", "neo4j"),
|
||||
"password": os.environ.get("NEO4J_PASSWORD", "password"),
|
||||
},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
else:
|
||||
# Fallback: Kuzu embedded (no external services needed)
|
||||
print(" NEO4J_URL not set — using Kuzu (embedded) graph store")
|
||||
base["graph_store"] = {
|
||||
"provider": "kuzu",
|
||||
"config": {"db": "/tmp/mirofish_graph.kuzu"},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Simulation Engine
|
||||
# ======================================================================
|
||||
|
||||
class MiroFishSimulation:
|
||||
"""
|
||||
Multi-agent simulation with graph-powered memory.
|
||||
|
||||
Uses Mem0 Graph Memory to replace MiroFish's Zep Cloud integration:
|
||||
- Entities and relationships are extracted automatically from text
|
||||
- search() returns both semantic memories AND graph relations
|
||||
- Per-agent isolation via run_id
|
||||
- Project isolation via user_id
|
||||
"""
|
||||
|
||||
def __init__(self, project_id: str, config: dict):
|
||||
self.project_id = project_id
|
||||
self.memory = Memory.from_config(config)
|
||||
self.stats = {
|
||||
"documents_ingested": 0,
|
||||
"activities_recorded": 0,
|
||||
"rounds_completed": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 1: Graph Building — Seed Document Ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest_documents(self, documents: list[str]):
|
||||
"""Ingest seed documents and extract entities + relationships.
|
||||
|
||||
MiroFish equivalent: GraphBuilderService.build_graph()
|
||||
Zep equivalent: graph.add_batch() with episode polling
|
||||
|
||||
With Mem0 Graph Memory, each document is processed by the LLM
|
||||
to extract entities (people, orgs, policies) and relationships
|
||||
(supports, opposes, filed). These become nodes and edges in the
|
||||
graph store, alongside vector embeddings for semantic search.
|
||||
"""
|
||||
print(" Ingesting documents and building knowledge graph...")
|
||||
for i, doc in enumerate(documents):
|
||||
result = self.memory.add(
|
||||
[{"role": "user", "content": doc}],
|
||||
user_id=self.project_id,
|
||||
metadata={"stage": "graph_building", "source": "seed_document", "chunk_index": i}
|
||||
)
|
||||
# Graph Memory returns extracted relations
|
||||
relations = result.get("relations", {})
|
||||
added = relations.get("added_entities", [])
|
||||
if added:
|
||||
print(f" Doc {i}: extracted {len(added)} entities/relations")
|
||||
|
||||
self.stats["documents_ingested"] = len(documents)
|
||||
print(f" Ingested {len(documents)} documents")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 2: Environment Setup — Agent Profile Enrichment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enrich_agent_profile(self, agent_name: str, persona_query: str) -> dict:
|
||||
"""Search memory + graph for context relevant to an agent's persona.
|
||||
|
||||
MiroFish equivalent: OasisProfileGenerator using graph.search()
|
||||
|
||||
Returns both semantic memories and graph relations that can be
|
||||
injected into the agent's system prompt.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
persona_query,
|
||||
user_id=self.project_id,
|
||||
limit=10
|
||||
)
|
||||
facts = [r["memory"] for r in results.get("results", [])]
|
||||
relations = results.get("relations", [])
|
||||
|
||||
print(f" {agent_name}: {len(facts)} facts, {len(relations)} relations")
|
||||
return {"facts": facts, "relations": relations}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 3: Simulation — Agent Activity Tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_action(self, agent_id: str, agent_name: str,
|
||||
action_type: str, content: str,
|
||||
platform: str, round_num: int):
|
||||
"""Record a single agent action as a memory with graph extraction.
|
||||
|
||||
MiroFish equivalent: ZepGraphMemoryUpdater.add_activity()
|
||||
Zep equivalent: graph.add(type="text", data=episode_text)
|
||||
|
||||
Agent memories use run_id to group by agent (no assistant
|
||||
memories involved). Graph Memory extracts entities/relationships
|
||||
from the action content automatically.
|
||||
"""
|
||||
formatted = f"{agent_name} [{action_type}]: {content}"
|
||||
|
||||
self.memory.add(
|
||||
[{"role": "user", "content": formatted}],
|
||||
run_id=agent_id,
|
||||
metadata={
|
||||
"action_type": action_type,
|
||||
"platform": platform,
|
||||
"round": round_num,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
)
|
||||
self.stats["activities_recorded"] += 1
|
||||
|
||||
def run_round(self, round_num: int, activities: list[tuple]):
|
||||
"""Execute one simulation round."""
|
||||
print(f" Round {round_num}: {len(activities)} actions")
|
||||
for agent_id, agent_name, action_type, content, platform in activities:
|
||||
self.record_action(agent_id, agent_name, action_type, content, platform, round_num)
|
||||
self.stats["rounds_completed"] = max(self.stats["rounds_completed"], round_num)
|
||||
|
||||
def recall_agent_memory(self, agent_id: str, query: str) -> dict:
|
||||
"""Agent recalls its own memories mid-simulation.
|
||||
|
||||
Searches by run_id to match the scope used during add().
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=5
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 4: Report Generation — Semantic + Graph Retrieval
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def quick_search(self, query: str, limit: int = 10) -> dict:
|
||||
"""Semantic search + graph relations across all agents.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.quick_search()
|
||||
Returns both vector-matched memories and related graph triples.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
user_id=self.project_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def panorama_search(self) -> dict:
|
||||
"""Retrieve all memories + all graph relations.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.panorama_search()
|
||||
Returns the complete knowledge state for report generation.
|
||||
"""
|
||||
results = self.memory.get_all(user_id=self.project_id)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def agent_search(self, agent_id: str, query: str, limit: int = 10) -> dict:
|
||||
"""Search within a single agent's memory space."""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
"""Delete all memories and graph data for this simulation."""
|
||||
self.memory.delete_all(user_id=self.project_id)
|
||||
print(f" Cleaned up all memories for {self.project_id}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Run the full 5-stage pipeline
|
||||
# ======================================================================
|
||||
|
||||
def main():
|
||||
project_id = f"mirofish_housing_{int(time.time())}"
|
||||
config = build_config()
|
||||
sim = MiroFishSimulation(project_id=project_id, config=config)
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 1: Graph Building — Ingest seed documents
|
||||
# ==================================================================
|
||||
print("=" * 60)
|
||||
print("STAGE 1: Graph Building")
|
||||
print("=" * 60)
|
||||
|
||||
sim.ingest_documents([
|
||||
"The city council proposed a new zoning reform allowing higher "
|
||||
"density housing in suburban areas. Mayor Chen expressed strong "
|
||||
"support, citing a 40% housing shortage affecting young professionals. "
|
||||
"The reform would allow buildings up to 8 stories in previously "
|
||||
"restricted 3-story zones.",
|
||||
|
||||
"Local homeowners association president Wang opposes the reform, "
|
||||
"arguing it will decrease property values by 15-20%. The association "
|
||||
"represents 5,000 homeowners in the affected districts. Wang has "
|
||||
"organized three community meetings and collected 2,000 signatures.",
|
||||
|
||||
"Professor Li from Beijing University published research showing "
|
||||
"similar reforms in Shenzhen led to 15% price drops in existing "
|
||||
"homes but created 30% more affordable housing units within 3 years. "
|
||||
"The study covered 12 districts and 50,000 housing units.",
|
||||
])
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 2: Environment Setup — Enrich agent profiles
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 2: Environment Setup")
|
||||
print("=" * 60)
|
||||
|
||||
mayor_context = sim.enrich_agent_profile(
|
||||
"Mayor Chen",
|
||||
"Mayor Chen housing reform zoning policy"
|
||||
)
|
||||
wang_context = sim.enrich_agent_profile(
|
||||
"Wang",
|
||||
"Wang homeowner opposition property values petition"
|
||||
)
|
||||
li_context = sim.enrich_agent_profile(
|
||||
"Professor Li",
|
||||
"Professor Li research housing data Shenzhen"
|
||||
)
|
||||
|
||||
print("\n Example profile context for Mayor Chen:")
|
||||
for fact in mayor_context["facts"][:3]:
|
||||
print(f" Fact: {fact}")
|
||||
for rel in mayor_context["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 3: Simulation — Run agent interactions
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 3: Simulation")
|
||||
print("=" * 60)
|
||||
|
||||
# Round 1: Opening statements
|
||||
sim.run_round(1, [
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"This reform will create 10,000 new housing units by 2028. "
|
||||
"Young families deserve affordable homes. #HousingForAll",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Our property values will plummet! The council ignores the "
|
||||
"voices of 5,000 homeowners. #StopTheReform",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"New analysis: Shenzhen zoning data shows net positive outcomes "
|
||||
"after 3 years. Short-term pain, long-term gain for housing equity.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 2: Debate and interaction
|
||||
sim.run_round(2, [
|
||||
("wang_homeowner", "Wang", "CREATE_COMMENT",
|
||||
"Replied to Professor Li: 'Shenzhen is a tier-1 city with "
|
||||
"completely different dynamics. Your comparison is misleading.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "LIKE_POST",
|
||||
"Liked Professor Li's post about Shenzhen housing data.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_COMMENT",
|
||||
"Replied to Wang: 'The methodology controls for city tier "
|
||||
"and population density. I invite you to review the full dataset.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Data from @ProfLi confirms what we've been saying: zoning "
|
||||
"reform works. Let's move forward with evidence, not fear.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 3: Escalation and platform expansion
|
||||
sim.run_round(3, [
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Filing formal petition with 3,000 signatures against the "
|
||||
"zoning reform. Council meeting next Tuesday. All homeowners "
|
||||
"must attend!",
|
||||
"reddit"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Announcing public town hall on zoning reform this Saturday. "
|
||||
"All voices welcome. Data-driven decisions benefit everyone.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"Published full dataset and methodology on my university page. "
|
||||
"Transparency is essential for informed public debate.",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "FOLLOW",
|
||||
"Followed @MayorChen to monitor policy updates.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Mid-simulation: agent recalls own memory + graph
|
||||
print("\n Mid-simulation recall for Mayor Chen:")
|
||||
mayor_recall = sim.recall_agent_memory(
|
||||
"mayor_chen",
|
||||
"What positions have I taken on housing reform?"
|
||||
)
|
||||
for mem in mayor_recall["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in mayor_recall["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 4: Report Generation — Retrieve memories + graph for analysis
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 4: Report Generation")
|
||||
print("=" * 60)
|
||||
|
||||
# Quick search: targeted query
|
||||
print("\n Quick Search: 'opposition to housing reform'")
|
||||
opposition = sim.quick_search("opposition to housing reform", limit=5)
|
||||
for mem in opposition["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in opposition["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# Agent-specific search
|
||||
print("\n Agent Search: Wang's activities")
|
||||
wang_activities = sim.agent_search("wang_homeowner", "all actions and statements")
|
||||
for mem in wang_activities["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
|
||||
# Panorama: full overview
|
||||
print("\n Panorama Search: all memories + relations")
|
||||
panorama = sim.panorama_search()
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
for mem in panorama["memories"][:5]:
|
||||
print(f" Memory: {mem}")
|
||||
if len(panorama["memories"]) > 5:
|
||||
print(f" ... and {len(panorama['memories']) - 5} more")
|
||||
for rel in panorama["relations"][:5]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 5: Deep Interaction — Post-simulation queries
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 5: Deep Interaction")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"How did the debate evolve across the three rounds?",
|
||||
"What evidence was cited by each side?",
|
||||
"Who supports and who opposes the reform?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n Query: '{query}'")
|
||||
results = sim.quick_search(query, limit=3)
|
||||
for mem in results["memories"][:2]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in results["relations"][:2]:
|
||||
src = rel.get("source", rel.get("source_node", "?"))
|
||||
edge = rel.get("relationship", rel.get("relation", "?"))
|
||||
dst = rel.get("destination", rel.get("destination_node", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# Summary
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("SIMULATION COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f" Project ID: {project_id}")
|
||||
print(f" Documents ingested: {sim.stats['documents_ingested']}")
|
||||
print(f" Activities tracked: {sim.stats['activities_recorded']}")
|
||||
print(f" Rounds completed: {sim.stats['rounds_completed']}")
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
|
||||
# Cleanup (uncomment to delete all memories + graph data)
|
||||
# sim.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("MiroFish Swarm Prediction Simulation powered by Mem0 Graph Memory\n")
|
||||
main()
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Graph Memory: The Right Fit for MiroFish
|
||||
|
||||
MiroFish's entire pipeline revolves around a **knowledge graph** — it extracts entities from documents, builds relationships, and queries the graph throughout simulation and reporting. Mem0's Graph Memory provides the same capabilities:
|
||||
|
||||
| MiroFish needs | Zep Cloud | Mem0 Graph Memory |
|
||||
|---|---|---|
|
||||
| **Entity extraction** | Built-in via Zep API | Automatic via LLM extraction |
|
||||
| **Relationship mining** | Graph edges | `(source) --[relationship]--> (destination)` triples |
|
||||
| **Semantic + keyword search** | Semantic + BM25 | Vector similarity + graph relation retrieval |
|
||||
| **Graph traversal** | Node/edge queries | `relations` array in search results |
|
||||
| **Per-agent isolation** | Single shared graph in MiroFish | Native `run_id` scoping |
|
||||
| **Self-hosting** | No (cloud only) | Yes — Neo4j, Memgraph, Kuzu, Neptune |
|
||||
| **Node/memory limits** | Capped on free tier | Unlimited (self-hosted) |
|
||||
|
||||
### How search() Returns Both Memories and Relations
|
||||
|
||||
When Graph Memory is enabled, every `search()` call returns two arrays:
|
||||
|
||||
```python
|
||||
results = memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Vector-matched memories (ordered by similarity)
|
||||
results["results"] # [{"memory": "...", "score": 0.85, ...}, ...]
|
||||
|
||||
# Graph relations connected to query entities
|
||||
results["relations"] # [{"source": "mayor_chen", "relationship": "supports", "destination": "zoning_reform"}, ...]
|
||||
```
|
||||
|
||||
This is what makes Mem0 Graph Memory a natural replacement for Zep — you get semantic search AND structured graph data in a single call.
|
||||
|
||||
### Per-Agent Memory Isolation
|
||||
|
||||
`user_id` scopes the simulation project. `run_id` tags individual agent actions at storage time (we use `run_id` instead of `agent_id` since no assistant memories are involved). Searches use `user_id` for project-wide retrieval:
|
||||
|
||||
```python
|
||||
# Store project-level memories (seed documents)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen supports the zoning reform."}],
|
||||
user_id="my_sim"
|
||||
)
|
||||
|
||||
# Store agent-specific memories (simulation actions)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen [CREATE_POST]: Reform works!"}],
|
||||
run_id="mayor_chen"
|
||||
)
|
||||
|
||||
# Search project-level memories (seed docs)
|
||||
memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Search agent-specific memories (actions stored with run_id)
|
||||
memory.search("housing reform", run_id="mayor_chen")
|
||||
|
||||
# Get all project-level memories + graph relations
|
||||
memory.get_all(user_id="my_sim")
|
||||
```
|
||||
|
||||
<Note>
|
||||
Use `user_id` for project-level data (seed documents) and `run_id` for agent actions — both for `add()` and `search()`. Always match the scope: if you `add()` with `run_id`, `search()` with `run_id`. Use the message list format `[{"role": "user", "content": "..."}]` for all `add()` calls — it works on both OSS and Cloud.
|
||||
</Note>
|
||||
|
||||
### Stage Mapping
|
||||
|
||||
| MiroFish Stage | What Happens | Mem0 Graph Memory Call |
|
||||
|---|---|---|
|
||||
| **1. Graph Building** | Ingest docs, extract entities | `memory.add(doc, user_id=project)` — entities/relations extracted automatically |
|
||||
| **2. Environment Setup** | Enrich agent personas from graph | `memory.search(query, user_id=project)` — returns facts + relations |
|
||||
| **3. Simulation** | Track per-agent actions | `memory.add(messages, run_id=agent)` |
|
||||
| **3. Simulation** | Mid-round recall | `memory.search(query, run_id=agent)` |
|
||||
| **4. Report Generation** | Targeted analysis | `memory.search(query, user_id=project)` — memories + graph |
|
||||
| **4. Report Generation** | Full overview | `memory.get_all(user_id=project)` — all memories + all relations |
|
||||
| **5. Deep Interaction** | Follow-up queries | `memory.search(query, user_id=project)` |
|
||||
|
||||
### Zep-to-Mem0 Migration Reference
|
||||
|
||||
For developers replacing MiroFish's Zep integration. Note that Mem0 Graph Memory covers the core graph operations but some Zep features have no direct equivalent — see caveats below.
|
||||
|
||||
| MiroFish Service | Zep Call | Mem0 Graph Memory Equivalent | Caveat |
|
||||
|---|---|---|---|
|
||||
| GraphBuilderService | `client.graph.create()` | Implicit on first `memory.add()` | |
|
||||
| GraphBuilderService | `client.graph.set_ontology()` | `custom_prompt` in graph_store config | Freeform text, not a typed schema like Zep's `EntityModel`/`EdgeModel` |
|
||||
| GraphBuilderService | `client.graph.add_batch(episodes)` | `memory.add()` per chunk | No batch API — call per chunk |
|
||||
| GraphBuilderService | `client.graph.episode.get(uuid)` | Not needed (add is synchronous in OSS) | |
|
||||
| GraphBuilderService | `client.graph.delete(id)` | `memory.delete_all(user_id=...)` | |
|
||||
| ZepEntityReader | `client.graph.node.get_by_graph_id()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepEntityReader | `client.graph.node.get(uuid)` | `memory.search(entity_name, user_id=...)` | Semantic search, not exact ID lookup |
|
||||
| ZepEntityReader | `client.graph.node.get_entity_edges()` | `memory.search(entity_name, user_id=...)` → `relations` | Returns all matching relations, not edges for a specific node |
|
||||
| ZepGraphMemoryUpdater | `client.graph.add(type="text")` | `memory.add(messages, run_id=...)` | No batch buffering or retry — implement in your wrapper |
|
||||
| ZepToolsService | `search_graph(query, scope)` | `memory.search(query, user_id=...)` → memories + relations | |
|
||||
| ZepToolsService | `get_entities()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepToolsService | Panorama (all nodes + edges) | `memory.get_all(user_id=...)` | No temporal fact separation (active vs historical) |
|
||||
| ZepToolsService | InsightForge (multi-query decomposition) | Not available | Implement LLM-driven sub-query decomposition in your own ReportAgent |
|
||||
| OasisProfileGenerator | `client.graph.search()` | `memory.search(query, user_id=...)` | |
|
||||
|
||||
<Note>
|
||||
**What Mem0 Graph Memory does not cover**: Zep's typed ontology schemas (`EntityModel`, `EdgeModel`), temporal fact lifecycle (`valid_at`/`invalid_at`/`expired_at`), single-node-by-ID lookup, and InsightForge's multi-query decomposition. For InsightForge-like functionality, implement sub-query logic in your own ReportAgent using `memory.search()` as the retrieval primitive.
|
||||
</Note>
|
||||
|
||||
### Custom Extraction Prompts
|
||||
|
||||
Guide what entities and relationships Mem0 extracts — analogous to (but less structured than) Zep's `set_ontology()`:
|
||||
|
||||
```python
|
||||
config = {
|
||||
"graph_store": {
|
||||
"provider": "neo4j",
|
||||
"config": {"url": "...", "username": "...", "password": "..."},
|
||||
"custom_prompt": (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action Types
|
||||
|
||||
MiroFish's OASIS engine produces these agent action types. Format them as natural language when storing. Skip `DO_NOTHING` actions (no memory value). `TREND` and `REFRESH` are Reddit-only discovery actions — store if you want to track browsing behavior.
|
||||
|
||||
| Action Type | Platform | Example Memory Content |
|
||||
|---|---|---|
|
||||
| `CREATE_POST` | Both | `"Mayor Chen [CREATE_POST]: This reform will create 10,000 units"` |
|
||||
| `CREATE_COMMENT` | Reddit | `"Wang [CREATE_COMMENT]: Replied to Prof Li: 'Your data is misleading'"` |
|
||||
| `LIKE_POST` | Both | `"Mayor Chen [LIKE_POST]: Liked Prof Li's post about Shenzhen data"` |
|
||||
| `REPOST` | Twitter | `"Prof Li [REPOST]: Reposted Mayor Chen's town hall announcement"` |
|
||||
| `FOLLOW` | Both | `"Wang [FOLLOW]: Followed @MayorChen"` |
|
||||
| `QUOTE_POST` | Twitter | `"Mayor Chen [QUOTE_POST]: 'Data confirms reform works' quoting Prof Li"` |
|
||||
| `DISLIKE_POST` | Reddit | `"Wang [DISLIKE_POST]: Downvoted Mayor Chen's reform post"` |
|
||||
| `TREND` | Reddit | `"Prof Li [TREND]: Browsed trending topics"` |
|
||||
| `DO_NOTHING` | Both | Skip — no memory value |
|
||||
|
||||
## Running the Example
|
||||
|
||||
```bash
|
||||
# Option A: Neo4j (production)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
|
||||
# Option B: Kuzu (zero dependencies, just need OpenAI key)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
python mirofish_swarm_memory.py # auto-detects missing NEO4J_URL, uses Kuzu
|
||||
```
|
||||
|
||||
<Note>
|
||||
Exact output varies as Mem0 automatically extracts and deduplicates entities. The specific relations and memory counts depend on LLM extraction quality.
|
||||
</Note>
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Unique `user_id` per simulation** — Use timestamps or UUIDs (e.g., `mirofish_housing_1742198400`) to prevent memory collisions between runs
|
||||
2. **Always set `run_id` for agent actions** — Per-agent isolation prevents memory cross-contamination between agents
|
||||
3. **Use `custom_prompt`** — Guide entity extraction to capture domain-specific relationships (people, policies, stances)
|
||||
4. **Format actions as natural language** — `"Mayor Chen [CREATE_POST]: content"` extracts better entities than raw JSON
|
||||
5. **Query relations for reports** — The `relations` array in search results gives structured `(source, relationship, destination)` triples for building analytical reports
|
||||
6. **Cleanup old simulations** — Call `delete_all(user_id=...)` when a simulation run is no longer needed
|
||||
|
||||
## Resources
|
||||
|
||||
- [MiroFish GitHub](https://github.com/666ghj/MiroFish) — Source code and setup guide
|
||||
- [MiroFish Documentation](https://deepwiki.com/666ghj/MiroFish) — Full framework docs
|
||||
- [Mem0 Graph Memory](/open-source/features/graph-memory) — Graph Memory documentation
|
||||
- [Mem0 Documentation](https://docs.mem0.ai/) — Full API reference
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Graph Memory" icon="network-wired" href="/open-source/features/graph-memory">
|
||||
Full Graph Memory documentation with provider setup.
|
||||
</Card>
|
||||
<Card title="MiroFish GitHub" icon="fish" href="https://github.com/666ghj/MiroFish">
|
||||
MiroFish source code and setup guide.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
+2
-1
@@ -375,7 +375,8 @@
|
||||
"cookbooks/frameworks/multimodal-retrieval",
|
||||
"cookbooks/frameworks/eliza-os-character",
|
||||
"cookbooks/frameworks/chrome-extension",
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp"
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp",
|
||||
"cookbooks/frameworks/mirofish-swarm-memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
|
||||
logger.info("About to await mem0_client.search for RAG context")
|
||||
search_results = await mem0_client.search(
|
||||
new_message.text_content,
|
||||
user_id=RAG_USER_ID,
|
||||
filters={"user_id": RAG_USER_ID},
|
||||
)
|
||||
logger.info(f"mem0_client.search returned: {search_results}")
|
||||
if search_results and search_results.get('results', []):
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
pnpm-lock.yaml
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.1",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -37,6 +37,7 @@
|
||||
"start": "pnpm run example memory",
|
||||
"example": "ts-node src/oss/examples/vector-stores/index.ts",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"test:ts": "jest --config jest.config.js",
|
||||
"test:watch": "jest --config jest.config.js --watch",
|
||||
"format": "npm run clean && prettier --write .",
|
||||
@@ -103,7 +104,7 @@
|
||||
"@azure/search-documents": "^12.0.0",
|
||||
"@cloudflare/workers-types": "^4.20250504.0",
|
||||
"@google/genai": "^1.2.0",
|
||||
"@langchain/core": "^0.3.44",
|
||||
"@langchain/core": "^1.0.0",
|
||||
"@mistralai/mistralai": "^1.5.2",
|
||||
"@qdrant/js-client-rest": "1.13.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
|
||||
Generated
+65
-45
@@ -23,8 +23,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)
|
||||
"@langchain/core":
|
||||
specifier: ^0.3.44
|
||||
version: 0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
|
||||
specifier: ^1.0.0
|
||||
version: 1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
"@mistralai/mistralai":
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(zod@3.24.2)
|
||||
@@ -917,12 +917,12 @@ packages:
|
||||
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
|
||||
}
|
||||
|
||||
"@langchain/core@0.3.44":
|
||||
"@langchain/core@1.1.32":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-3BsSFf7STvPPZyl2kMANgtVnCUvDdyP4k+koP+nY2Tczd5V+RFkuazIn/JOj/xxy/neZjr4PxFU4BFyF1aKXOA==,
|
||||
integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
engines: { node: ">=20" }
|
||||
|
||||
"@mistralai/mistralai@1.5.2":
|
||||
resolution:
|
||||
@@ -1214,6 +1214,12 @@ packages:
|
||||
integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==,
|
||||
}
|
||||
|
||||
"@standard-schema/spec@1.1.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==,
|
||||
}
|
||||
|
||||
"@supabase/auth-js@2.68.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -1383,12 +1389,6 @@ packages:
|
||||
integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==,
|
||||
}
|
||||
|
||||
"@types/retry@0.12.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==,
|
||||
}
|
||||
|
||||
"@types/stack-utils@2.0.3":
|
||||
resolution:
|
||||
{
|
||||
@@ -1813,6 +1813,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
chalk@5.6.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==,
|
||||
}
|
||||
engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 }
|
||||
|
||||
char-regex@1.0.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -3295,16 +3302,28 @@ packages:
|
||||
integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==,
|
||||
}
|
||||
|
||||
langsmith@0.3.15:
|
||||
langsmith@0.5.10:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-cv3ebg0Hh0gRbl72cv/uzaZ+KOdfa2mGF1s74vmB2vlNVO/Ap/O9RYaHV+tpR8nwhGZ50R3ILnTOwSwGP+XQxw==,
|
||||
integrity: sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": "*"
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "*"
|
||||
"@opentelemetry/sdk-trace-base": "*"
|
||||
openai: "*"
|
||||
ws: ">=7"
|
||||
peerDependenciesMeta:
|
||||
"@opentelemetry/api":
|
||||
optional: true
|
||||
"@opentelemetry/exporter-trace-otlp-proto":
|
||||
optional: true
|
||||
"@opentelemetry/sdk-trace-base":
|
||||
optional: true
|
||||
openai:
|
||||
optional: true
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
leven@3.1.0:
|
||||
resolution:
|
||||
@@ -3794,13 +3813,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
p-retry@4.6.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
p-timeout@3.2.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4271,13 +4283,6 @@ packages:
|
||||
engines: { node: ">= 0.4" }
|
||||
hasBin: true
|
||||
|
||||
retry@0.13.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==,
|
||||
}
|
||||
engines: { node: ">= 4" }
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4928,6 +4933,13 @@ packages:
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -5143,6 +5155,12 @@ packages:
|
||||
integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==,
|
||||
}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==,
|
||||
}
|
||||
|
||||
snapshots:
|
||||
"@ampproject/remapping@2.3.0":
|
||||
dependencies:
|
||||
@@ -5759,22 +5777,25 @@ snapshots:
|
||||
"@jridgewell/resolve-uri": 3.1.2
|
||||
"@jridgewell/sourcemap-codec": 1.5.0
|
||||
|
||||
"@langchain/core@0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))":
|
||||
"@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)":
|
||||
dependencies:
|
||||
"@cfworker/json-schema": 4.1.1
|
||||
"@standard-schema/spec": 1.1.0
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.19
|
||||
langsmith: 0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
|
||||
langsmith: 0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
uuid: 10.0.0
|
||||
zod: 3.24.2
|
||||
zod-to-json-schema: 3.24.5(zod@3.24.2)
|
||||
uuid: 11.1.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- "@opentelemetry/api"
|
||||
- "@opentelemetry/exporter-trace-otlp-proto"
|
||||
- "@opentelemetry/sdk-trace-base"
|
||||
- openai
|
||||
- ws
|
||||
|
||||
"@mistralai/mistralai@1.5.2(zod@3.24.2)":
|
||||
dependencies:
|
||||
@@ -5919,6 +5940,8 @@ snapshots:
|
||||
dependencies:
|
||||
"@sinonjs/commons": 3.0.1
|
||||
|
||||
"@standard-schema/spec@1.1.0": {}
|
||||
|
||||
"@supabase/auth-js@2.68.0":
|
||||
dependencies:
|
||||
"@supabase/node-fetch": 2.6.15
|
||||
@@ -6038,8 +6061,6 @@ snapshots:
|
||||
|
||||
"@types/phoenix@1.6.6": {}
|
||||
|
||||
"@types/retry@0.12.0": {}
|
||||
|
||||
"@types/stack-utils@2.0.3": {}
|
||||
|
||||
"@types/uuid@10.0.0": {}
|
||||
@@ -6307,6 +6328,8 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
char-regex@1.0.2: {}
|
||||
|
||||
charenc@0.0.2: {}
|
||||
@@ -7389,17 +7412,17 @@ snapshots:
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
langsmith@0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)):
|
||||
langsmith@0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1):
|
||||
dependencies:
|
||||
"@types/uuid": 10.0.0
|
||||
chalk: 4.1.2
|
||||
chalk: 5.6.2
|
||||
console-table-printer: 2.12.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
semver: 7.7.1
|
||||
uuid: 10.0.0
|
||||
optionalDependencies:
|
||||
openai: 4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)
|
||||
ws: 8.18.1
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
@@ -7641,11 +7664,6 @@ snapshots:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-retry@4.6.2:
|
||||
dependencies:
|
||||
"@types/retry": 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@3.2.0:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
@@ -7900,8 +7918,6 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rimraf@5.0.10:
|
||||
@@ -8313,6 +8329,8 @@ snapshots:
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
@@ -8415,3 +8433,5 @@ snapshots:
|
||||
zod: 3.24.2
|
||||
|
||||
zod@3.24.2: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
@@ -18,9 +18,26 @@ export type {
|
||||
AllUsers,
|
||||
User,
|
||||
FeedbackPayload,
|
||||
Feedback,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
export { Feedback, WebhookEvent } from "./mem0.types";
|
||||
|
||||
// Export the main client
|
||||
export { MemoryClient };
|
||||
export default MemoryClient;
|
||||
|
||||
// Export structured exceptions
|
||||
export {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
} from "../common/exceptions";
|
||||
|
||||
export type { MemoryErrorOptions } from "../common/exceptions";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -155,7 +156,7 @@ export default class MemoryClient {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new APIError(`API request failed: ${errorData}`);
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
}
|
||||
const jsonResponse = await response.json();
|
||||
return jsonResponse;
|
||||
@@ -200,8 +201,8 @@ export default class MemoryClient {
|
||||
if (project_id && !this.projectId) this.projectId = project_id;
|
||||
if (user_email) this.telemetryId = user_email;
|
||||
} catch (error: any) {
|
||||
// Convert generic errors to APIError with meaningful messages
|
||||
if (error instanceof APIError) {
|
||||
// Pass through structured exceptions and APIError
|
||||
if (error instanceof MemoryError || error instanceof APIError) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new APIError(
|
||||
@@ -310,7 +311,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_all", [payloadKeys]);
|
||||
const { api_version, page, page_size, ...otherOptions } = options!;
|
||||
const { api_version, page, page_size, ...otherOptions } = options ?? {};
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
otherOptions.org_name = this.organizationName;
|
||||
otherOptions.project_name = this.projectName;
|
||||
@@ -361,7 +362,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("search", [payloadKeys]);
|
||||
const { api_version, ...otherOptions } = options!;
|
||||
const { api_version, ...otherOptions } = options ?? {};
|
||||
const payload = { query, ...otherOptions };
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
payload.org_name = this.organizationName;
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface PromptUpdatePayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
enum WebhookEvent {
|
||||
export enum WebhookEvent {
|
||||
MEMORY_ADDED = "memory_add",
|
||||
MEMORY_UPDATED = "memory_update",
|
||||
MEMORY_DELETED = "memory_delete",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Test helpers for MemoryClient unit tests.
|
||||
* Provides mock fetch, factory functions, and constants.
|
||||
*/
|
||||
|
||||
// ─── Mock Fetch ──────────────────────────────────────────
|
||||
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock fetch function that matches URL patterns to responses.
|
||||
* Patterns are matched using string includes, sorted longest-first
|
||||
* so more specific routes (e.g. /v1/memories/search/) win over
|
||||
* broader ones (e.g. /v1/memories/) regardless of insertion order.
|
||||
*/
|
||||
export function createMockFetch(
|
||||
responses: Map<string, MockResponse>,
|
||||
): jest.Mock {
|
||||
return jest.fn(
|
||||
async (url: string | URL | Request, _options?: RequestInit) => {
|
||||
const urlStr =
|
||||
typeof url === "string"
|
||||
? url
|
||||
: url instanceof URL
|
||||
? url.toString()
|
||||
: url.url;
|
||||
|
||||
// Sort patterns longest-first so specific routes match before broad ones
|
||||
const sortedPatterns = [...responses.entries()].sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
);
|
||||
|
||||
for (const [pattern, response] of sortedPatterns) {
|
||||
if (urlStr.includes(pattern)) {
|
||||
return {
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
statusText: response.status === 200 ? "OK" : "Error",
|
||||
json: async () => response.body,
|
||||
text: async () =>
|
||||
typeof response.body === "string"
|
||||
? response.body
|
||||
: JSON.stringify(response.body),
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({ error: "Not found" }),
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Factory Functions ───────────────────────────────────
|
||||
|
||||
export interface MockMemory {
|
||||
id: string;
|
||||
memory?: string;
|
||||
data?: { memory: string } | null;
|
||||
event?: string;
|
||||
user_id?: string;
|
||||
agent_id?: string | null;
|
||||
app_id?: string | null;
|
||||
run_id?: string | null;
|
||||
hash?: string;
|
||||
categories?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
score?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
export function createMockMemory(
|
||||
overrides: Partial<MockMemory> = {},
|
||||
): MockMemory {
|
||||
return {
|
||||
id: "mem_test_123",
|
||||
memory: "Test memory content",
|
||||
user_id: "user_test",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
categories: [],
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockMemoryHistory {
|
||||
id: string;
|
||||
memory_id: string;
|
||||
input: Array<{ role: string; content: string }>;
|
||||
old_memory: string | null;
|
||||
new_memory: string | null;
|
||||
user_id: string;
|
||||
categories: string[];
|
||||
event: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function createMockMemoryHistory(
|
||||
overrides: Partial<MockMemoryHistory> = {},
|
||||
): MockMemoryHistory {
|
||||
return {
|
||||
id: "hist_test_123",
|
||||
memory_id: "mem_test_123",
|
||||
input: [{ role: "user", content: "test" }],
|
||||
old_memory: null,
|
||||
new_memory: "Test memory",
|
||||
user_id: "user_test",
|
||||
categories: [],
|
||||
event: "ADD",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_memories: number;
|
||||
owner: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
|
||||
return {
|
||||
id: "user_123",
|
||||
name: "test_user",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
total_memories: 5,
|
||||
owner: "owner_123",
|
||||
type: "user",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockAllUsers {
|
||||
count: number;
|
||||
results: MockUser[];
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export function createMockAllUsers(users: MockUser[] = []): MockAllUsers {
|
||||
return {
|
||||
count: users.length,
|
||||
results: users,
|
||||
next: null,
|
||||
previous: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
export const TEST_API_KEY = "test-api-key-12345";
|
||||
export const TEST_HOST = "https://api.test.mem0.ai";
|
||||
export const TEST_ORG_ID = "org_test_123";
|
||||
export const TEST_PROJECT_ID = "proj_test_456";
|
||||
|
||||
export const MOCK_PING_RESPONSE = {
|
||||
status: "ok",
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
user_email: "test@example.com",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standard set of mock responses for common MemoryClient operations.
|
||||
* Returns a Map that can be extended with additional patterns before passing to createMockFetch.
|
||||
*/
|
||||
export function createStandardMockResponses(): Map<string, MockResponse> {
|
||||
const responses = new Map<string, MockResponse>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
return responses;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — batchUpdate, batchDelete.
|
||||
* Tests verify payload transformation (memoryId → memory_id, string → object).
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── batchUpdate() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchUpdate()", () => {
|
||||
test("sends PUT to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([{ memoryId: "mem_1", text: "updated 1" }]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("transforms memoryId to memory_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([
|
||||
{ memoryId: "mem_1", text: "updated 1" },
|
||||
{ memoryId: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memories).toEqual([
|
||||
{ memory_id: "mem_1", text: "updated 1" },
|
||||
{ memory_id: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batchDelete() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchDelete()", () => {
|
||||
test("sends DELETE to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1"]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "DELETE")).toBeDefined();
|
||||
});
|
||||
|
||||
test("wraps string IDs into {memory_id} objects", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1", "mem_2", "mem_3"]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([
|
||||
{ memory_id: "mem_1" },
|
||||
{ memory_id: "mem_2" },
|
||||
{ memory_id: "mem_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* MemoryClient unit tests — add, get, getAll, update, delete, deleteAll, history.
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory, MemoryHistory } from "../mem0.types";
|
||||
import {
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── add() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - add()", () => {
|
||||
test("sends POST to /v1/memories/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "Hello" }], { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes messages in request body", async () => {
|
||||
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add(messages, { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual(messages);
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], {
|
||||
user_id: "user_1",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("user_1");
|
||||
});
|
||||
|
||||
test("attaches org_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("attaches project_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sends empty messages array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - get()", () => {
|
||||
test("sends GET to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws on 404 with error message from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/nonexistent/", {
|
||||
status: 404,
|
||||
body: "Memory not found",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getAll()", () => {
|
||||
test("uses v2 POST endpoint when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("uses v1 GET endpoint by default with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("appends page and page_size to URL as query params", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({
|
||||
user_id: "u1",
|
||||
api_version: "v2",
|
||||
page: 2,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("page="),
|
||||
);
|
||||
expect(call![0]).toContain("page=2");
|
||||
expect(call![0]).toContain("page_size=25");
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.getAll();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - update()", () => {
|
||||
test("sends PUT to /v1/memories/:id/ with text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { text: "Updated text" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(call).toBeDefined();
|
||||
expect(getFetchBody(call!).text).toBe("Updated text");
|
||||
});
|
||||
|
||||
test("sends metadata in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { metadata: { priority: "high" } });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).metadata).toEqual({ priority: "high" });
|
||||
});
|
||||
|
||||
test("sends timestamp in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { timestamp: 1710600000 });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("includes all fields when text + metadata + timestamp provided", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", {
|
||||
text: "Updated",
|
||||
metadata: { source: "test" },
|
||||
timestamp: 1710600000,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.text).toBe("Updated");
|
||||
expect(body.metadata).toEqual({ source: "test" });
|
||||
expect(body.timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("throws when no fields provided", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.update("mem_123", {})).rejects.toThrow(
|
||||
"At least one of text, metadata, or timestamp must be provided",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - delete()", () => {
|
||||
test("sends DELETE to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: { message: "Memory deleted successfully" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.delete("mem_123");
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem_123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteAll()", () => {
|
||||
test("sends DELETE to /v1/memories/ with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("URL-encodes special characters in user_id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "user@email.com" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - history()", () => {
|
||||
test("sends GET to /v1/memories/:id/history/", async () => {
|
||||
const historyEntries = [
|
||||
createMockMemoryHistory({
|
||||
memory_id: "mem_123",
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "I am Alex",
|
||||
}),
|
||||
];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", {
|
||||
status: 200,
|
||||
body: historyEntries,
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.history("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/history/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles empty history without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: MemoryHistory[] = await client.history("mem_123");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* MemoryClient E2E integration tests.
|
||||
*
|
||||
* These tests exercise realistic usage patterns with mock HTTP responses.
|
||||
* Skipped by default — run with MEM0_RUN_E2E=1 to enable.
|
||||
*
|
||||
* Run: MEM0_RUN_E2E=1 npx jest memoryClient.e2e.test.ts
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type {
|
||||
Memory,
|
||||
AllUsers,
|
||||
MemoryHistory,
|
||||
User,
|
||||
Messages,
|
||||
} from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
String(args[0] ?? "").match(
|
||||
/Telemetry|Failed to initialize|Failed to capture/,
|
||||
)
|
||||
)
|
||||
return;
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return;
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// Shared test data matching realistic API responses
|
||||
const userId = "test_user_abc123";
|
||||
const memoryId = "mem_550e8400";
|
||||
|
||||
const mockMemory = createMockMemory({
|
||||
id: memoryId,
|
||||
memory: "Alex is a vegetarian",
|
||||
user_id: userId,
|
||||
event: "ADD",
|
||||
data: { memory: "Alex is a vegetarian" },
|
||||
categories: ["personal"],
|
||||
metadata: null,
|
||||
created_at: "2026-03-17T10:00:00Z",
|
||||
updated_at: "2026-03-17T10:00:00Z",
|
||||
score: 0.95,
|
||||
});
|
||||
|
||||
function mockFetchForTest(
|
||||
extraPatterns?: Record<string, { status: number; body: unknown }>,
|
||||
) {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
responses.set("/v1/memories/search/", { status: 200, body: [mockMemory] });
|
||||
responses.set("/v2/memories/search/", { status: 200, body: [mockMemory] });
|
||||
responses.set("/history/", {
|
||||
status: 200,
|
||||
body: [
|
||||
createMockMemoryHistory({
|
||||
memory_id: memoryId,
|
||||
user_id: userId,
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "Alex is a vegetarian",
|
||||
}),
|
||||
],
|
||||
});
|
||||
responses.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([
|
||||
createMockUser({ id: "entity_1", name: userId, type: "user" }),
|
||||
]),
|
||||
});
|
||||
// This must come last — it's a broad pattern that matches /v1/memories/:id/ and /v1/memories/
|
||||
responses.set("/v1/memories/", { status: 200, body: [mockMemory] });
|
||||
|
||||
if (extraPatterns) {
|
||||
for (const [k, v] of Object.entries(extraPatterns)) {
|
||||
responses.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
global.fetch = createMockFetch(responses);
|
||||
}
|
||||
|
||||
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
|
||||
|
||||
describeOrSkip("MemoryClient API (E2E)", () => {
|
||||
beforeEach(() => mockFetchForTest());
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user" as const, content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant" as const, content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
describe("add messages", () => {
|
||||
let res: Memory[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
res = await client.add(messages1, { user_id: userId });
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
});
|
||||
|
||||
test("first message has a string id", () => {
|
||||
expect(typeof res[0].id).toBe("string");
|
||||
});
|
||||
|
||||
test("first message has a string data.memory", () => {
|
||||
expect(typeof res[0].data?.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first message has a string event", () => {
|
||||
expect(typeof res[0].event).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve specific memory by ID", () => {
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const memories = await client.getAll({ user_id: userId });
|
||||
memory = Array.isArray(memories) ? memories[0] : memories;
|
||||
});
|
||||
|
||||
test("returns string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("returns string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("each category is a string", () => {
|
||||
if (Array.isArray(memory.categories)) {
|
||||
expect(
|
||||
memory.categories.every((c: string) => typeof c === "string"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("updated_at is a valid date", () => {
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve all users", () => {
|
||||
let allUsers: AllUsers;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
allUsers = await client.users();
|
||||
});
|
||||
|
||||
test("count is a number", () => {
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
});
|
||||
|
||||
test("first user has a string id", () => {
|
||||
expect(typeof allUsers.results[0].id).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string name", () => {
|
||||
expect(typeof allUsers.results[0].name).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string created_at", () => {
|
||||
expect(typeof allUsers.results[0].created_at).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string updated_at", () => {
|
||||
expect(typeof allUsers.results[0].updated_at).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a number total_memories", () => {
|
||||
expect(typeof allUsers.results[0].total_memories).toBe("number");
|
||||
});
|
||||
|
||||
test("first user has a string type", () => {
|
||||
expect(typeof allUsers.results[0].type).toBe("string");
|
||||
});
|
||||
|
||||
test("results contain an entity matching userId", () => {
|
||||
const entity = allUsers.results.find(
|
||||
(user: User) => user.name === userId,
|
||||
);
|
||||
expect(entity).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test("matched entity has a string id", () => {
|
||||
const entity = allUsers.results.find(
|
||||
(user: User) => user.name === userId,
|
||||
);
|
||||
expect(typeof entity?.id).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve all memories for the user", () => {
|
||||
let memories: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
memories = await client.getAll({ user_id: userId });
|
||||
memory = memories[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(memories)).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory has a string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("first memory metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("first memory updated_at is a valid date", () => {
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search with API version 2", () => {
|
||||
let results: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
results = await client.search("What do you know about me?", {
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
});
|
||||
memory = results[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("first result has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first result metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first result categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first result created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("first result has a number score", () => {
|
||||
expect(typeof memory.score).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search with API version 1", () => {
|
||||
let results: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
results = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
memory = results[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("first result has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("first result has a number score", () => {
|
||||
expect(typeof memory.score).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve history of a specific memory", () => {
|
||||
let history: MemoryHistory[];
|
||||
let entry: MemoryHistory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
history = await client.history(memoryId);
|
||||
entry = history[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
});
|
||||
|
||||
test("first entry has a string id", () => {
|
||||
expect(typeof entry.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry has a string memory_id", () => {
|
||||
expect(typeof entry.memory_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry has a string user_id", () => {
|
||||
expect(typeof entry.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry user_id matches the requested userId", () => {
|
||||
expect(entry.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("old_memory is null or a string", () => {
|
||||
expect(
|
||||
entry.old_memory === null || typeof entry.old_memory === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("new_memory is null or a string", () => {
|
||||
expect(
|
||||
entry.new_memory === null || typeof entry.new_memory === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("created_at is a valid date", () => {
|
||||
expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
test("updated_at is a valid date", () => {
|
||||
expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
test("event is one of ADD, UPDATE, DELETE, NOOP", () => {
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event);
|
||||
});
|
||||
|
||||
test("ADD event has null old_memory", () => {
|
||||
expect(entry.old_memory).toBeNull();
|
||||
});
|
||||
|
||||
test("ADD event has non-null new_memory", () => {
|
||||
expect(entry.new_memory).not.toBeNull();
|
||||
});
|
||||
|
||||
test("input is an array or null", () => {
|
||||
expect(Array.isArray(entry.input) || entry.input === null).toBe(true);
|
||||
});
|
||||
|
||||
test("each input item is an object", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(entry.input.every((i: Messages) => typeof i === "object")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("each input item has a string content", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(
|
||||
entry.input.every((i: Messages) => typeof i.content === "string"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("each input item has a valid role", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(
|
||||
entry.input.every((i: Messages) =>
|
||||
["user", "assistant"].includes(i.role),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete user", () => {
|
||||
test("returns success message", async () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "org_test",
|
||||
projectId: "proj_test",
|
||||
});
|
||||
client.client.delete = jest.fn().mockResolvedValue({
|
||||
data: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
|
||||
const result = await client.deleteUsers({ user_id: userId });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — constructor, validation, ping.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
MemoryNotFoundError,
|
||||
ValidationError,
|
||||
MemoryError,
|
||||
} from "../../common/exceptions";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_HOST,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
installConsoleSuppression,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Initialization", () => {
|
||||
beforeEach(() => setupMockFetch());
|
||||
|
||||
test("throws when API key is empty string", () => {
|
||||
expect(() => new MemoryClient({ apiKey: "" })).toThrow(
|
||||
"Mem0 API key is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is whitespace only", () => {
|
||||
expect(() => new MemoryClient({ apiKey: " " })).toThrow(
|
||||
"Mem0 API key cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is not a string", () => {
|
||||
expect(
|
||||
() => new MemoryClient({ apiKey: 123 as unknown as string }),
|
||||
).toThrow("Mem0 API key must be a string");
|
||||
});
|
||||
|
||||
test("sets default host to https://api.mem0.ai", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.host).toBe("https://api.mem0.ai");
|
||||
});
|
||||
|
||||
test("uses custom host when provided", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY, host: TEST_HOST });
|
||||
expect(client.host).toBe(TEST_HOST);
|
||||
});
|
||||
|
||||
test("sets organizationId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets Authorization header with Token prefix", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.headers["Authorization"]).toBe(`Token ${TEST_API_KEY}`);
|
||||
});
|
||||
|
||||
test("creates axios client with 60s timeout", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.client.defaults.timeout).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ping ────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - ping()", () => {
|
||||
test("sets organizationId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets telemetryId from user_email in response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.telemetryId).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("preserves constructor organizationId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe("my_org");
|
||||
});
|
||||
|
||||
test("preserves constructor projectId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe("my_proj");
|
||||
});
|
||||
|
||||
test("throws AuthenticationError on 401 response", async () => {
|
||||
const { AuthenticationError } = await import("../../common/exceptions");
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 401,
|
||||
body: "Invalid API key",
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: "bad-key" });
|
||||
await expect(client.ping()).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("throws on invalid (non-object) response format", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: "not an object" });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("Invalid response format");
|
||||
});
|
||||
|
||||
test("throws on status !== ok in response", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 200,
|
||||
body: { status: "error", message: "API Key is invalid" },
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("API Key is invalid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Error Handling", () => {
|
||||
test("404 throws MemoryNotFoundError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/gone/", { status: 404, body: "Memory not found" });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("gone")).rejects.toThrow(MemoryNotFoundError);
|
||||
await expect(client.get("gone")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
|
||||
test("500 throws MemoryError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/err/", {
|
||||
status: 500,
|
||||
body: "Internal server error",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("err")).rejects.toThrow(MemoryError);
|
||||
await expect(client.get("err")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
test("400 throws ValidationError with details from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/bad/", {
|
||||
status: 400,
|
||||
body: "Invalid request: user_id is required",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("bad")).rejects.toThrow(ValidationError);
|
||||
await expect(client.get("bad")).rejects.toThrow(
|
||||
"Invalid request: user_id is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("Authorization header is included in fetch calls", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_1/", {
|
||||
status: 200,
|
||||
body: { id: "mem_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_1");
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_1/"),
|
||||
);
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("network failure (fetch throws) is propagated", async () => {
|
||||
global.fetch = jest.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/v1/memories/net_err/")) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (urlStr.includes("/v1/ping/")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => MOCK_PING_RESPONSE,
|
||||
text: async () => JSON.stringify(MOCK_PING_RESPONSE),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("net_err")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getProject, updateProject, exports, feedback.
|
||||
* Tests verify request construction and validation behavior.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { Feedback } from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── getProject() ───────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getProject()", () => {
|
||||
test("throws when organizationId and projectId not set", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: { status: "ok" } });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
try {
|
||||
await client.ping();
|
||||
} catch {
|
||||
// ping might throw — but orgId stays null
|
||||
}
|
||||
|
||||
await expect(
|
||||
client.getProject({ fields: ["custom_instructions"] }),
|
||||
).rejects.toThrow("organizationId and projectId must be set");
|
||||
});
|
||||
|
||||
test("sends GET to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Be helpful" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getProject({ fields: ["custom_instructions"] });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/orgs/organizations/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("fields=custom_instructions");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProject() ────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - updateProject()", () => {
|
||||
test("sends PATCH to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes custom_instructions in PATCH body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(getFetchBody(call!).custom_instructions).toBe(
|
||||
"Updated instructions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── feedback() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - feedback()", () => {
|
||||
test("sends POST to /v1/feedback/ with payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes memory_id, feedback, and reason in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memory_id).toBe("mem_123");
|
||||
expect(body.feedback).toBe("POSITIVE");
|
||||
expect(body.feedback_reason).toBe("Very helpful");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Memory Exports ─────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Memory Exports", () => {
|
||||
test("createMemoryExport throws when missing filters or schema", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(
|
||||
client.createMemoryExport({
|
||||
filters: null as never,
|
||||
schema: null as never,
|
||||
}),
|
||||
).rejects.toThrow("Missing filters or schema");
|
||||
});
|
||||
|
||||
test("createMemoryExport sends POST to /v1/exports/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Export created", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory", "user_id"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createMemoryExport attaches org_id and project_id to body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Created", id: "exp_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/exports/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("getMemoryExport throws when missing both id and filters", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(client.getMemoryExport({} as never)).rejects.toThrow(
|
||||
"Missing memory_export_id or filters",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMemoryExport sends POST to /v1/exports/get/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/get/", {
|
||||
status: 200,
|
||||
body: { message: "Export data", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getMemoryExport({ memory_export_id: "exp_123" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/get/", "POST")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — search (v1/v2 routing, filters).
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory } from "../mem0.types";
|
||||
import { createMockMemory, TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - search()", () => {
|
||||
test("sends POST to /v1/memories/search/ by default", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes query in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).query).toBe("What is my name?");
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("u1");
|
||||
});
|
||||
|
||||
test("uses /v2/memories/search/ when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("passes filters through to the v2 API body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("query", {
|
||||
api_version: "v2",
|
||||
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.filters).toEqual({
|
||||
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("query");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty results array", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("nonexistent query", {
|
||||
user_id: "u1",
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
import { MemoryClient } from "../mem0";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const apiKey = process.env.MEM0_API_KEY || "";
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationId: "org_gRNd1RrQa4y52iK4tG8o59hXyVbaULikgq4kethC", projectId: "proj_7RfMkWs0PMgXYweGUNKqV9M9mgIRNt5XcupE7mSP" });
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationName: "saket-default-org", projectName: "default-project" });
|
||||
const client = new MemoryClient({ apiKey, host: "https://api.mem0.ai" });
|
||||
|
||||
// Generate a random string
|
||||
const randomString = () => {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
};
|
||||
|
||||
describe("MemoryClient API", () => {
|
||||
let userId: string, memoryId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
userId = randomString();
|
||||
});
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user", content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant", content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
it("should add messages successfully", async () => {
|
||||
const res = await client.add(messages1, { user_id: userId || "" });
|
||||
|
||||
// Validate the response contains an iterable list
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
|
||||
// Validate the fields of the first message in the response
|
||||
const message = res[0];
|
||||
expect(typeof message.id).toBe("string");
|
||||
expect(typeof message.data?.memory).toBe("string");
|
||||
expect(typeof message.event).toBe("string");
|
||||
|
||||
// Store the memory ID for later use
|
||||
memoryId = message.id;
|
||||
});
|
||||
|
||||
it("should retrieve the specific memory by ID", async () => {
|
||||
const memory = await client.get(memoryId);
|
||||
|
||||
// Validate that the memory fields have the correct types and values
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or any object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(Array.isArray(memory.categories) || memory.categories === null).toBe(
|
||||
true,
|
||||
);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
it("should retrieve all users successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
|
||||
// Validate the number of users is a number
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
|
||||
// Validate the structure of the first user
|
||||
const firstUser = allUsers.results[0];
|
||||
expect(typeof firstUser.id).toBe("string");
|
||||
expect(typeof firstUser.name).toBe("string");
|
||||
expect(typeof firstUser.created_at).toBe("string");
|
||||
expect(typeof firstUser.updated_at).toBe("string");
|
||||
expect(typeof firstUser.total_memories).toBe("number");
|
||||
expect(typeof firstUser.type).toBe("string");
|
||||
|
||||
// Find the user with the name matching userId
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
expect(entity).not.toBeUndefined();
|
||||
|
||||
// Store the entity ID for later use
|
||||
const entity_id = entity?.id;
|
||||
expect(typeof entity_id).toBe("string");
|
||||
});
|
||||
|
||||
it("should retrieve all memories for the user", async () => {
|
||||
const res3 = await client.getAll({ user_id: userId });
|
||||
|
||||
// Validate that res3 is an iterable list (array)
|
||||
expect(Array.isArray(res3)).toBe(true);
|
||||
|
||||
if (res3.length > 0) {
|
||||
// Iterate through the first memory for validation (you can loop through all if needed)
|
||||
const memory = res3[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
} else {
|
||||
// If there are no memories, assert that the list is empty
|
||||
expect(res3.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query and filters (API version 2)", async () => {
|
||||
const searchOptionsV2 = {
|
||||
query: "What do you know about me?",
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
};
|
||||
|
||||
const searchResultV2 = await client.search(
|
||||
"What do you know about me?",
|
||||
searchOptionsV2,
|
||||
);
|
||||
|
||||
// Validate that searchResultV2 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV2)).toBe(true);
|
||||
|
||||
if (searchResultV2.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV2[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
if (memory.user_id) {
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
}
|
||||
|
||||
if (memory.agent_id) {
|
||||
// Should be a string (agent_id)
|
||||
expect(typeof memory.agent_id).toBe("string");
|
||||
expect(memory.agent_id).toBe("shopping-assistant");
|
||||
}
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV2.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query (API version 1)", async () => {
|
||||
const searchResultV1 = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
// Validate that searchResultV1 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV1)).toBe(true);
|
||||
|
||||
if (searchResultV1.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV1[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV1.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retrieve history of a specific memory and validate the fields", async () => {
|
||||
const res22 = await client.history(memoryId);
|
||||
|
||||
// Validate that res22 is an iterable list (array)
|
||||
expect(Array.isArray(res22)).toBe(true);
|
||||
|
||||
if (res22.length > 0) {
|
||||
// Iterate through the first history entry for validation (you can loop through all if needed)
|
||||
const historyEntry = res22[0];
|
||||
|
||||
// Should be a string (history entry id)
|
||||
expect(typeof historyEntry.id).toBe("string");
|
||||
|
||||
// Should be a string (memory id related to the history entry)
|
||||
expect(typeof historyEntry.memory_id).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof historyEntry.user_id).toBe("string");
|
||||
expect(historyEntry.user_id).toBe(userId);
|
||||
|
||||
// Should be a string or null (old memory)
|
||||
expect(
|
||||
historyEntry.old_memory === null ||
|
||||
typeof historyEntry.old_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be a string or null (new memory)
|
||||
expect(
|
||||
historyEntry.new_memory === null ||
|
||||
typeof historyEntry.new_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(historyEntry.categories) ||
|
||||
historyEntry.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.categories)) {
|
||||
historyEntry.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(historyEntry.created_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(historyEntry.updated_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a string, one of: ADD, UPDATE, DELETE, NOOP
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(historyEntry.event);
|
||||
|
||||
// Validate conditions based on event type
|
||||
if (historyEntry.event === "ADD") {
|
||||
expect(historyEntry.old_memory).toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "UPDATE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "DELETE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).toBeNull();
|
||||
}
|
||||
|
||||
// Should be a list of objects or null (input)
|
||||
expect(
|
||||
Array.isArray(historyEntry.input) || historyEntry.input === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.input)) {
|
||||
historyEntry.input.forEach((input) => {
|
||||
// Each input should be an object
|
||||
expect(typeof input).toBe("object");
|
||||
|
||||
// Should have string content
|
||||
expect(typeof input.content).toBe("string");
|
||||
|
||||
// Should have a role that is either 'user' or 'assistant'
|
||||
expect(["user", "assistant"]).toContain(input.role);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If no history entries, assert that the list is empty
|
||||
expect(res22.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete the user successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
|
||||
if (entity) {
|
||||
const deletedUser = await client.deleteUser(entity.id);
|
||||
|
||||
// Validate the deletion message
|
||||
expect(deletedUser.message).toBe("Entity deleted successfully!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* MemoryClient unit tests — users, deleteUser, deleteUsers.
|
||||
* Tests verify entity type routing and request construction.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── users() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - users()", () => {
|
||||
test("sends GET to /v1/entities/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser()]),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.users();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/entities/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUsers() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
function createClientWithMockedAxios() {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
const axiosDeleteMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
client.client.delete = axiosDeleteMock;
|
||||
return { client, axiosDeleteMock };
|
||||
}
|
||||
|
||||
test("routes user_id to DELETE /v2/entities/user/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ user_id: "u1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith("/v2/entities/user/u1/", {
|
||||
params: expect.objectContaining({
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes agent_id to DELETE /v2/entities/agent/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ agent_id: "agent_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/agent/agent_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes app_id to DELETE /v2/entities/app/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ app_id: "app_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/app/app_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes run_id to DELETE /v2/entities/run/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ run_id: "run_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/run/run_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 'Entity deleted successfully.' for single entity", async () => {
|
||||
const { client } = createClientWithMockedAxios();
|
||||
const result = await client.deleteUsers({ user_id: "u1" });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
|
||||
test("returns 'All users, agents, apps and runs deleted.' when no params given", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser({ name: "u1", type: "user" })]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
|
||||
const result = await client.deleteUsers();
|
||||
expect(result.message).toBe("All users, agents, apps and runs deleted.");
|
||||
});
|
||||
|
||||
test("throws when no entities exist to delete", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest.fn();
|
||||
|
||||
await expect(client.deleteUsers()).rejects.toThrow("No entities to delete");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUser() (deprecated) ──────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
test("sends DELETE to /v1/entities/:type/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/123/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({
|
||||
entity_id: 123 as never,
|
||||
entity_type: "user",
|
||||
});
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("defaults entity_type to 'user' when empty", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/456/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({ entity_id: 456 as never, entity_type: "" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
|
||||
* Tests verify request URL and HTTP method, not mock response values.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { WebhookEvent } from "../mem0.types";
|
||||
import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - Webhooks", () => {
|
||||
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getWebhooks();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook includes webhook payload in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("new-hook");
|
||||
expect(body.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("updateWebhook includes updated fields in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("updated-hook");
|
||||
expect(body.url).toBe("https://new-url.com");
|
||||
});
|
||||
|
||||
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook deleted" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteWebhook({ webhookId: "wh_1" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared test setup for MemoryClient unit tests.
|
||||
* Provides mock fetch wiring, console suppression, and utility finders.
|
||||
*/
|
||||
import {
|
||||
createMockFetch,
|
||||
createStandardMockResponses,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
// ─── Global fetch mock + telemetry suppression ───────────
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
export function setupMockFetch(
|
||||
extraResponses?: Map<string, { status: number; body: unknown }>,
|
||||
): jest.Mock {
|
||||
const responses = createStandardMockResponses();
|
||||
if (extraResponses) {
|
||||
for (const [key, value] of extraResponses) {
|
||||
responses.set(key, value);
|
||||
}
|
||||
}
|
||||
const mockFetch = createMockFetch(responses);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
export function installConsoleSuppression(): void {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (
|
||||
msg.includes("Telemetry") ||
|
||||
msg.includes("Failed to initialize") ||
|
||||
msg.includes("Failed to capture")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (msg.includes("telemetry") || msg.includes("Telemetry")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helper: find specific fetch calls ───────────────────
|
||||
|
||||
export function findFetchCall(
|
||||
mock: jest.Mock,
|
||||
urlPattern: string,
|
||||
method?: string,
|
||||
): [string, RequestInit] | undefined {
|
||||
return mock.mock.calls.find((call: [string, RequestInit]) => {
|
||||
const urlMatch = call[0].includes(urlPattern);
|
||||
if (!method) return urlMatch;
|
||||
return urlMatch && call[1]?.method === method;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFetchBody(
|
||||
call: [string, RequestInit],
|
||||
): Record<string, unknown> {
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
export { MOCK_PING_RESPONSE };
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
HTTP_STATUS_TO_EXCEPTION,
|
||||
} from "./exceptions";
|
||||
|
||||
describe("MemoryError", () => {
|
||||
const error = new MemoryError("test error", "MEM_001", {
|
||||
details: { operation: "add" },
|
||||
suggestion: "Try again",
|
||||
debugInfo: { requestId: "req_123" },
|
||||
});
|
||||
|
||||
test("is an instance of Error", () => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test("has correct message", () => {
|
||||
expect(error.message).toBe("test error");
|
||||
});
|
||||
|
||||
test("has correct errorCode", () => {
|
||||
expect(error.errorCode).toBe("MEM_001");
|
||||
});
|
||||
|
||||
test("has correct details", () => {
|
||||
expect(error.details).toEqual({ operation: "add" });
|
||||
});
|
||||
|
||||
test("has correct suggestion", () => {
|
||||
expect(error.suggestion).toBe("Try again");
|
||||
});
|
||||
|
||||
test("has correct debugInfo", () => {
|
||||
expect(error.debugInfo).toEqual({ requestId: "req_123" });
|
||||
});
|
||||
|
||||
test("defaults details to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.details).toEqual({});
|
||||
});
|
||||
|
||||
test("defaults suggestion to undefined", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
test("defaults debugInfo to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.debugInfo).toEqual({});
|
||||
});
|
||||
|
||||
test("is throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new MemoryError("fail", "MEM_001");
|
||||
}).toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exception subclasses", () => {
|
||||
const subclasses = [
|
||||
{ Class: AuthenticationError, name: "AuthenticationError" },
|
||||
{ Class: RateLimitError, name: "RateLimitError" },
|
||||
{ Class: ValidationError, name: "ValidationError" },
|
||||
{ Class: MemoryNotFoundError, name: "MemoryNotFoundError" },
|
||||
{ Class: NetworkError, name: "NetworkError" },
|
||||
{ Class: ConfigurationError, name: "ConfigurationError" },
|
||||
{ Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" },
|
||||
] as const;
|
||||
|
||||
test.each(subclasses)("$name extends MemoryError", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name extends Error", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name has correct name", ({ Class, name }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error.name).toBe(name);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name supports instanceof checks", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error instanceof Class).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createExceptionFromResponse", () => {
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 401 to errorCode HTTP_401", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.errorCode).toBe("HTTP_401");
|
||||
});
|
||||
|
||||
test("maps 401 to authentication suggestion", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.suggestion).toBe(
|
||||
"Please check your API key and authentication credentials",
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error).toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 429 passes debugInfo through", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error.debugInfo).toEqual({ retryAfter: 60 });
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
const error = createExceptionFromResponse(404, "Not found");
|
||||
expect(error).toBeInstanceOf(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 400 to ValidationError", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request");
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
const error = createExceptionFromResponse(413, "Quota exceeded");
|
||||
expect(error).toBeInstanceOf(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test.each([502, 503, 504])("maps %i to NetworkError", (code) => {
|
||||
const error = createExceptionFromResponse(code, "Service unavailable");
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 500 to errorCode HTTP_500", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error.errorCode).toBe("HTTP_500");
|
||||
});
|
||||
|
||||
test("maps unknown status to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps unknown status to correct errorCode", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.errorCode).toBe("HTTP_418");
|
||||
});
|
||||
|
||||
test("maps unknown status to retry suggestion", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.suggestion).toBe("Please try again later");
|
||||
});
|
||||
|
||||
test("uses response text as message", () => {
|
||||
const error = createExceptionFromResponse(400, "Invalid user_id format");
|
||||
expect(error.message).toBe("Invalid user_id format");
|
||||
});
|
||||
|
||||
test("falls back to generic message when response text is empty", () => {
|
||||
const error = createExceptionFromResponse(500, "");
|
||||
expect(error.message).toBe("HTTP 500 error");
|
||||
});
|
||||
|
||||
test("passes details through", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request", {
|
||||
details: { field: "user_id", value: "" },
|
||||
});
|
||||
expect(error.details).toEqual({ field: "user_id", value: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP_STATUS_TO_EXCEPTION", () => {
|
||||
test("maps 400 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 403 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 408 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 409 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test("maps 422 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 502 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 503 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 504 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Structured exception classes for mem0 TypeScript SDK.
|
||||
*
|
||||
* Provides specific, actionable exceptions with error codes, suggestions,
|
||||
* and debug information. Maps HTTP status codes to appropriate exception types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { RateLimitError, MemoryNotFoundError } from 'mem0ai'
|
||||
*
|
||||
* try {
|
||||
* await client.get(memoryId)
|
||||
* } catch (e) {
|
||||
* if (e instanceof MemoryNotFoundError) {
|
||||
* console.log(e.suggestion) // "The requested resource was not found"
|
||||
* } else if (e instanceof RateLimitError) {
|
||||
* await sleep(e.debugInfo.retryAfter ?? 60)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface MemoryErrorOptions {
|
||||
details?: Record<string, unknown>;
|
||||
suggestion?: string;
|
||||
debugInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base exception for all memory-related errors.
|
||||
*
|
||||
* Every mem0 exception includes an error code for programmatic handling,
|
||||
* optional details, a user-friendly suggestion, and debug information.
|
||||
*/
|
||||
export class MemoryError extends Error {
|
||||
readonly errorCode: string;
|
||||
readonly details: Record<string, unknown>;
|
||||
readonly suggestion?: string;
|
||||
readonly debugInfo: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options: MemoryErrorOptions = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MemoryError";
|
||||
this.errorCode = errorCode;
|
||||
this.details = options.details ?? {};
|
||||
this.suggestion = options.suggestion;
|
||||
this.debugInfo = options.debugInfo ?? {};
|
||||
|
||||
// Fix prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when authentication fails (401, 403). */
|
||||
export class AuthenticationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when rate limits are exceeded (429). */
|
||||
export class RateLimitError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when input validation fails (400, 409, 422). */
|
||||
export class ValidationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a memory is not found (404). */
|
||||
export class MemoryNotFoundError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when network connectivity issues occur (408, 502, 503, 504). */
|
||||
export class NetworkError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when client configuration is invalid. */
|
||||
export class ConfigurationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when memory quota is exceeded (413). */
|
||||
export class MemoryQuotaExceededError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryQuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Status → Exception Mapping ─────────────────────
|
||||
|
||||
type MemoryErrorConstructor = new (
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) => MemoryError;
|
||||
|
||||
export const HTTP_STATUS_TO_EXCEPTION: Record<number, MemoryErrorConstructor> =
|
||||
{
|
||||
400: ValidationError,
|
||||
401: AuthenticationError,
|
||||
403: AuthenticationError,
|
||||
404: MemoryNotFoundError,
|
||||
408: NetworkError,
|
||||
409: ValidationError,
|
||||
413: MemoryQuotaExceededError,
|
||||
422: ValidationError,
|
||||
429: RateLimitError,
|
||||
500: MemoryError,
|
||||
502: NetworkError,
|
||||
503: NetworkError,
|
||||
504: NetworkError,
|
||||
};
|
||||
|
||||
const HTTP_SUGGESTIONS: Record<number, string> = {
|
||||
400: "Please check your request parameters and try again",
|
||||
401: "Please check your API key and authentication credentials",
|
||||
403: "You don't have permission to perform this operation",
|
||||
404: "The requested resource was not found",
|
||||
408: "Request timed out. Please try again",
|
||||
409: "Resource conflict. Please check your request",
|
||||
413: "Request too large. Please reduce the size of your request",
|
||||
422: "Invalid request data. Please check your input",
|
||||
429: "Rate limit exceeded. Please wait before making more requests",
|
||||
500: "Internal server error. Please try again later",
|
||||
502: "Service temporarily unavailable. Please try again later",
|
||||
503: "Service unavailable. Please try again later",
|
||||
504: "Gateway timeout. Please try again later",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an appropriate exception based on HTTP response status code.
|
||||
*
|
||||
* @param statusCode - HTTP status code from the response
|
||||
* @param responseText - Response body text
|
||||
* @param options - Additional error context (details, debugInfo)
|
||||
* @returns An instance of the appropriate MemoryError subclass
|
||||
*/
|
||||
export function createExceptionFromResponse(
|
||||
statusCode: number,
|
||||
responseText: string,
|
||||
options: Omit<MemoryErrorOptions, "suggestion"> = {},
|
||||
): MemoryError {
|
||||
const ExceptionClass = HTTP_STATUS_TO_EXCEPTION[statusCode] ?? MemoryError;
|
||||
const errorCode = `HTTP_${statusCode}`;
|
||||
const suggestion = HTTP_SUGGESTIONS[statusCode] ?? "Please try again later";
|
||||
|
||||
return new ExceptionClass(
|
||||
responseText || `HTTP ${statusCode} error`,
|
||||
errorCode,
|
||||
{ ...options, suggestion },
|
||||
);
|
||||
}
|
||||
@@ -20,14 +20,28 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const baseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
userConf?.url;
|
||||
const embeddingDims =
|
||||
userConf?.embeddingDims ??
|
||||
((userConf as Record<string, unknown>)?.embedding_dims as
|
||||
| number
|
||||
| undefined);
|
||||
|
||||
return {
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
: defaultConf.apiKey,
|
||||
model: finalModel,
|
||||
baseURL,
|
||||
url: userConf?.url,
|
||||
embeddingDims: userConf?.embeddingDims,
|
||||
embeddingDims,
|
||||
modelProperties:
|
||||
userConf?.modelProperties !== undefined
|
||||
? userConf.modelProperties
|
||||
@@ -43,13 +57,23 @@ export class ConfigManager {
|
||||
const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config;
|
||||
const userConf = userConfig.vectorStore?.config;
|
||||
|
||||
// Resolve the vector store dimension. If the user explicitly
|
||||
// provided one, use it. Otherwise leave it undefined so that
|
||||
// Memory._autoInitialize() can auto-detect it by running a
|
||||
// probe embedding at startup — this makes *any* embedder work
|
||||
// out of the box without the user needing to know or set the
|
||||
// dimension manually.
|
||||
const explicitDimension =
|
||||
userConf?.dimension ||
|
||||
userConfig.embedder?.config?.embeddingDims ||
|
||||
undefined;
|
||||
|
||||
// Prioritize user-provided client instance
|
||||
if (userConf?.client && typeof userConf.client === "object") {
|
||||
return {
|
||||
client: userConf.client,
|
||||
// Include other fields from userConf if necessary, or omit defaults
|
||||
collectionName: userConf.collectionName, // Can be undefined
|
||||
dimension: userConf.dimension || defaultConf.dimension, // Merge dimension
|
||||
collectionName: userConf.collectionName,
|
||||
dimension: explicitDimension,
|
||||
...userConf, // Include any other passthrough fields from user
|
||||
};
|
||||
} else {
|
||||
@@ -57,7 +81,7 @@ export class ConfigManager {
|
||||
return {
|
||||
collectionName:
|
||||
userConf?.collectionName || defaultConf.collectionName,
|
||||
dimension: userConf?.dimension || defaultConf.dimension,
|
||||
dimension: explicitDimension,
|
||||
// Ensure client is not carried over from defaults if not provided by user
|
||||
client: undefined,
|
||||
// Include other passthrough fields from userConf even if no client
|
||||
@@ -80,8 +104,17 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const llmBaseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
defaultConf.baseURL;
|
||||
|
||||
return {
|
||||
baseURL: userConf?.baseURL || defaultConf.baseURL,
|
||||
baseURL: llmBaseURL,
|
||||
url: userConf?.url,
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
@@ -95,16 +128,34 @@ export class ConfigManager {
|
||||
})(),
|
||||
},
|
||||
historyDbPath:
|
||||
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.historyDbPath,
|
||||
userConfig.historyDbPath ||
|
||||
userConfig.historyStore?.config?.historyDbPath ||
|
||||
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
|
||||
customPrompt: userConfig.customPrompt,
|
||||
graphStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.graphStore,
|
||||
...userConfig.graphStore,
|
||||
},
|
||||
historyStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.historyStore,
|
||||
...userConfig.historyStore,
|
||||
},
|
||||
historyStore: (() => {
|
||||
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
|
||||
const historyProvider =
|
||||
userConfig.historyStore?.provider || defaultHistoryStore.provider;
|
||||
const isSqlite = historyProvider.toLowerCase() === "sqlite";
|
||||
|
||||
// Precedence: explicit historyStore.config > top-level historyDbPath > default
|
||||
return {
|
||||
...defaultHistoryStore,
|
||||
...userConfig.historyStore,
|
||||
provider: historyProvider,
|
||||
config: {
|
||||
...(isSqlite ? defaultHistoryStore.config : {}),
|
||||
...(isSqlite && userConfig.historyDbPath
|
||||
? { historyDbPath: userConfig.historyDbPath }
|
||||
: {}),
|
||||
...userConfig.historyStore?.config,
|
||||
},
|
||||
};
|
||||
})(),
|
||||
disableHistory:
|
||||
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
|
||||
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class GoogleEmbedder implements Embedder {
|
||||
const response = await this.google.models.embedContent({
|
||||
model: this.model,
|
||||
contents: texts,
|
||||
config: { outputDimensionality: 768 },
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
});
|
||||
return response.embeddings!.map((item) => item.values!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import OpenAI from "openai";
|
||||
import { Embedder } from "./base";
|
||||
import { EmbeddingConfig } from "../types";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioEmbedder implements Embedder {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
|
||||
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
|
||||
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
|
||||
this.model = config.model || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const normalized =
|
||||
typeof text === "string" ? text.replace(/\n/g, " ") : String(text);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
const normalized = texts.map((t) =>
|
||||
typeof t === "string" ? t.replace(/\n/g, " ") : String(t),
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class OllamaEmbedder implements Embedder {
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.url || "http://localhost:11434",
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "nomic-embed-text:latest";
|
||||
this.embeddingDims = config.embeddingDims || 768;
|
||||
|
||||
@@ -8,7 +8,10 @@ export class OpenAIEmbedder implements Embedder {
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.openai = new OpenAI({ apiKey: config.apiKey });
|
||||
this.openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL || config.url,
|
||||
});
|
||||
this.model = config.model || "text-embedding-3-small";
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ Memory Format:
|
||||
source -- relationship -- destination
|
||||
|
||||
Provide a list of deletion instructions, each specifying the relationship to be deleted.
|
||||
|
||||
Respond in JSON format.
|
||||
`;
|
||||
|
||||
export function getDeleteMessages(
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./types";
|
||||
export * from "./embeddings/base";
|
||||
export * from "./embeddings/openai";
|
||||
export * from "./embeddings/ollama";
|
||||
export * from "./embeddings/lmstudio";
|
||||
export * from "./embeddings/google";
|
||||
export * from "./embeddings/azure";
|
||||
export * from "./embeddings/langchain";
|
||||
@@ -14,6 +15,7 @@ export * from "./llms/openai_structured";
|
||||
export * from "./llms/anthropic";
|
||||
export * from "./llms/groq";
|
||||
export * from "./llms/ollama";
|
||||
export * from "./llms/lmstudio";
|
||||
export * from "./llms/mistral";
|
||||
export * from "./llms/langchain";
|
||||
export * from "./vector_stores/base";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { OpenAILLM } from "./openai";
|
||||
import { LLMConfig, Message } from "../types";
|
||||
import { LLMResponse } from "./base";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioLLM extends OpenAILLM {
|
||||
constructor(config: LLMConfig) {
|
||||
super({
|
||||
...config,
|
||||
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
model: config.model || DEFAULT_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
messages: Message[],
|
||||
responseFormat?: { type: string },
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
try {
|
||||
return await super.generateResponse(messages, responseFormat, tools);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
||||
try {
|
||||
return await super.generateChat(messages);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.config?.url || "http://localhost:11434",
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "llama3.1:8b";
|
||||
this.ensureModelExists().catch((err) => {
|
||||
|
||||
@@ -212,7 +212,7 @@ export class MemoryGraph {
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.`,
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
|
||||
},
|
||||
{ role: "user", content: data },
|
||||
],
|
||||
|
||||
@@ -41,7 +41,7 @@ export class Memory {
|
||||
private config: MemoryConfig;
|
||||
private customPrompt: string | undefined;
|
||||
private embedder: Embedder;
|
||||
private vectorStore: VectorStore;
|
||||
private vectorStore!: VectorStore;
|
||||
private llm: LLM;
|
||||
private db: HistoryManager;
|
||||
private collectionName: string | undefined;
|
||||
@@ -49,6 +49,8 @@ export class Memory {
|
||||
private graphMemory?: MemoryGraph;
|
||||
private enableGraph: boolean;
|
||||
telemetryId: string;
|
||||
private _initPromise: Promise<void>;
|
||||
private _initError?: Error;
|
||||
|
||||
constructor(config: Partial<MemoryConfig> = {}) {
|
||||
// Merge and validate config
|
||||
@@ -59,10 +61,9 @@ export class Memory {
|
||||
this.config.embedder.provider,
|
||||
this.config.embedder.config,
|
||||
);
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
// Vector store creation is deferred to _autoInitialize() so that
|
||||
// the embedding dimension can be auto-detected first when not
|
||||
// explicitly configured.
|
||||
this.llm = LLMFactory.create(
|
||||
this.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
@@ -70,20 +71,10 @@ export class Memory {
|
||||
if (this.config.disableHistory) {
|
||||
this.db = new DummyHistoryManager();
|
||||
} else {
|
||||
const defaultConfig = {
|
||||
provider: "sqlite",
|
||||
config: {
|
||||
historyDbPath: this.config.historyDbPath || ":memory:",
|
||||
},
|
||||
};
|
||||
|
||||
this.db =
|
||||
this.config.historyStore && !this.config.disableHistory
|
||||
? HistoryManagerFactory.create(
|
||||
this.config.historyStore.provider,
|
||||
this.config.historyStore,
|
||||
)
|
||||
: HistoryManagerFactory.create("sqlite", defaultConfig);
|
||||
this.db = HistoryManagerFactory.create(
|
||||
this.config.historyStore!.provider,
|
||||
this.config.historyStore!,
|
||||
);
|
||||
}
|
||||
|
||||
this.collectionName = this.config.vectorStore.config.collectionName;
|
||||
@@ -96,8 +87,67 @@ export class Memory {
|
||||
this.graphMemory = new MemoryGraph(this.config);
|
||||
}
|
||||
|
||||
// Initialize telemetry if vector store is initialized
|
||||
this._initializeTelemetry();
|
||||
// Auto-detect embedding dimension (if needed), create vector store,
|
||||
// and initialize it. All public methods await this before proceeding.
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If no explicit dimension was provided, runs a probe embedding to
|
||||
* detect it. Then creates and initializes the vector store.
|
||||
*/
|
||||
private async _autoInitialize(): Promise<void> {
|
||||
if (!this.config.vectorStore.config.dimension) {
|
||||
try {
|
||||
const probe = await this.embedder.embed("dimension probe");
|
||||
this.config.vectorStore.config.dimension = probe.length;
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to auto-detect embedding dimension from provider '${this.config.embedder.provider}': ${error.message}. ` +
|
||||
`Please set 'dimension' in vectorStore.config or 'embeddingDims' in embedder.config explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
|
||||
// The vector store constructor may fire initialize() asynchronously
|
||||
// (e.g. Qdrant). Explicitly await it here to guarantee the backing
|
||||
// store (collections, tables, etc.) is ready before any public method
|
||||
// attempts to read or write.
|
||||
await this.vectorStore.initialize();
|
||||
|
||||
await this._initializeTelemetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that auto-initialization (dimension detection + vector store
|
||||
* creation) has completed before any public method proceeds.
|
||||
* If a previous init attempt failed, retries automatically.
|
||||
*/
|
||||
private async _ensureInitialized(): Promise<void> {
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
// Clear failed state and retry — the embedder or vector store
|
||||
// may have been transiently unavailable at startup.
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
throw this._initError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _initializeTelemetry() {
|
||||
@@ -157,6 +207,7 @@ export class Memory {
|
||||
messages: string | Message[],
|
||||
config: AddMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("add", {
|
||||
message_count: Array.isArray(messages) ? messages.length : 1,
|
||||
has_metadata: !!config.metadata,
|
||||
@@ -382,6 +433,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem | null> {
|
||||
await this._ensureInitialized();
|
||||
const memory = await this.vectorStore.get(memoryId);
|
||||
if (!memory) return null;
|
||||
|
||||
@@ -423,6 +475,7 @@ export class Memory {
|
||||
query: string,
|
||||
config: SearchMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("search", {
|
||||
query_length: query.length,
|
||||
limit: config.limit,
|
||||
@@ -489,6 +542,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async update(memoryId: string, data: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("update", { memory_id: memoryId });
|
||||
const embedding = await this.embedder.embed(data);
|
||||
await this.updateMemory(memoryId, data, { [data]: embedding });
|
||||
@@ -496,6 +550,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete", { memory_id: memoryId });
|
||||
await this.deleteMemory(memoryId);
|
||||
return { message: "Memory deleted successfully!" };
|
||||
@@ -504,6 +559,7 @@ export class Memory {
|
||||
async deleteAll(
|
||||
config: DeleteAllMemoryOptions,
|
||||
): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete_all", {
|
||||
has_user_id: !!config.userId,
|
||||
has_agent_id: !!config.agentId,
|
||||
@@ -531,10 +587,12 @@ export class Memory {
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<any[]> {
|
||||
await this._ensureInitialized();
|
||||
return this.db.getHistory(memoryId);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("reset");
|
||||
await this.db.reset();
|
||||
|
||||
@@ -559,28 +617,30 @@ export class Memory {
|
||||
await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check?
|
||||
}
|
||||
|
||||
// Re-initialize factories/clients based on the original config
|
||||
// Re-initialize factories/clients based on the original config.
|
||||
// Dimension is already set in this.config from the initial probe,
|
||||
// so _autoInitialize will skip the probe and just re-create the store.
|
||||
this.embedder = EmbedderFactory.create(
|
||||
this.config.embedder.provider,
|
||||
this.config.embedder.config,
|
||||
);
|
||||
// Re-create vector store instance - crucial for Langchain to reset wrapper state if needed
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config, // This will pass the original client instance back
|
||||
);
|
||||
this.llm = LLMFactory.create(
|
||||
this.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
);
|
||||
// Re-init DB if needed (though db.reset() likely handles its state)
|
||||
// Re-init Graph if needed
|
||||
|
||||
// Re-initialize telemetry
|
||||
this._initializeTelemetry();
|
||||
// Re-create vector store via _autoInitialize (which handles dimension + creation)
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
}
|
||||
|
||||
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("get_all", {
|
||||
limit: config.limit,
|
||||
has_user_id: !!config.userId,
|
||||
|
||||
@@ -278,5 +278,9 @@ export function parseMessages(messages: string[]): string {
|
||||
}
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(/```[^`]*```/g, "");
|
||||
// Extract content inside code fences instead of deleting it.
|
||||
// The old regex /```[^`]*```/g replaced the entire block (including
|
||||
// its content) with an empty string, so when an LLM returned JSON
|
||||
// wrapped in ```json ... ``` the actual payload was discarded.
|
||||
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { HistoryManager } from "./base";
|
||||
import { ensureSQLiteDirectory } from "../utils/sqlite";
|
||||
|
||||
export class SQLiteManager implements HistoryManager {
|
||||
private db: Database.Database;
|
||||
@@ -7,6 +8,7 @@ export class SQLiteManager implements HistoryManager {
|
||||
private stmtSelect!: Database.Statement;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
ensureSQLiteDirectory(dbPath);
|
||||
this.db = new Database(dbPath);
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Backward-compatibility tests for SQLite path handling changes.
|
||||
*
|
||||
* These tests verify that every documented and common usage pattern
|
||||
* from before the fix continues to work identically after the change.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config merging – existing patterns must keep working
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ConfigManager.mergeConfig", () => {
|
||||
it("empty config returns all expected defaults", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
|
||||
expect(cfg.version).toBe("v1.1");
|
||||
expect(cfg.embedder.provider).toBe("openai");
|
||||
expect(cfg.vectorStore.provider).toBe("memory");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("memories");
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
expect(cfg.llm.provider).toBe("openai");
|
||||
expect(cfg.historyStore).toBeDefined();
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
|
||||
expect(cfg.disableHistory).toBe(false);
|
||||
expect(cfg.enableGraph).toBe(false);
|
||||
});
|
||||
|
||||
it("workaround: explicit historyStore still works (existing user pattern)", () => {
|
||||
// This is the documented workaround from all three issues
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/workaround.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("/tmp/workaround.db");
|
||||
});
|
||||
|
||||
it("disableHistory: true still works", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ disableHistory: true });
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("supabase historyStore config is preserved", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://abc.supabase.co",
|
||||
supabaseKey: "secret-key",
|
||||
tableName: "custom_history",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("supabase");
|
||||
expect(cfg.historyStore!.config.supabaseUrl).toBe(
|
||||
"https://abc.supabase.co",
|
||||
);
|
||||
expect(cfg.historyStore!.config.supabaseKey).toBe("secret-key");
|
||||
expect(cfg.historyStore!.config.tableName).toBe("custom_history");
|
||||
});
|
||||
|
||||
it("custom embedder, llm, vectorStore configs pass through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", url: "http://localhost:11434" },
|
||||
},
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.1:8b" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.embedder.provider).toBe("ollama");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text");
|
||||
expect(cfg.llm.provider).toBe("ollama");
|
||||
expect(cfg.llm.config.model).toBe("llama3.1:8b");
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("graphStore config passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
enableGraph: true,
|
||||
graphStore: {
|
||||
provider: "neo4j",
|
||||
config: {
|
||||
url: "neo4j://custom:7687",
|
||||
username: "admin",
|
||||
password: "pass",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.enableGraph).toBe(true);
|
||||
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
|
||||
});
|
||||
|
||||
it("customPrompt passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
customPrompt: "You are a helpful assistant",
|
||||
});
|
||||
expect(cfg.customPrompt).toBe("You are a helpful assistant");
|
||||
});
|
||||
|
||||
it("version override passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ version: "v1.0" });
|
||||
expect(cfg.version).toBe("v1.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. SQLiteManager – existing behavior preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: SQLiteManager", () => {
|
||||
it("relative path still works (resolves from CWD)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
const manager = new SQLiteManager("memory.db");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(fs.existsSync(path.join(tempDir, "memory.db"))).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("absolute path still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const dbPath = path.join(tempDir, "history.db");
|
||||
|
||||
try {
|
||||
const manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(":memory: still works", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
|
||||
it("reset clears history and allows re-use", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "val", "ADD");
|
||||
await manager.reset();
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(0);
|
||||
await manager.addHistory("m2", null, "new-val", "ADD");
|
||||
const history2 = await manager.getHistory("m2");
|
||||
expect(history2).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. MemoryVectorStore – existing API preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: MemoryVectorStore", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("explicit dbPath still works (the existing config.dbPath feature)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "my_vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
|
||||
const result = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("insert, search, get, update, delete, list all work", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
const v1 = normalize([1, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0]);
|
||||
|
||||
// insert
|
||||
await store.insert([v1, v2], ["a", "b"], [{ t: "a" }, { t: "b" }]);
|
||||
|
||||
// get
|
||||
const a = await store.get("a");
|
||||
expect(a!.payload.t).toBe("a");
|
||||
|
||||
// search
|
||||
const results = await store.search(v1, 2);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].id).toBe("a"); // closest to v1
|
||||
|
||||
// update
|
||||
await store.update("a", v2, { t: "updated" });
|
||||
const updated = await store.get("a");
|
||||
expect(updated!.payload.t).toBe("updated");
|
||||
|
||||
// list
|
||||
const [listed, count] = await store.list();
|
||||
expect(count).toBe(2);
|
||||
expect(listed).toHaveLength(2);
|
||||
|
||||
// delete
|
||||
await store.delete("a");
|
||||
const deleted = await store.get("a");
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// deleteCol
|
||||
await store.deleteCol();
|
||||
const [afterDrop] = await store.list();
|
||||
expect(afterDrop).toHaveLength(0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on insert still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(
|
||||
store.insert([[1, 0]], ["id1"], [{ t: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on search still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(store.search([1, 0], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("default dimension is 1536 when not specified", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const store = new MemoryVectorStore({});
|
||||
// Verify by trying to insert a 1536-dim vector
|
||||
const vec = new Array(1536).fill(0);
|
||||
vec[0] = 1;
|
||||
expect(store.insert([vec], ["id1"], [{ t: "x" }])).resolves.not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("search with filters still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0]), normalize([0, 1, 0])],
|
||||
["a", "b"],
|
||||
[
|
||||
{ text: "hello", userId: "user1" },
|
||||
{ text: "world", userId: "user2" },
|
||||
],
|
||||
);
|
||||
|
||||
const results = await store.search(normalize([1, 0, 0]), 10, {
|
||||
userId: "user2",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("b");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. VectorStoreConfig type – dbPath is optional, existing configs work
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: VectorStoreConfig type", () => {
|
||||
it("config without dbPath still works (no required field breakage)", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 512 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.dbPath).toBeUndefined();
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(512);
|
||||
});
|
||||
|
||||
it("config with client instance passes through unchanged", () => {
|
||||
const fakeClient = { connect: () => {} };
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { client: fakeClient, dimension: 768 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.client).toBe(fakeClient);
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. ensureSQLiteDirectory – does not break existing paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ensureSQLiteDirectory", () => {
|
||||
it("no-ops for already existing directory", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-existing-"));
|
||||
try {
|
||||
// Should not throw even though directory already exists
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles path with trailing slash gracefully", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-trailing-"));
|
||||
try {
|
||||
// path.dirname of "dir/sub/" is "dir/sub", mkdirSync should handle it
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "sub", "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config merging – historyDbPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ConfigManager.mergeConfig – historyDbPath handling", () => {
|
||||
it("propagates top-level historyDbPath into historyStore.config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/custom/history.db",
|
||||
});
|
||||
expect(cfg.historyDbPath).toBe("/tmp/custom/history.db");
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe(
|
||||
"/tmp/custom/history.db",
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit historyStore.config.historyDbPath takes precedence over top-level", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/shorthand.db",
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/explicit.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/explicit.db");
|
||||
});
|
||||
|
||||
it("preserves default memory.db when nothing is provided", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("memory.db");
|
||||
});
|
||||
|
||||
it("respects only historyStore.config when top-level is absent", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/nested-only.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/nested-only.db");
|
||||
});
|
||||
|
||||
it("does not leak historyDbPath into non-sqlite providers", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/should-not-apply.db",
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://x.supabase.co",
|
||||
supabaseKey: "key",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.provider).toBe("supabase");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disableHistory does not prevent historyStore config from merging", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
disableHistory: true,
|
||||
historyDbPath: "/tmp/disabled.db",
|
||||
});
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/disabled.db");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLiteManager – directory creation & DB operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SQLiteManager – directory auto-creation", () => {
|
||||
it("creates nested parent directories and writes to the DB", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-sqlite-"));
|
||||
const dbPath = path.join(tempDir, "a", "b", "c", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("mem-1", null, "test value", "ADD");
|
||||
const history = await manager.getHistory("mem-1");
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].new_value).toBe("test value");
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("end-to-end: mergeConfig + SQLiteManager at configured path", async () => {
|
||||
const tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "mem0-history-path-"),
|
||||
);
|
||||
const historyDbPath = path.join(tempDir, "nested", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
const mergedConfig = ConfigManager.mergeConfig({ historyDbPath });
|
||||
|
||||
manager = new SQLiteManager(
|
||||
mergedConfig.historyStore!.config.historyDbPath!,
|
||||
);
|
||||
await manager.addHistory("memory-1", null, "remember me", "ADD");
|
||||
|
||||
expect(fs.existsSync(historyDbPath)).toBe(true);
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works with :memory: without attempting directory creation", () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
expect(manager).toBeDefined();
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryVectorStore – path handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryVectorStore – path handling", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses ~/.mem0/vector_store.db by default", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects explicit dbPath config", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const dbPath = path.join(tempDir, "custom", "vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 4, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0, 0])],
|
||||
["v1"],
|
||||
[{ text: "hello" }],
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
const results = await store.search(normalize([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works when CWD is read-only", async () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const readOnlyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ro-"));
|
||||
|
||||
try {
|
||||
fs.chmodSync(readOnlyCwd, 0o555);
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
process.chdir(readOnlyCwd);
|
||||
|
||||
const store = new MemoryVectorStore({ dimension: 4 });
|
||||
await store.insert(
|
||||
[normalize([0, 1, 0, 0])],
|
||||
["v2"],
|
||||
[{ text: "works" }],
|
||||
);
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.chmodSync(readOnlyCwd, 0o755);
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(readOnlyCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits migration warning when old CWD-based vector_store.db exists", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Default vector_store.db location changed"),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT emit migration warning when dbPath is explicitly set", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({
|
||||
dimension: 4,
|
||||
dbPath: path.join(tempDir, "explicit.db"),
|
||||
});
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utils
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ensureSQLiteDirectory", () => {
|
||||
it("creates nested directories", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ensure-"));
|
||||
const target = path.join(tempDir, "x", "y", "z", "test.db");
|
||||
try {
|
||||
ensureSQLiteDirectory(target);
|
||||
expect(fs.existsSync(path.join(tempDir, "x", "y", "z"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips :memory:", () => {
|
||||
expect(() => ensureSQLiteDirectory(":memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips file: URIs", () => {
|
||||
expect(() => ensureSQLiteDirectory("file::memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips empty string", () => {
|
||||
expect(() => ensureSQLiteDirectory("")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultVectorStoreDbPath", () => {
|
||||
it("returns path under homedir/.mem0", () => {
|
||||
const result = getDefaultVectorStoreDbPath();
|
||||
expect(result).toBe(path.join(os.homedir(), ".mem0", "vector_store.db"));
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export interface Message {
|
||||
export interface EmbeddingConfig {
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
embeddingDims?: number;
|
||||
modelProperties?: Record<string, any>;
|
||||
@@ -23,6 +24,7 @@ export interface EmbeddingConfig {
|
||||
export interface VectorStoreConfig {
|
||||
collectionName?: string;
|
||||
dimension?: number;
|
||||
dbPath?: string;
|
||||
client?: any;
|
||||
instance?: any;
|
||||
[key: string]: any;
|
||||
@@ -41,6 +43,7 @@ export interface HistoryStoreConfig {
|
||||
export interface LLMConfig {
|
||||
provider?: string;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
config?: Record<string, any>;
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
@@ -129,6 +132,7 @@ export const MemoryConfigSchema = z.object({
|
||||
.object({
|
||||
collectionName: z.string().optional(),
|
||||
dimension: z.number().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
client: z.any().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
@@ -140,6 +144,7 @@ export const MemoryConfigSchema = z.object({
|
||||
model: z.union([z.string(), z.any()]).optional(),
|
||||
modelProperties: z.record(z.string(), z.any()).optional(),
|
||||
baseURL: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
historyDbPath: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OpenAIEmbedder } from "../embeddings/openai";
|
||||
import { OllamaEmbedder } from "../embeddings/ollama";
|
||||
import { LMStudioEmbedder } from "../embeddings/lmstudio";
|
||||
import { OpenAILLM } from "../llms/openai";
|
||||
import { OpenAIStructuredLLM } from "../llms/openai_structured";
|
||||
import { AnthropicLLM } from "../llms/anthropic";
|
||||
@@ -19,6 +20,7 @@ import { Qdrant } from "../vector_stores/qdrant";
|
||||
import { VectorizeDB } from "../vector_stores/vectorize";
|
||||
import { RedisDB } from "../vector_stores/redis";
|
||||
import { OllamaLLM } from "../llms/ollama";
|
||||
import { LMStudioLLM } from "../llms/lmstudio";
|
||||
import { SupabaseDB } from "../vector_stores/supabase";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
|
||||
@@ -40,6 +42,8 @@ export class EmbedderFactory {
|
||||
return new OpenAIEmbedder(config);
|
||||
case "ollama":
|
||||
return new OllamaEmbedder(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioEmbedder(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleEmbedder(config);
|
||||
@@ -66,6 +70,8 @@ export class LLMFactory {
|
||||
return new GroqLLM(config);
|
||||
case "ollama":
|
||||
return new OllamaLLM(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioLLM(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleLLM(config);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
export function getDefaultVectorStoreDbPath(): string {
|
||||
return path.join(os.homedir(), ".mem0", "vector_store.db");
|
||||
}
|
||||
|
||||
export function ensureSQLiteDirectory(dbPath: string): void {
|
||||
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export class AzureAISearch implements VectorStore {
|
||||
private readonly hybridSearch: boolean;
|
||||
private readonly vectorFilterMode: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: AzureAISearchConfig) {
|
||||
this.serviceName = config.serviceName;
|
||||
@@ -117,6 +118,13 @@ export class AzureAISearch implements VectorStore {
|
||||
* Initialize the Azure AI Search index if it doesn't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
const collections = await this.listCols();
|
||||
if (!collections.includes(this.indexName)) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
interface MemoryVector {
|
||||
id: string;
|
||||
@@ -16,10 +21,19 @@ export class MemoryVectorStore implements VectorStore {
|
||||
|
||||
constructor(config: VectorStoreConfig) {
|
||||
this.dimension = config.dimension || 1536; // Default OpenAI dimension
|
||||
this.dbPath = path.join(process.cwd(), "vector_store.db");
|
||||
if (config.dbPath) {
|
||||
this.dbPath = config.dbPath;
|
||||
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
|
||||
|
||||
if (!config.dbPath) {
|
||||
const oldDefault = path.join(process.cwd(), "vector_store.db");
|
||||
if (fs.existsSync(oldDefault) && oldDefault !== this.dbPath) {
|
||||
console.warn(
|
||||
`[mem0] Default vector_store.db location changed from ${oldDefault} to ${this.dbPath}. ` +
|
||||
`Move your existing file or set vectorStore.config.dbPath explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ensureSQLiteDirectory(this.dbPath);
|
||||
this.db = new Database(this.dbPath);
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class Qdrant implements VectorStore {
|
||||
private client: QdrantClient;
|
||||
private readonly collectionName: string;
|
||||
private dimension: number;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: QdrantConfig) {
|
||||
if (config.client) {
|
||||
@@ -211,22 +212,8 @@ export class Qdrant implements VectorStore {
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
try {
|
||||
// First check if the collection exists
|
||||
const collections = await this.client.getCollections();
|
||||
const userCollectionExists = collections.collections.some(
|
||||
(col: { name: string }) => col.name === "memory_migrations",
|
||||
);
|
||||
|
||||
if (!userCollectionExists) {
|
||||
// Create the collection if it doesn't exist
|
||||
await this.client.createCollection("memory_migrations", {
|
||||
vectors: {
|
||||
size: 1,
|
||||
distance: "Cosine",
|
||||
on_disk: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Ensure collection exists (idempotent — handles race conditions)
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
|
||||
// Now try to get the user ID
|
||||
const result = await this.client.scroll("memory_migrations", {
|
||||
@@ -286,66 +273,62 @@ export class Qdrant implements VectorStore {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
private async ensureCollection(name: string, size: number): Promise<void> {
|
||||
try {
|
||||
// Create collection if it doesn't exist
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(c) => c.name === this.collectionName,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: this.dimension,
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle case where collection was created between our check and create
|
||||
if (error?.status === 409) {
|
||||
// Collection already exists - verify it has the correct configuration
|
||||
const collectionInfo = await this.client.getCollection(
|
||||
this.collectionName,
|
||||
);
|
||||
await this.client.createCollection(name, {
|
||||
vectors: {
|
||||
size,
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error?.status === 409 ||
|
||||
error?.status === 401 ||
|
||||
error?.status === 403
|
||||
) {
|
||||
// Collection already exists — verify configuration for the main collection
|
||||
if (name === this.collectionName) {
|
||||
try {
|
||||
const collectionInfo = await this.client.getCollection(name);
|
||||
const vectorConfig = collectionInfo.config?.params?.vectors;
|
||||
|
||||
if (!vectorConfig || vectorConfig.size !== this.dimension) {
|
||||
if (vectorConfig && vectorConfig.size !== size) {
|
||||
throw new Error(
|
||||
`Collection ${this.collectionName} exists but has wrong configuration. ` +
|
||||
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
|
||||
`Collection ${name} exists but has wrong vector size. ` +
|
||||
`Expected: ${size}, got: ${vectorConfig.size}`,
|
||||
);
|
||||
}
|
||||
// Collection exists with correct configuration - we can proceed
|
||||
} else {
|
||||
throw error;
|
||||
} catch (verifyError: any) {
|
||||
// Re-throw dimension mismatch errors
|
||||
if (verifyError?.message?.includes("wrong vector size")) {
|
||||
throw verifyError;
|
||||
}
|
||||
// Transient errors (e.g. 500 while collection is being committed)
|
||||
// are non-fatal — the collection exists per the 409.
|
||||
console.warn(
|
||||
`Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Otherwise collection exists and is fine — proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create memory_migrations collection if it doesn't exist
|
||||
const userExists = collections.collections.some(
|
||||
(c) => c.name === "memory_migrations",
|
||||
);
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
if (!userExists) {
|
||||
try {
|
||||
await this.client.createCollection("memory_migrations", {
|
||||
vectors: {
|
||||
size: 1, // Minimal size since we only store user_id
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle case where collection was created between our check and create
|
||||
if (error?.status === 409) {
|
||||
// Collection already exists - we can proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.ensureCollection(this.collectionName, this.dimension);
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
} catch (error) {
|
||||
console.error("Error initializing Qdrant:", error);
|
||||
throw error;
|
||||
|
||||
@@ -139,6 +139,7 @@ export class RedisDB implements VectorStore {
|
||||
private readonly indexName: string;
|
||||
private readonly indexPrefix: string;
|
||||
private readonly schema: RedisSchema;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: RedisConfig) {
|
||||
this.indexName = config.collectionName;
|
||||
@@ -240,6 +241,13 @@ export class RedisDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.client.connect();
|
||||
console.log("Connected to Redis");
|
||||
|
||||
@@ -86,6 +86,7 @@ export class SupabaseDB implements VectorStore {
|
||||
private readonly tableName: string;
|
||||
private readonly embeddingColumnName: string;
|
||||
private readonly metadataColumnName: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: SupabaseConfig) {
|
||||
this.client = createClient(config.supabaseUrl, config.supabaseKey);
|
||||
@@ -100,6 +101,13 @@ export class SupabaseDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Verify table exists and vector operations work by attempting a test insert
|
||||
const testVector = Array(1536).fill(0);
|
||||
|
||||
@@ -20,6 +20,7 @@ export class VectorizeDB implements VectorStore {
|
||||
private dimensions: number;
|
||||
private indexName: string;
|
||||
private accountId: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: VectorizeConfig) {
|
||||
this.client = new Cloudflare({ apiToken: config.apiKey });
|
||||
@@ -343,6 +344,13 @@ export class VectorizeDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Check if the index already exists
|
||||
let indexFound = false;
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
/// <reference types="jest" />
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
|
||||
describe("ConfigManager", () => {
|
||||
describe("mergeConfig - dimension handling", () => {
|
||||
const baseLlm = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
|
||||
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "test-key" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// Dimension should be undefined so Memory._autoInitialize() will
|
||||
// auto-detect it via a probe embedding at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims from embedder config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("should prefer explicit vector store dimension over embedder dims", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("should leave dimension undefined when using a custom client without explicit dims", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// No embeddingDims and no explicit dimension → should be undefined
|
||||
// for auto-detection at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims when using a custom client", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LLM url passthrough for Ollama", () => {
|
||||
const baseEmbedder = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
const baseVectorStore = {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test" },
|
||||
};
|
||||
|
||||
it("should preserve url in LLM config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
|
||||
it("should prefer baseURL over url when both are provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "llama3.2:3b",
|
||||
baseURL: "http://custom:11434",
|
||||
url: "http://fallback:11434",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.baseURL).toBe("http://custom:11434");
|
||||
expect(config.llm.config.url).toBe("http://fallback:11434");
|
||||
});
|
||||
|
||||
it("should use default baseURL when no url or baseURL provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBeUndefined();
|
||||
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
|
||||
it("should preserve url in embedder config (existing behavior)", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "nomic-embed-text",
|
||||
url: "http://10.0.0.100:11434",
|
||||
},
|
||||
},
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// LM Studio snake_case normalization
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
describe("mergeConfig - LM Studio embedder config", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5");
|
||||
});
|
||||
|
||||
it("normalizes embedding_dims to embeddingDims for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("prefers camelCase embeddingDims over snake_case embedding_dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
embeddingDims: 1536,
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
});
|
||||
|
||||
it("passes through camelCase config without issues", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LM Studio LLM config", () => {
|
||||
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("meta-llama-3.1");
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("falls back to default baseURL when neither is provided for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: { provider: "lmstudio", config: { model: "test-model" } },
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - full OpenClaw-style LM Studio config", () => {
|
||||
it("handles the exact config from issue #4235", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: "192.168.200.12",
|
||||
port: 6333,
|
||||
checkCompatibility: false,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe(
|
||||
"text-embedding-gte-qwen2-1.5b-instruct",
|
||||
);
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b");
|
||||
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.host).toBe("192.168.200.12");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Memory class – LM Studio end-to-end flow (mocked factories)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – LM Studio end-to-end flow", () => {
|
||||
let MemoryClass: any;
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let mockEmbedder: any;
|
||||
let mockVStore: any;
|
||||
let mockLlm: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
mockEmbedder = {
|
||||
embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]),
|
||||
};
|
||||
mockVStore = {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockLlm = {
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
};
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) };
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("creates Memory with lmstudio embedder and llm providers", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-detects embedding dimension via probe with lmstudio", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("handles snake_case OpenClaw config through full Memory stack", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-oss-20b",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("search flow works with lmstudio embedder", async () => {
|
||||
mockVStore.search.mockResolvedValueOnce([
|
||||
{
|
||||
id: "mem-1",
|
||||
payload: {
|
||||
data: "User likes hiking",
|
||||
user_id: "u1",
|
||||
hash: "abc123",
|
||||
created_at: "2026-01-01",
|
||||
},
|
||||
score: 0.95,
|
||||
},
|
||||
]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
const result = await mem.search("What does the user like?", {
|
||||
userId: "u1",
|
||||
});
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
|
||||
expect(mockVStore.search).toHaveBeenCalled();
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].memory).toBe("User likes hiking");
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce(
|
||||
'{"facts":["User loves sushi"]}',
|
||||
);
|
||||
mockVStore.search.mockResolvedValue([]);
|
||||
mockVStore.list.mockResolvedValue([[], 0]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.add("I love sushi", { userId: "u1" });
|
||||
|
||||
expect(mockLlm.generateResponse).toHaveBeenCalled();
|
||||
expect(mockEmbedder.embed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Tests for embedding dimension auto-detection.
|
||||
*
|
||||
* Covers:
|
||||
* - ConfigManager: dimension resolution logic
|
||||
* - Memory class: probe-based auto-detection, lazy init gate, backward compat
|
||||
* - MemoryVectorStore: backward compat with explicit dimensions
|
||||
* - Explicit error messages on probe failure
|
||||
*/
|
||||
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. ConfigManager – dimension resolution
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("ConfigManager – dimension resolution", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("leaves dimension undefined when nothing explicit is set", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims from embedder config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers explicit vectorStore.dimension over embeddingDims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("leaves dimension undefined for custom client without explicit dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims with a custom client", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("preserves all other vectorStore config fields", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "my-coll",
|
||||
host: "my-host",
|
||||
port: 6333,
|
||||
apiKey: "qdrant-key",
|
||||
},
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("my-coll");
|
||||
expect(cfg.vectorStore.config.host).toBe("my-host");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
expect(cfg.vectorStore.config.apiKey).toBe("qdrant-key");
|
||||
});
|
||||
|
||||
it("leaves dimension undefined with empty config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: {} },
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. MemoryVectorStore – backward compat with explicit dimensions
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("MemoryVectorStore – backward compat", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("defaults to dimension 1536 when not specified", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension=1536 still works", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension rejects mismatched vectors", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const wrongVector = new Array(768).fill(0.1);
|
||||
await expect(
|
||||
store.insert([wrongVector], ["id-1"], [{ data: "hello" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
|
||||
it("search validates dimension", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await expect(store.search([1, 2, 3], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("custom dimension=768 works end-to-end", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[1, ...new Array(767).fill(0)],
|
||||
[0, 1, ...new Array(766).fill(0)],
|
||||
],
|
||||
["a", "b"],
|
||||
[{ data: "alpha" }, { data: "beta" }],
|
||||
);
|
||||
|
||||
const results = await store.search([1, ...new Array(767).fill(0)], 2);
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe("a");
|
||||
});
|
||||
|
||||
it("getUserId and setUserId still work", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const userId = await store.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
|
||||
await store.setUserId("custom-user");
|
||||
const newUserId = await store.getUserId();
|
||||
expect(newUserId).toBe("custom-user");
|
||||
});
|
||||
|
||||
it("initialize() is idempotent", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. Memory class – auto-init with probe, lazy gate, backward compat
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – auto-initialization", () => {
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let MemoryClass: any;
|
||||
|
||||
function createMockEmbedder(dims: number) {
|
||||
return {
|
||||
embed: jest.fn().mockResolvedValue(new Array(dims).fill(0)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(dims).fill(0)]),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockVectorStore() {
|
||||
return {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
};
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("probes embedder to detect dimension when none set", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// Should have called embed("dimension probe") to detect dimension
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory should have been called with detected dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("skips probe when explicit dimension provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// embed should NOT have been called for probing
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory gets the explicit dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("skips probe when embeddingDims provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// ConfigManager resolves dimension from embeddingDims → no probe needed
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("all public methods wait for initialization", async () => {
|
||||
let resolveProbe: () => void;
|
||||
let probeCallCount = 0;
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockImplementation(() => {
|
||||
probeCallCount++;
|
||||
if (probeCallCount === 1) {
|
||||
// First call is the dimension probe — hang until manually resolved
|
||||
return new Promise<number[]>((resolve) => {
|
||||
resolveProbe = () => resolve(new Array(768).fill(0));
|
||||
});
|
||||
}
|
||||
// Subsequent calls (from search, etc.) resolve immediately
|
||||
return Promise.resolve(new Array(768).fill(0));
|
||||
}),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "test" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
let getAllDone = false;
|
||||
let searchDone = false;
|
||||
let getDone = false;
|
||||
|
||||
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
|
||||
const searchP = mem
|
||||
.search("q", { userId: "u" })
|
||||
.then(() => (searchDone = true));
|
||||
const getP = mem.get("id").then(() => (getDone = true));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(getAllDone).toBe(false);
|
||||
expect(searchDone).toBe(false);
|
||||
expect(getDone).toBe(false);
|
||||
|
||||
// Resolve the probe — init completes — methods unblock
|
||||
resolveProbe!();
|
||||
await Promise.all([getAllP, searchP, getP]);
|
||||
expect(getAllDone).toBe(true);
|
||||
expect(searchDone).toBe(true);
|
||||
expect(getDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reset re-creates vector store with correct dimension", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset should re-create vector store
|
||||
const mockVStore2 = createMockVectorStore();
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore2);
|
||||
await mem.reset();
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second creation should still have dimension=768 (cached from first probe)
|
||||
const secondCall = mockVectorStoreFactory.create.mock.calls[1];
|
||||
expect(secondCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("backward compat: full explicit config works without probe", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("throws explicit error when probe fails", async () => {
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
// getAll should reject with the init error
|
||||
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
|
||||
"auto-detect embedding dimension",
|
||||
);
|
||||
|
||||
// Verify the error was logged and contains helpful information
|
||||
const errorCall = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[0] instanceof Error &&
|
||||
call[0].message.includes("auto-detect embedding dimension"),
|
||||
);
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorMsg = (errorCall![0] as Error).message;
|
||||
expect(errorMsg).toContain("ollama");
|
||||
expect(errorMsg).toContain("Connection refused");
|
||||
expect(errorMsg).toContain("dimension");
|
||||
expect(errorMsg).toContain("embeddingDims");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { VectorStoreFactory } from "../src/utils/factory";
|
||||
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create Azure AI Search vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
serviceName: "test-service",
|
||||
apiKey: "test-api-key",
|
||||
embeddingModelDims: 1536,
|
||||
compressionType: "none" as const,
|
||||
useFloat16: false,
|
||||
hybridSearch: false,
|
||||
vectorFilterMode: "preFilter" as const,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
|
||||
|
||||
expect(vectorStore).toBeInstanceOf(AzureAISearch);
|
||||
});
|
||||
|
||||
it("should create memory vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("memory", config);
|
||||
|
||||
expect(vectorStore).toBeDefined();
|
||||
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
|
||||
});
|
||||
|
||||
it("should throw error for unsupported provider", () => {
|
||||
const config = {};
|
||||
|
||||
expect(() => {
|
||||
VectorStoreFactory.create("unsupported-provider", config);
|
||||
}).toThrow("Unsupported vector store provider: unsupported-provider");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory.
|
||||
* Mocks all provider modules to avoid external dependency crashes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
|
||||
// Mock all provider modules before importing factory
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/ollama", () => ({
|
||||
OllamaEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/langchain", () => ({
|
||||
LangchainEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/lmstudio", () => ({
|
||||
LMStudioEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-embedder", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/openai_structured", () => ({
|
||||
OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({
|
||||
type: "openai-structured-llm",
|
||||
config,
|
||||
})),
|
||||
}));
|
||||
jest.mock("../src/llms/anthropic", () => ({
|
||||
AnthropicLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "anthropic-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/groq", () => ({
|
||||
GroqLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "groq-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/ollama", () => ({
|
||||
OllamaLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/azure", () => ({
|
||||
AzureOpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/mistral", () => ({
|
||||
MistralLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "mistral-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/langchain", () => ({
|
||||
LangchainLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/lmstudio", () => ({
|
||||
LMStudioLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-llm", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/vector_stores/qdrant", () => ({
|
||||
Qdrant: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "qdrant", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/redis", () => ({
|
||||
RedisDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "redis", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/supabase", () => ({
|
||||
SupabaseDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/langchain", () => ({
|
||||
LangchainVectorStore: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-vs", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/vectorize", () => ({
|
||||
VectorizeDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "vectorize", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
AzureAISearch: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase-history", config })),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbedderFactory,
|
||||
LLMFactory,
|
||||
VectorStoreFactory,
|
||||
HistoryManagerFactory,
|
||||
} from "../src/utils/factory";
|
||||
import type {
|
||||
EmbeddingConfig,
|
||||
LLMConfig,
|
||||
VectorStoreConfig,
|
||||
HistoryStoreConfig,
|
||||
} from "../src/types";
|
||||
|
||||
const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" };
|
||||
const dummyLLMConfig: LLMConfig = { apiKey: "test" };
|
||||
const dummyVSConfig: VectorStoreConfig = {
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
// ─── EmbedderFactory ────────────────────────────────────
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates embedder for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create(provider, dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("OpenAI", dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("nonexistent", dummyEmbedConfig),
|
||||
).toThrow("Unsupported embedder provider: nonexistent");
|
||||
});
|
||||
|
||||
test("passes config to created embedder", () => {
|
||||
const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" };
|
||||
const result = EmbedderFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLMFactory ─────────────────────────────────────────
|
||||
|
||||
describe("LLMFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["openai_structured"],
|
||||
["anthropic"],
|
||||
["groq"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["mistral"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates LLM for provider '%s'", (provider) => {
|
||||
expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow(
|
||||
"Unsupported LLM provider: nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes config to created LLM", () => {
|
||||
const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" };
|
||||
const result = LLMFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VectorStoreFactory ─────────────────────────────────
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
test("creates memory vector store", () => {
|
||||
// MemoryVectorStore is real (not mocked) — needs valid config
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("memory", {
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["qdrant"],
|
||||
["redis"],
|
||||
["supabase"],
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("nonexistent", dummyVSConfig),
|
||||
).toThrow("Unsupported vector store provider: nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HistoryManagerFactory ──────────────────────────────
|
||||
|
||||
describe("HistoryManagerFactory", () => {
|
||||
test("creates SQLite history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: ":memory:" },
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates supabase history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "supabase",
|
||||
config: { supabaseUrl: "http://test", supabaseKey: "key" },
|
||||
};
|
||||
expect(() =>
|
||||
HistoryManagerFactory.create("supabase", config),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates memory history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "memory",
|
||||
config: {},
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
const config: HistoryStoreConfig = { provider: "bad", config: {} };
|
||||
expect(() => HistoryManagerFactory.create("bad", config)).toThrow(
|
||||
"Unsupported history store provider: bad",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Regression tests for graph_memory.ts response parsing (issue #4248).
|
||||
*
|
||||
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
|
||||
* 1. _retrieveNodesFromData → entity extraction
|
||||
* 2. _establishNodesRelationsFromData → relation extraction
|
||||
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
|
||||
*
|
||||
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
|
||||
* string-only responses, empty tool calls, and prompt construction.
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
import { MemoryGraph } from "../src/memory/graph_memory";
|
||||
import {
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
getDeleteMessages,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock neo4j-driver: provides a fake Driver with a no-op session
|
||||
jest.mock("neo4j-driver", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
driver: jest.fn(() => ({
|
||||
session: () => ({
|
||||
run: jest.fn().mockResolvedValue({ records: [] }),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
})),
|
||||
auth: { basic: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
|
||||
const mockGenerateResponse = jest.fn();
|
||||
const mockGenerateChat = jest.fn();
|
||||
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
|
||||
|
||||
jest.mock("../src/utils/factory", () => ({
|
||||
LLMFactory: {
|
||||
create: jest.fn(() => ({
|
||||
generateResponse: mockGenerateResponse,
|
||||
generateChat: mockGenerateChat,
|
||||
})),
|
||||
},
|
||||
EmbedderFactory: {
|
||||
create: jest.fn(() => ({
|
||||
embed: mockEmbed,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Minimal config that satisfies the MemoryGraph constructor
|
||||
function makeConfig(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: { provider: "openai", config: {} },
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Helper to access private methods via `any` cast
|
||||
function graph(overrides: Record<string, any> = {}): any {
|
||||
return new MemoryGraph(makeConfig(overrides));
|
||||
}
|
||||
|
||||
const FILTERS = { userId: "test-user" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. _retrieveNodesFromData – entity extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_retrieveNodesFromData", () => {
|
||||
it("parses a well-formed extract_entities tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ entity: "Alice", entity_type: "person" },
|
||||
{ entity: "Pizza", entity_type: "food" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ alice: "person", pizza: "food" });
|
||||
});
|
||||
|
||||
it("returns empty map when LLM returns a plain string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is undefined", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is an empty array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles malformed JSON in tool call arguments gracefully", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// Should not throw — the catch block in the source logs the error
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles missing entities array in arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({ wrong_key: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// args.entities is undefined → for..of on undefined throws → caught
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("skips tool calls with unrelated names", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "some_other_tool",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "X", entity_type: "Y" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("normalises entity names to lowercase with underscores", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "New York City", entity_type: "City Name" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({ new_york_city: "city_name" });
|
||||
});
|
||||
|
||||
it("passes json_object response format and the correct system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemMsg = messages[0].content as string;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
expect(systemMsg).toContain("test-user");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. _establishNodesRelationsFromData – relation extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_establishNodesRelationsFromData", () => {
|
||||
it("parses a well-formed establish_relationships tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ source: "Alice", relationship: "likes", destination: "Pizza" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
{ alice: "person", pizza: "food" },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("just a string");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when toolCalls is empty", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when entities key is missing from arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({ not_entities: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
// args.entities is undefined → falls back to []
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// _establishNodesRelationsFromData does JSON.parse without try/catch
|
||||
await expect(
|
||||
mg._establishNodesRelationsFromData("x", FILTERS, {}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
|
||||
// (only replaced when config.graphStore.customPrompt is set)
|
||||
});
|
||||
|
||||
it("appends JSON format suffix and custom prompt when configured", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph({ customPrompt: "Focus on food relationships only." });
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, {});
|
||||
|
||||
const [messages] = mockGenerateResponse.mock.calls[0];
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("Focus on food relationships only.");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_getDeleteEntitiesFromSearchOutput", () => {
|
||||
const SEARCH_OUTPUT = [
|
||||
{
|
||||
source: "alice",
|
||||
source_id: "1",
|
||||
relationship: "likes",
|
||||
relation_id: "r1",
|
||||
destination: "pizza",
|
||||
destination_id: "2",
|
||||
similarity: 0.95,
|
||||
},
|
||||
];
|
||||
|
||||
it("parses a well-formed delete_graph_memory tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "Alice",
|
||||
relationship: "likes",
|
||||
destination: "Pizza",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"Alice hates pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("string response");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when no tool calls are present", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-delete_graph_memory tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "noop",
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects multiple delete tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "A",
|
||||
relationship: "r1",
|
||||
destination: "B",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "C",
|
||||
relationship: "r2",
|
||||
destination: "D",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe("a");
|
||||
expect(result[1].source).toBe("c");
|
||||
});
|
||||
|
||||
it("passes json_object format and includes 'json' in system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles empty searchOutput array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
[],
|
||||
"data",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Prompt construction — JSON keyword present in every json_object site
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Prompt construction — all json_object sites include 'json'", () => {
|
||||
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("test", { userId }, {});
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
|
||||
it("normalises spaces and case in entity source/relationship/destination", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{
|
||||
source: "New York",
|
||||
relationship: "Capital Of",
|
||||
destination: "United States",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"test",
|
||||
FILTERS,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: "new_york",
|
||||
relationship: "capital_of",
|
||||
destination: "united_states",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
DELETE_RELATIONS_SYSTEM_PROMPT,
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
UPDATE_GRAPH_PROMPT,
|
||||
getDeleteMessages,
|
||||
formatEntities,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
/**
|
||||
* Regression tests for graph prompts (issue #4248).
|
||||
*
|
||||
* When response_format: { type: "json_object" } is used, OpenAI requires
|
||||
* the word "json" (case-insensitive) to appear in at least one message.
|
||||
* Missing it produces a 400 error.
|
||||
*
|
||||
* Three call sites use json_object today:
|
||||
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
|
||||
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
|
||||
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
// ─── JSON keyword presence ────────────────────────────────────────────────────
|
||||
|
||||
describe("Graph prompts — JSON keyword requirement", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
|
||||
// graph_memory.ts appends "\nPlease provide your response in JSON format."
|
||||
const withSuffix =
|
||||
EXTRACT_RELATIONS_PROMPT +
|
||||
"\nPlease provide your response in JSON format.";
|
||||
expect(withSuffix.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
|
||||
const [systemContent] = getDeleteMessages(
|
||||
"alice -- loves -- pizza",
|
||||
"Alice now hates pizza",
|
||||
"user-42",
|
||||
);
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
|
||||
// Mirrors the template in _retrieveNodesFromData()
|
||||
const userId = "user-1";
|
||||
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
|
||||
expect(prompt.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDeleteMessages ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getDeleteMessages", () => {
|
||||
it("replaces USER_ID with the provided userId in the system prompt", () => {
|
||||
const [system] = getDeleteMessages("mem", "data", "alice-123");
|
||||
expect(system).toContain("alice-123");
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("includes existing memories and new data in the user prompt", () => {
|
||||
const existing = "bob -- knows -- carol";
|
||||
const newData = "Bob no longer knows Carol";
|
||||
const [, user] = getDeleteMessages(existing, newData, "u1");
|
||||
expect(user).toContain(existing);
|
||||
expect(user).toContain(newData);
|
||||
});
|
||||
|
||||
it("returns a 2-tuple [system, user]", () => {
|
||||
const result = getDeleteMessages("a", "b", "c");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(typeof result[0]).toBe("string");
|
||||
expect(typeof result[1]).toBe("string");
|
||||
});
|
||||
|
||||
// — Malformed / edge-case inputs —
|
||||
|
||||
it("handles empty strings without throwing", () => {
|
||||
expect(() => getDeleteMessages("", "", "")).not.toThrow();
|
||||
const [system, user] = getDeleteMessages("", "", "");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
expect(typeof user).toBe("string");
|
||||
});
|
||||
|
||||
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
|
||||
const [system] = getDeleteMessages(
|
||||
"mem",
|
||||
"data",
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
expect(system).toContain('<script>alert("xss")</script>');
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles unicode input", () => {
|
||||
const [system, user] = getDeleteMessages(
|
||||
"日本語メモリ",
|
||||
"新しい情報",
|
||||
"ユーザー1",
|
||||
);
|
||||
expect(system).toContain("ユーザー1");
|
||||
expect(user).toContain("日本語メモリ");
|
||||
expect(user).toContain("新しい情報");
|
||||
});
|
||||
|
||||
it("handles very long input strings", () => {
|
||||
const longStr = "x".repeat(100_000);
|
||||
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
|
||||
const [system] = getDeleteMessages(longStr, longStr, "u");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatEntities ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("formatEntities", () => {
|
||||
it("formats a single entity triplet", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "Alice", relationship: "knows", destination: "Bob" },
|
||||
]);
|
||||
expect(result).toBe("Alice -- knows -- Bob");
|
||||
});
|
||||
|
||||
it("joins multiple entities with newlines", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "A", relationship: "r1", destination: "B" },
|
||||
{ source: "C", relationship: "r2", destination: "D" },
|
||||
]);
|
||||
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
|
||||
});
|
||||
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(formatEntities([])).toBe("");
|
||||
});
|
||||
|
||||
it("preserves special characters in entity fields", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
|
||||
]);
|
||||
expect(result).toContain("O'Brien");
|
||||
expect(result).toContain('said "hello"');
|
||||
expect(result).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prompt structural invariants ─────────────────────────────────────────────
|
||||
|
||||
describe("Prompt structural invariants", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
|
||||
});
|
||||
|
||||
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
|
||||
});
|
||||
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio Embedder — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
const mockCreate = jest.fn().mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
embeddings: { create: mockCreate },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioEmbedder (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("Sample text to embed.");
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0]).toEqual({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
input: "Sample text to embed.",
|
||||
encoding_format: "float",
|
||||
});
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embed() normalizes newlines", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await embedder.embed("Line one\nLine two");
|
||||
|
||||
expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two");
|
||||
});
|
||||
|
||||
it("embed() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await expect(embedder.embed("text")).rejects.toThrow(
|
||||
"LM Studio embedder failed: Connection refused",
|
||||
);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const mockBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
|
||||
});
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]);
|
||||
expect(result).toEqual(mockBatch);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio integration tests against a real local server.
|
||||
* Skipped by default. Enable with: LMSTUDIO_INTEGRATION=1
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. LM Studio installed with `lms` CLI
|
||||
* 2. Server running: lms server start
|
||||
* 3. Embedding model loaded: lms load text-embedding-nomic-embed-text-v1.5
|
||||
* 4. (Optional) Chat model loaded for LLM tests
|
||||
*/
|
||||
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
import { LMStudioLLM } from "../src/llms/lmstudio";
|
||||
|
||||
const LMSTUDIO_BASE_URL =
|
||||
process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1";
|
||||
const RUN_INTEGRATION = process.env.LMSTUDIO_INTEGRATION === "1";
|
||||
const describeIf = RUN_INTEGRATION ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
async function listModels(): Promise<{
|
||||
embedding: string | null;
|
||||
chat: string | null;
|
||||
}> {
|
||||
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
|
||||
const body = await res.json();
|
||||
const models: any[] = body.data || [];
|
||||
const embedding = models.find(
|
||||
(m) => m.id.includes("embed") || m.id.includes("nomic"),
|
||||
);
|
||||
const chat = models.find(
|
||||
(m) => !m.id.includes("embed") && !m.id.includes("nomic"),
|
||||
);
|
||||
return { embedding: embedding?.id ?? null, chat: chat?.id ?? null };
|
||||
}
|
||||
|
||||
function cosineSim(a: number[], b: number[]): number {
|
||||
let dot = 0,
|
||||
normA = 0,
|
||||
normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
|
||||
describeIf("LM Studio Integration", () => {
|
||||
it("server is reachable and lists models", async () => {
|
||||
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
|
||||
expect(res.ok).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.data).toBeDefined();
|
||||
console.log(
|
||||
"Loaded models:",
|
||||
body.data.map((m: any) => m.id),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Embedder ────────────────────────────────────────────────────────
|
||||
describe("LMStudioEmbedder (real server)", () => {
|
||||
let embedder: LMStudioEmbedder;
|
||||
let modelId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const models = await listModels();
|
||||
if (!models.embedding) throw new Error("No embedding model loaded");
|
||||
modelId = models.embedding;
|
||||
embedder = new LMStudioEmbedder({
|
||||
baseURL: LMSTUDIO_BASE_URL,
|
||||
model: modelId,
|
||||
});
|
||||
});
|
||||
|
||||
it("embed() returns a numeric vector", async () => {
|
||||
const vector = await embedder.embed("Hello world");
|
||||
expect(Array.isArray(vector)).toBe(true);
|
||||
expect(vector.length).toBeGreaterThan(0);
|
||||
vector.forEach((v) => expect(typeof v).toBe("number"));
|
||||
console.log(` Model: ${modelId}, dimension: ${vector.length}`);
|
||||
});
|
||||
|
||||
it("embed() produces identical output for newline-normalized text", async () => {
|
||||
const v1 = await embedder.embed("hello world");
|
||||
const v2 = await embedder.embed("hello\nworld");
|
||||
expect(v1.length).toBe(v2.length);
|
||||
const totalDiff = v1.reduce((s, val, i) => s + Math.abs(val - v2[i]), 0);
|
||||
expect(totalDiff).toBeLessThan(0.001);
|
||||
});
|
||||
|
||||
it("embedBatch() returns correct number of vectors", async () => {
|
||||
const vectors = await embedder.embedBatch(["first", "second", "third"]);
|
||||
expect(vectors).toHaveLength(3);
|
||||
vectors.forEach((v) => {
|
||||
expect(v.length).toBe(vectors[0].length);
|
||||
v.forEach((val) => expect(typeof val).toBe("number"));
|
||||
});
|
||||
});
|
||||
|
||||
it("semantically similar texts have higher cosine similarity", async () => {
|
||||
const [v1, v2, v3] = await Promise.all([
|
||||
embedder.embed("I love hiking in the mountains"),
|
||||
embedder.embed("I enjoy trekking through mountain trails"),
|
||||
embedder.embed("The stock market crashed yesterday"),
|
||||
]);
|
||||
const simSimilar = cosineSim(v1, v2);
|
||||
const simDifferent = cosineSim(v1, v3);
|
||||
console.log(
|
||||
` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`,
|
||||
);
|
||||
expect(Number.isFinite(simSimilar)).toBe(true);
|
||||
expect(Number.isFinite(simDifferent)).toBe(true);
|
||||
expect(simSimilar).toBeGreaterThan(simDifferent);
|
||||
});
|
||||
|
||||
it("embed() handles empty string", async () => {
|
||||
const vector = await embedder.embed("");
|
||||
expect(Array.isArray(vector)).toBe(true);
|
||||
expect(vector.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("embed() handles long text", async () => {
|
||||
const longText = "This is a test sentence. ".repeat(200);
|
||||
const vector = await embedder.embed(longText);
|
||||
expect(Array.isArray(vector)).toBe(true);
|
||||
expect(vector.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLM ─────────────────────────────────────────────────────────────
|
||||
describe("LMStudioLLM (real server)", () => {
|
||||
let llm: LMStudioLLM;
|
||||
let chatModelId: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const models = await listModels();
|
||||
chatModelId = models.chat;
|
||||
if (!chatModelId) {
|
||||
console.warn("No chat model loaded — LLM tests will be skipped");
|
||||
return;
|
||||
}
|
||||
llm = new LMStudioLLM({ baseURL: LMSTUDIO_BASE_URL, model: chatModelId });
|
||||
});
|
||||
|
||||
it("generateResponse() returns a response", async () => {
|
||||
if (!chatModelId) return;
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Say hello in exactly 3 words." },
|
||||
]);
|
||||
if (typeof result === "string") {
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
console.log(` Response (string): ${result.slice(0, 100)}`);
|
||||
} else {
|
||||
expect(result).toHaveProperty("content");
|
||||
expect(result.content.length).toBeGreaterThan(0);
|
||||
console.log(` Response (object): ${result.content.slice(0, 100)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("generateChat() returns LLMResponse with content and role", async () => {
|
||||
if (!chatModelId) return;
|
||||
const result = await llm.generateChat([
|
||||
{ role: "user", content: "What is 2+2?" },
|
||||
]);
|
||||
expect(result).toHaveProperty("content");
|
||||
expect(result).toHaveProperty("role");
|
||||
expect(result.role).toBe("assistant");
|
||||
expect(result.content.length).toBeGreaterThan(0);
|
||||
console.log(` Chat: ${result.content.slice(0, 100)}`);
|
||||
});
|
||||
|
||||
it("generateChat() handles multi-turn conversation", async () => {
|
||||
if (!chatModelId) return;
|
||||
const result = await llm.generateChat([
|
||||
{ role: "user", content: "My name is Alice." },
|
||||
{ role: "assistant", content: "Hello Alice!" },
|
||||
{ role: "user", content: "What is my name?" },
|
||||
]);
|
||||
expect(result.content.length).toBeGreaterThan(0);
|
||||
console.log(` Multi-turn: ${result.content.slice(0, 100)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio LLM — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioLLM } from "../src/llms/lmstudio";
|
||||
|
||||
const mockCreate = jest.fn();
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
chat: { completions: { create: mockCreate } },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioLLM (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("generateResponse() returns a text response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Hello, world!",
|
||||
role: "assistant",
|
||||
tool_calls: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Hi" },
|
||||
]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("generateResponse() handles tool calls", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "London"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "What is the weather?" }],
|
||||
undefined,
|
||||
[{ type: "function", function: { name: "get_weather" } }],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "",
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it("generateResponse() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateResponse([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Connection refused");
|
||||
});
|
||||
|
||||
it("generateChat() returns LLMResponse shape", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateChat([
|
||||
{ role: "user", content: "Help me" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "I can help with that.",
|
||||
role: "assistant",
|
||||
});
|
||||
});
|
||||
|
||||
it("generateChat() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateChat([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Timeout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* OSS Memory unit tests — add() with inference, without inference, filter validation, metadata.
|
||||
* Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["extracted fact from input"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "extracted fact from input",
|
||||
old_memory: "",
|
||||
new_memory: "extracted fact from input",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - add()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `add_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array for string input", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns at least one result with an id", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
test("result item has a memory string field", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(typeof result.results[0].memory).toBe("string");
|
||||
});
|
||||
|
||||
test("accepts Message[] input", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris." },
|
||||
];
|
||||
const result: SearchResult = await memory.add(messages, { userId });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with agentId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", {
|
||||
agentId: "agent_1",
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with runId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", { runId: "run_1" });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.add("test", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes metadata through to stored memory", async () => {
|
||||
const result: SearchResult = await memory.add("I love TypeScript", {
|
||||
userId,
|
||||
metadata: { source: "chat", tag: "programming" },
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ source: "chat", tag: "programming" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
// When infer=false, the literal message text is stored
|
||||
expect(result.results[0].memory).toBe("Direct storage content");
|
||||
});
|
||||
|
||||
test("with infer=false marks event as ADD in metadata", async () => {
|
||||
const result: SearchResult = await memory.add("Direct fact", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results[0].metadata).toEqual(
|
||||
expect.objectContaining({ event: "ADD" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history.
|
||||
* Content-based LLM mock. Tests verify real behavior, not mock echoes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["stored fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "stored fact",
|
||||
old_memory: "",
|
||||
new_memory: "stored fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("Memory - get()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `get_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns the memory matching the ID from add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("I love AI", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.id).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a string for the memory field", async () => {
|
||||
const addResult: SearchResult = await memory.add("Testing get", {
|
||||
userId,
|
||||
});
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const item = await memory.get("nonexistent-uuid-12345");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
test("returns hash and createdAt on stored memory", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash test", { userId });
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.hash).toBe("string");
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - update()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `update_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Original", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const result = await memory.update(id, "Updated");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
test("persists the updated text", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before update", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After update");
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item!.memory).toBe("After update");
|
||||
});
|
||||
|
||||
test("preserves createdAt and sets updatedAt", async () => {
|
||||
const addResult: SearchResult = await memory.add("Timestamp test", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
const originalCreatedAt = before!.createdAt;
|
||||
|
||||
await memory.update(id, "New text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.createdAt).toBe(originalCreatedAt);
|
||||
expect(after!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("updates the hash", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash change", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
await memory.update(id, "Completely different text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.hash).not.toBe(before!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - delete()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `delete_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Delete me", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const result = await memory.delete(addResult.results[0].id);
|
||||
expect(result.message).toBe("Memory deleted successfully!");
|
||||
});
|
||||
|
||||
test("get() returns null after deletion", async () => {
|
||||
const addResult: SearchResult = await memory.add("Temporary", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.delete(id);
|
||||
expect(await memory.get(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("Memory - deleteAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `deleteall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("removes all memories for the user and returns success", async () => {
|
||||
await memory.add("Fact A", { userId });
|
||||
await memory.add("Fact B", { userId });
|
||||
const result = await memory.deleteAll({ userId });
|
||||
expect(result.message).toBe("Memories deleted successfully!");
|
||||
const remaining: SearchResult = await memory.getAll({ userId });
|
||||
expect(remaining.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws when no filter is provided", async () => {
|
||||
await expect(memory.deleteAll({} as any)).rejects.toThrow(
|
||||
"At least one filter is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - getAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `getall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns all stored memories for the user", async () => {
|
||||
await memory.add("First", { userId });
|
||||
await memory.add("Second", { userId });
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("each result has id and memory fields", async () => {
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
for (const item of result.results) {
|
||||
expect(item.id).toBeDefined();
|
||||
expect(typeof item.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no memories exist", async () => {
|
||||
const result: SearchResult = await memory.getAll({
|
||||
userId: "no_such_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - search()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `search_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
await memory.add("I love TypeScript", { userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array", async () => {
|
||||
const result: SearchResult = await memory.search("TypeScript", { userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns results with score field", async () => {
|
||||
const result: SearchResult = await memory.search("content", { userId });
|
||||
if (result.results.length > 0) {
|
||||
expect(typeof result.results[0].score).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.search("query", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty results for user with no memories", async () => {
|
||||
const result: SearchResult = await memory.search("query", {
|
||||
userId: "empty_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("Memory - history()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `history_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("records ADD event after add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("New fact", { userId });
|
||||
const history = await memory.history(addResult.results[0].id);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("records additional entry after update()", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After");
|
||||
const history = await memory.history(id);
|
||||
expect(history.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for non-existent memory ID", async () => {
|
||||
const history = await memory.history("nonexistent-id");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder.
|
||||
* Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock LLM and embedder so tests run without API keys.
|
||||
// Content-based mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() }));
|
||||
jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() }));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({
|
||||
facts: ["John is a software engineer"],
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "John is a software engineer",
|
||||
old_memory: "",
|
||||
new_memory: "John is a software engineer",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
describeOrSkip("Memory Class (E2E)", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("add() single memory", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns a defined result", () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns result with an id", () => {
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("add() multiple messages", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
result = (await memory.add(messages, { userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() single memory", () => {
|
||||
let memoryItem: MemoryItem;
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
memoryItem = (await memory.get(memoryId)) as MemoryItem;
|
||||
});
|
||||
|
||||
it("returns the correct id", () => {
|
||||
expect(memoryItem.id).toBe(memoryId);
|
||||
});
|
||||
|
||||
it("returns a string memory", () => {
|
||||
expect(typeof memoryItem.memory).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update() memory", () => {
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
});
|
||||
|
||||
it("returns success message", async () => {
|
||||
const result = await memory.update(memoryId, "Updated content");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
it("persists the updated content", async () => {
|
||||
await memory.update(memoryId, "Updated content");
|
||||
const updated = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updated.memory).toBe("Updated content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll() memories for user", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love visiting new places in the winters", { userId });
|
||||
await memory.add("I like to rule the world", { userId });
|
||||
result = (await memory.getAll({ userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least two results", () => {
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search() memories", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love programming in Python", { userId });
|
||||
await memory.add("JavaScript is my favorite language", { userId });
|
||||
result = (await memory.search("What programming languages do I know?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history() of a memory", () => {
|
||||
let history: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add("I like swimming in warm water", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
history = await memory.history(memoryId);
|
||||
});
|
||||
|
||||
it("returns an array", () => {
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one entry", () => {
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete() a memory", () => {
|
||||
it("returns null after deletion", async () => {
|
||||
const addResult = (await memory.add("I love to drink vodka in summers", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.delete(memoryId);
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("add() returns results with custom config", async () => {
|
||||
const result = (await customMemory.add("I love programming in Python", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("search() returns results with custom config", async () => {
|
||||
await customMemory.add("The weather in London is rainy today", {
|
||||
userId,
|
||||
});
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", {
|
||||
userId,
|
||||
});
|
||||
const result = (await customMemory.search("What is the weather like?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* OSS Memory unit tests — constructor, initialization, config validation, reset.
|
||||
* Mocks LLM/Embedder at module level. No API keys needed.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
// ─── Content-based LLM mock (reviewer #9) ────────────────
|
||||
// Returns facts for system-prompt calls, memory actions for user-only calls.
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["test fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "test fact",
|
||||
old_memory: "",
|
||||
new_memory: "test fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-init", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - Initialization", () => {
|
||||
test("constructs without throwing with valid config", () => {
|
||||
expect(() => createMemory()).not.toThrow();
|
||||
});
|
||||
|
||||
test("fromConfig creates instance from config dict", () => {
|
||||
const config = {
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4" },
|
||||
},
|
||||
};
|
||||
const mem = Memory.fromConfig(config);
|
||||
expect(mem).toBeInstanceOf(Memory);
|
||||
});
|
||||
|
||||
test("fromConfig throws on invalid config", () => {
|
||||
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
|
||||
const mem = createMemory({ disableHistory: true });
|
||||
// If DummyHistoryManager is used, history returns [] without error
|
||||
const result = await mem.history("nonexistent-id");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory - reset()", () => {
|
||||
test("reset clears all stored memories", async () => {
|
||||
const mem = createMemory();
|
||||
const userId = `reset_test_${Date.now()}`;
|
||||
|
||||
await mem.add("Remember this fact", { userId });
|
||||
const before: SearchResult = await mem.getAll({ userId });
|
||||
expect(before.results.length).toBeGreaterThan(0);
|
||||
|
||||
await mem.reset();
|
||||
|
||||
const after: SearchResult = await mem.getAll({ userId });
|
||||
expect(after.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
jest.setTimeout(30000); // Increase timeout to 30 seconds
|
||||
|
||||
describe("Memory Class", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with default configuration
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
// Reset all memories before each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("Basic Memory Operations", () => {
|
||||
it("should add a single memory", async () => {
|
||||
const result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add multiple messages", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
|
||||
const result = (await memory.add(messages, userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get a single memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const result = (await memory.get(memoryId)) as MemoryItem;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(memoryId);
|
||||
expect(result.memory).toBeDefined();
|
||||
expect(typeof result.memory).toBe("string");
|
||||
});
|
||||
|
||||
it("should update a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const updatedContent = "Updated content";
|
||||
const result = await memory.update(memoryId, updatedContent);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
|
||||
// Verify the update by getting the memory
|
||||
const updatedMemory = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updatedMemory.memory).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it("should get all memories for a user", async () => {
|
||||
// Add a few memories
|
||||
await memory.add("I love visiting new places in the winters", userId);
|
||||
await memory.add("I like to rule the world", userId);
|
||||
|
||||
const result = (await memory.getAll(userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should search memories", async () => {
|
||||
// Add some test memories
|
||||
await memory.add("I love programming in Python", userId);
|
||||
await memory.add("JavaScript is my favorite language", userId);
|
||||
|
||||
const result = (await memory.search(
|
||||
"What programming languages do I know?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get memory history", async () => {
|
||||
// Add and update a memory to create history
|
||||
const addResult = (await memory.add(
|
||||
"I like swimming in warm water",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
|
||||
const history = await memory.history(memoryId);
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love to drink vodka in summers",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
|
||||
// Delete the memory
|
||||
await memory.delete(memoryId);
|
||||
|
||||
// Try to get the deleted memory - should throw or return null
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("should work with custom configuration", async () => {
|
||||
const result = (await customMemory.add(
|
||||
"I love programming in Python",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should perform semantic search with custom embeddings", async () => {
|
||||
// Add test memories
|
||||
await customMemory.add("The weather in London is rainy today", userId);
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", userId);
|
||||
|
||||
const result = (await customMemory.search(
|
||||
"What is the weather like?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
// Results should be ordered by relevance
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* End-to-end tests for Qdrant dimension mismatch fix.
|
||||
*
|
||||
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
|
||||
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
|
||||
*
|
||||
* Skipped automatically when Qdrant is not available.
|
||||
*
|
||||
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
|
||||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { Qdrant } from "../src/vector_stores/qdrant";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const QDRANT_HOST = "localhost";
|
||||
const QDRANT_PORT = 6333;
|
||||
|
||||
// Check if Qdrant is reachable synchronously at load time using
|
||||
// a sync check via child_process so describe.skip works correctly.
|
||||
function isQdrantAvailable(): boolean {
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||
{ timeout: 3000, stdio: "ignore" },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const qdrantAvailable = isQdrantAvailable();
|
||||
if (!qdrantAvailable) {
|
||||
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
|
||||
}
|
||||
|
||||
let qdrantClient: QdrantClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!qdrantAvailable) return;
|
||||
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
|
||||
const collections = await qdrantClient.getCollections();
|
||||
expect(collections).toBeDefined();
|
||||
});
|
||||
|
||||
// Helper: delete a collection if it exists
|
||||
async function deleteCollectionIfExists(name: string) {
|
||||
try {
|
||||
await qdrantClient.deleteCollection(name);
|
||||
} catch {
|
||||
// Collection doesn't exist — fine
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create a fake embedder that produces vectors of a given dimension
|
||||
function createFakeEmbedder(dims: number) {
|
||||
return {
|
||||
embed: jest.fn().mockImplementation(async (_text: string) => {
|
||||
const vec = new Array(dims).fill(0);
|
||||
for (let i = 0; i < _text.length && i < dims; i++) {
|
||||
vec[i] = _text.charCodeAt(i) / 255;
|
||||
}
|
||||
return vec;
|
||||
}),
|
||||
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
|
||||
return Promise.all(
|
||||
texts.map(async (t) => {
|
||||
const vec = new Array(dims).fill(0);
|
||||
for (let i = 0; i < t.length && i < dims; i++) {
|
||||
vec[i] = t.charCodeAt(i) / 255;
|
||||
}
|
||||
return vec;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Conditionally skip tests when Qdrant is unavailable
|
||||
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteCollectionIfExists("e2e_test_768");
|
||||
await deleteCollectionIfExists("e2e_test_1536");
|
||||
await deleteCollectionIfExists("e2e_test_race");
|
||||
await deleteCollectionIfExists("e2e_test_race2");
|
||||
await deleteCollectionIfExists("e2e_test_noexplicit");
|
||||
await deleteCollectionIfExists("e2e_test_explicit");
|
||||
await deleteCollectionIfExists("e2e_test_embdims");
|
||||
await deleteCollectionIfExists("e2e_test_autodetect");
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
|
||||
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
|
||||
const collectionName = "e2e_test_1536";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await qdrantClient.createCollection(collectionName, {
|
||||
vectors: { size: 1536, distance: "Cosine" },
|
||||
});
|
||||
|
||||
// Insert a 768-dim vector — this is what nomic-embed-text produces
|
||||
const vector768 = new Array(768).fill(0.1);
|
||||
try {
|
||||
await qdrantClient.upsert(collectionName, {
|
||||
points: [
|
||||
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
|
||||
],
|
||||
});
|
||||
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
|
||||
} catch (error: any) {
|
||||
// This is the exact "Bad Request" error users were hitting
|
||||
expect(error.status).toBe(400);
|
||||
}
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
});
|
||||
|
||||
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
|
||||
const collectionName = "e2e_test_768";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
// Create Qdrant store with correct dimension (what our auto-detect provides)
|
||||
const store = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify collection was created with 768 dims
|
||||
const info = await qdrantClient.getCollection(collectionName);
|
||||
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// Insert 768-dim vectors (what nomic-embed-text produces)
|
||||
const vec1 = new Array(768).fill(0);
|
||||
vec1[0] = 1.0;
|
||||
const vec2 = new Array(768).fill(0);
|
||||
vec2[1] = 1.0;
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
|
||||
await store.insert(
|
||||
[vec1, vec2],
|
||||
[id1, id2],
|
||||
[
|
||||
{ data: "hello", userId: "u1" },
|
||||
{ data: "world", userId: "u1" },
|
||||
],
|
||||
);
|
||||
|
||||
// Search with 768-dim query — this USED TO fail with Bad Request
|
||||
const results = await store.search(vec1, 2, { userId: "u1" });
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe(id1); // Most similar to itself
|
||||
expect(results[0].score).toBeGreaterThan(0.9);
|
||||
|
||||
// Get by ID
|
||||
const item = await store.get(id1);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.payload.data).toBe("hello");
|
||||
|
||||
// Update with 768-dim vector
|
||||
const vec3 = new Array(768).fill(0);
|
||||
vec3[2] = 1.0;
|
||||
await store.update(id1, vec3, { data: "updated", userId: "u1" });
|
||||
const updated = await store.get(id1);
|
||||
expect(updated!.payload.data).toBe("updated");
|
||||
|
||||
// Delete
|
||||
await store.delete(id2);
|
||||
const deleted = await store.get(id2);
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// List
|
||||
const [listed, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(1);
|
||||
expect(listed[0].payload.data).toBe("updated");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
|
||||
const collectionName = "e2e_test_autodetect";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
// Mock only the non-Qdrant factories to avoid Google SDK import crash
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
// Import Qdrant directly (avoids loading Google embedder via factory)
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
// This is the EXACT config from issue #4212 — NO dimension specified
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
// This triggers init — probe should detect 768 dims
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Verify the probe was called
|
||||
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// Verify Qdrant collection was created with auto-detected 768 dims
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// Search should work (this used to throw Bad Request)
|
||||
const searchResult = await mem.search("hello world", {
|
||||
userId: "test-user",
|
||||
});
|
||||
expect(searchResult).toBeDefined();
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
|
||||
const collectionName = "e2e_test_explicit";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
// Workaround config from #4212 — explicit dimension
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
dimension: 768,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Probe should NOT have been called
|
||||
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
|
||||
const collectionName = "e2e_test_embdims";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Probe should NOT have been called — dimension inferred from embeddingDims
|
||||
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. Reproduce #4056 issue 1: Collection creation race condition
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
|
||||
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
|
||||
const collectionName = "e2e_test_race";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
// Create 5 Qdrant instances concurrently — this simulates the race
|
||||
// that caused "Collection memory_migrations already exists!" in #4056
|
||||
const instances = Array.from(
|
||||
{ length: 5 },
|
||||
() =>
|
||||
new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
}),
|
||||
);
|
||||
|
||||
// All should initialize without throwing 409 Conflict
|
||||
await Promise.all(instances.map((inst) => inst.initialize()));
|
||||
|
||||
// Verify collection exists with correct dimension
|
||||
const info = await qdrantClient.getCollection(collectionName);
|
||||
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// memory_migrations should also exist (created by initialize)
|
||||
const migrInfo = await qdrantClient.getCollection("memory_migrations");
|
||||
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
it("getUserId works after concurrent initialization", async () => {
|
||||
const collectionName = "e2e_test_race2";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const instance = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
|
||||
await instance.initialize();
|
||||
|
||||
// getUserId should work without 409 crash
|
||||
const userId = await instance.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
|
||||
// setUserId + getUserId roundtrip
|
||||
await instance.setUserId("custom-e2e-user");
|
||||
const updated = await instance.getUserId();
|
||||
expect(updated).toBe("custom-e2e-user");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
|
||||
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
|
||||
const collectionName = "e2e_test_noexplicit";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const instance = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
|
||||
await instance.initialize();
|
||||
|
||||
// Allow Qdrant a moment to fully commit collections
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Main collection should be 768
|
||||
const mainInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// memory_migrations should be 1 (NOT 768!)
|
||||
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
|
||||
const migrationsInfo =
|
||||
await qdrantClient.getCollection("memory_migrations");
|
||||
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
|
||||
|
||||
// getUserId should work — vector dim=1 in memory_migrations
|
||||
const userId = await instance.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
|
||||
// setUserId should also work
|
||||
await instance.setUserId("custom-test-user");
|
||||
const newUserId = await instance.getUserId();
|
||||
expect(newUserId).toBe("custom-test-user");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* End-to-end tests for Redis vector store with init guard fix.
|
||||
*
|
||||
* Requires a running Redis Stack instance at localhost:6379.
|
||||
* Skipped automatically when Redis is not available.
|
||||
*
|
||||
* Run: npx jest --config jest.config.js src/oss/tests/redis-e2e.test.ts --forceExit
|
||||
*/
|
||||
|
||||
import { createClient } from "redis";
|
||||
import { RedisDB } from "../src/vector_stores/redis";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const REDIS_HOST = "localhost";
|
||||
const REDIS_PORT = 6379;
|
||||
const REDIS_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
|
||||
const COLLECTION_NAME = "e2e_redis_test";
|
||||
|
||||
// Check if Redis is reachable synchronously at load time
|
||||
function isRedisAvailable(): boolean {
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`node -e "const s=require('net').createConnection({host:'${REDIS_HOST}',port:${REDIS_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||
{ timeout: 3000, stdio: "ignore" },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const redisAvailable = isRedisAvailable();
|
||||
if (!redisAvailable) {
|
||||
console.warn("Redis not available at localhost:6379 — skipping e2e tests");
|
||||
}
|
||||
|
||||
// Standalone client for cleanup
|
||||
let cleanupClient: ReturnType<typeof createClient>;
|
||||
|
||||
async function cleanupRedis() {
|
||||
if (!redisAvailable) return;
|
||||
try {
|
||||
// Drop the index if it exists
|
||||
await cleanupClient.ft.dropIndex(COLLECTION_NAME);
|
||||
} catch {
|
||||
// Index doesn't exist — fine
|
||||
}
|
||||
|
||||
// Delete all keys with our prefix
|
||||
const keys = await cleanupClient.keys(`mem0:${COLLECTION_NAME}:*`);
|
||||
if (keys.length > 0) {
|
||||
await cleanupClient.del(keys);
|
||||
}
|
||||
|
||||
// Clean up memory_migrations key
|
||||
await cleanupClient.del("memory_migrations:1");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!redisAvailable) return;
|
||||
|
||||
cleanupClient = createClient({ url: REDIS_URL });
|
||||
await cleanupClient.connect();
|
||||
|
||||
// Verify Redis Stack is running with search module
|
||||
const modules = (await cleanupClient.moduleList()) as unknown as any[];
|
||||
const hasSearch = modules.some((mod: any[]) => {
|
||||
const moduleMap = new Map();
|
||||
for (let i = 0; i < mod.length; i += 2) {
|
||||
moduleMap.set(mod[i], mod[i + 1]);
|
||||
}
|
||||
return moduleMap.get("name")?.toLowerCase() === "search";
|
||||
});
|
||||
expect(hasSearch).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!redisAvailable) return;
|
||||
await cleanupRedis();
|
||||
await cleanupClient.quit();
|
||||
});
|
||||
|
||||
// Conditionally skip tests when Redis is unavailable
|
||||
const describeIfRedis = redisAvailable ? describe : describe.skip;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. Basic initialization and idempotent init guard
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: initialization", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("initializes successfully and creates index", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 128,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify the index was created by querying index info
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
expect(info).toBeDefined();
|
||||
expect(info.indexName).toBe(COLLECTION_NAME);
|
||||
|
||||
await store.close();
|
||||
});
|
||||
|
||||
it("idempotent initialize() — multiple calls don't crash", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 128,
|
||||
});
|
||||
|
||||
// Call initialize multiple times concurrently
|
||||
await Promise.all([
|
||||
store.initialize(),
|
||||
store.initialize(),
|
||||
store.initialize(),
|
||||
]);
|
||||
|
||||
// Should still work fine
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
expect(info).toBeDefined();
|
||||
|
||||
await store.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. Full CRUD operations
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: CRUD operations", () => {
|
||||
let store: RedisDB;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupRedis();
|
||||
store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 4, // Small dims for testing
|
||||
});
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.close();
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("insert and search vectors", async () => {
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const vec1 = [1.0, 0.0, 0.0, 0.0];
|
||||
const vec2 = [0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec1, vec2],
|
||||
[id1, id2],
|
||||
[
|
||||
{
|
||||
data: "hello world",
|
||||
hash: "h1",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "goodbye world",
|
||||
hash: "h2",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Search — vec1 should be most similar to itself
|
||||
const results = await store.search(vec1, 2, { userId: "user1" });
|
||||
expect(results.length).toBe(2);
|
||||
// The first result should be closest to the query
|
||||
expect(results[0].id).toBe(id1);
|
||||
expect(results[0].score).toBeDefined();
|
||||
expect(results[0].payload).toBeDefined();
|
||||
});
|
||||
|
||||
it("get vector by ID", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [0.5, 0.5, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "test memory",
|
||||
hash: "h-test",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(id);
|
||||
expect(result!.payload.data).toBe("test memory");
|
||||
expect(result!.payload.hash).toBe("h-test");
|
||||
});
|
||||
|
||||
it("get non-existent vector returns null", async () => {
|
||||
const result = await store.get("non-existent-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("update vector and payload", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [1.0, 0.0, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "original",
|
||||
hash: "h-orig",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Update with new vector and payload
|
||||
const newVec = [0.0, 0.0, 1.0, 0.0];
|
||||
await store.update(id, newVec, {
|
||||
data: "updated memory",
|
||||
hash: "h-updated",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.data).toBe("updated memory");
|
||||
expect(result!.payload.hash).toBe("h-updated");
|
||||
});
|
||||
|
||||
it("delete vector", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "to be deleted",
|
||||
hash: "h-del",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Verify it exists
|
||||
const before = await store.get(id);
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Delete
|
||||
await store.delete(id);
|
||||
|
||||
// Verify it's gone
|
||||
const after = await store.get(id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it("list vectors with filters", async () => {
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const id3 = uuidv4();
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
],
|
||||
[id1, id2, id3],
|
||||
[
|
||||
{
|
||||
data: "mem1",
|
||||
hash: "h1",
|
||||
userId: "usera",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "mem2",
|
||||
hash: "h2",
|
||||
userId: "usera",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "mem3",
|
||||
hash: "h3",
|
||||
userId: "userb",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// List all
|
||||
const [all, allCount] = await store.list();
|
||||
expect(allCount).toBe(3);
|
||||
expect(all.length).toBe(3);
|
||||
|
||||
// List with filter
|
||||
const [filtered, filteredCount] = await store.list({
|
||||
userId: "usera",
|
||||
});
|
||||
expect(filteredCount).toBe(2);
|
||||
expect(filtered.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. getUserId / setUserId
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: getUserId / setUserId", () => {
|
||||
let store: RedisDB;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupRedis();
|
||||
store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 4,
|
||||
});
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.close();
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("getUserId generates random ID if none exists", async () => {
|
||||
const userId = await store.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("setUserId + getUserId roundtrip", async () => {
|
||||
await store.setUserId("custom-redis-user");
|
||||
const retrieved = await store.getUserId();
|
||||
expect(retrieved).toBe("custom-redis-user");
|
||||
});
|
||||
|
||||
it("getUserId returns same value on subsequent calls", async () => {
|
||||
const first = await store.getUserId();
|
||||
const second = await store.getUserId();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4. Dimension handling (our fix ensures correct dims from Memory)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: dimension handling", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("creates index with correct dimensions from config", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 768,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify the index has the right dimension in its schema
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
// Check that the vector field has DIM=768
|
||||
const attributes = info.attributes as any[];
|
||||
const vectorAttr = attributes.find(
|
||||
(a: any) => a.identifier === "embedding" || a.attribute === "embedding",
|
||||
);
|
||||
expect(vectorAttr).toBeDefined();
|
||||
|
||||
await store.close();
|
||||
});
|
||||
|
||||
it("insert with matching dimension succeeds", async () => {
|
||||
const dims = 128;
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: dims,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
const id = uuidv4();
|
||||
const vec = new Array(dims).fill(0.1);
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "test",
|
||||
hash: "h1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(id);
|
||||
|
||||
await store.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { removeCodeBlocks } from "../src/prompts";
|
||||
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("extracts JSON from ```json code fence", () => {
|
||||
const input = '```json\n{"facts": ["hello"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("extracts content from bare ``` code fence", () => {
|
||||
const input = '```\n{"key": "value"}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
const input = '{"facts": ["hello"]}';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("handles multiple code blocks", () => {
|
||||
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
|
||||
});
|
||||
|
||||
it("handles Claude-style response with surrounding text", () => {
|
||||
const input =
|
||||
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toContain('"facts"');
|
||||
expect(removeCodeBlocks(input)).not.toContain("```");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Storage manager unit tests — SQLiteManager, DummyHistoryManager.
|
||||
* Uses real in-memory SQLite, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { SQLiteManager } from "../src/storage/SQLiteManager";
|
||||
import { DummyHistoryManager } from "../src/storage/DummyHistoryManager";
|
||||
import { MemoryHistoryManager } from "../src/storage/MemoryHistoryManager";
|
||||
|
||||
// ─── SQLiteManager ──────────────────────────────────────
|
||||
|
||||
describe("SQLiteManager", () => {
|
||||
let db: SQLiteManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new SQLiteManager(":memory:");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory inserts a record retrievable by getHistory", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns records in reverse chronological order", async () => {
|
||||
await db.addHistory("mem1", null, "first", "ADD", "2026-01-01");
|
||||
await db.addHistory("mem1", "first", "second", "UPDATE", "2026-01-02");
|
||||
await db.addHistory("mem1", "second", "third", "UPDATE", "2026-01-03");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
// DESC order by id: most recent first
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
const history = await db.getHistory("nonexistent");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("addHistory stores previous_value for UPDATE", async () => {
|
||||
await db.addHistory("mem1", "old text", "new text", "UPDATE");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].previous_value).toBe("old text");
|
||||
expect(history[0].new_value).toBe("new text");
|
||||
});
|
||||
|
||||
test("addHistory stores null new_value for DELETE", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
"deleted text",
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].action).toBe("DELETE");
|
||||
expect(history[0].new_value).toBeNull();
|
||||
expect(history[0].is_deleted).toBe(1);
|
||||
});
|
||||
|
||||
test("reset clears all history and recreates table", async () => {
|
||||
await db.addHistory("mem1", null, "data", "ADD");
|
||||
await db.addHistory("mem2", null, "data", "ADD");
|
||||
await db.reset();
|
||||
expect(await db.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(0);
|
||||
// Table still works after reset
|
||||
await db.addHistory("mem3", null, "after reset", "ADD");
|
||||
expect(await db.getHistory("mem3")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stores createdAt and updatedAt timestamps", async () => {
|
||||
const created = "2026-03-17T10:00:00Z";
|
||||
const updated = "2026-03-17T11:00:00Z";
|
||||
await db.addHistory("mem1", null, "data", "ADD", created, updated);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].created_at).toBe(created);
|
||||
expect(history[0].updated_at).toBe(updated);
|
||||
});
|
||||
|
||||
test("handles multiple memories independently", async () => {
|
||||
await db.addHistory("mem1", null, "data1", "ADD");
|
||||
await db.addHistory("mem2", null, "data2", "ADD");
|
||||
expect(await db.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DummyHistoryManager ────────────────────────────────
|
||||
|
||||
describe("DummyHistoryManager", () => {
|
||||
let dummy: DummyHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
dummy = new DummyHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(dummy).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory is a no-op that resolves", async () => {
|
||||
await expect(
|
||||
dummy.addHistory("id", null, "val", "ADD"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("getHistory returns empty array", async () => {
|
||||
const result = await dummy.getHistory("any-id");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("reset resolves without throwing", async () => {
|
||||
await expect(dummy.reset()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => dummy.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryHistoryManager ───────────────────────────────
|
||||
|
||||
describe("MemoryHistoryManager", () => {
|
||||
let mgr: MemoryHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new MemoryHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(mgr).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory + getHistory round-trips correctly", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns entries sorted by date descending", async () => {
|
||||
await mgr.addHistory("mem1", null, "first", "ADD", "2026-01-01T00:00:00Z");
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"first",
|
||||
"second",
|
||||
"UPDATE",
|
||||
"2026-01-02T00:00:00Z",
|
||||
);
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"second",
|
||||
"third",
|
||||
"UPDATE",
|
||||
"2026-01-03T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
expect(await mgr.getHistory("nonexistent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("getHistory caps at 100 entries", async () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
`entry-${i}`,
|
||||
"ADD",
|
||||
`2026-01-01T00:${String(i).padStart(2, "0")}:00Z`,
|
||||
);
|
||||
}
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("reset clears all entries", async () => {
|
||||
await mgr.addHistory("mem1", null, "data", "ADD");
|
||||
await mgr.addHistory("mem2", null, "data", "ADD");
|
||||
await mgr.reset();
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => mgr.close()).not.toThrow();
|
||||
});
|
||||
|
||||
test("isolates history by memory_id", async () => {
|
||||
await mgr.addHistory("mem1", null, "d1", "ADD");
|
||||
await mgr.addHistory("mem2", null, "d2", "ADD");
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity.
|
||||
* Uses real SQLite in-memory DB, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import type { VectorStoreResult } from "../src/types";
|
||||
|
||||
const DIM = 4; // Small dimension for fast tests
|
||||
|
||||
function createStore(): MemoryVectorStore {
|
||||
return new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: DIM,
|
||||
dbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
function vec(values: number[]): number[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
describe("MemoryVectorStore - insert + get", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test("inserts and retrieves a vector by ID", async () => {
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0])],
|
||||
["id1"],
|
||||
[{ data: "hello", userId: "u1" }],
|
||||
);
|
||||
const result: VectorStoreResult | null = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("id1");
|
||||
expect(result!.payload.data).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const result = await store.get("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during insert", async () => {
|
||||
await expect(
|
||||
store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - search", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])],
|
||||
["a", "b", "c"],
|
||||
[
|
||||
{ data: "north", userId: "u1" },
|
||||
{ data: "east", userId: "u1" },
|
||||
{ data: "north-ish", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns results sorted by cosine similarity descending", async () => {
|
||||
const results: VectorStoreResult[] = await store.search(
|
||||
vec([1, 0, 0, 0]),
|
||||
10,
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].id).toBe("a"); // exact match
|
||||
// scores should be descending
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!);
|
||||
}
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
|
||||
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty when filter matches nothing", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, {
|
||||
userId: "nobody",
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws on query dimension mismatch", async () => {
|
||||
await expect(store.search(vec([1, 0]), 10)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - update", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]);
|
||||
});
|
||||
|
||||
test("updates payload and vector", async () => {
|
||||
await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" });
|
||||
const result = await store.get("upd1");
|
||||
expect(result!.payload.data).toBe("updated");
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during update", async () => {
|
||||
await expect(
|
||||
store.update("upd1", vec([1, 0]), { data: "bad" }),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - delete + deleteCol", () => {
|
||||
test("delete removes a vector", async () => {
|
||||
const store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]);
|
||||
await store.delete("del1");
|
||||
expect(await store.get("del1")).toBeNull();
|
||||
});
|
||||
|
||||
test("deleteCol clears all vectors", async () => {
|
||||
const store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0])],
|
||||
["x", "y"],
|
||||
[{ data: "a" }, { data: "b" }],
|
||||
);
|
||||
await store.deleteCol();
|
||||
const [results] = await store.list();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - list", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])],
|
||||
["l1", "l2", "l3"],
|
||||
[
|
||||
{ data: "a", userId: "u1" },
|
||||
{ data: "b", userId: "u1" },
|
||||
{ data: "c", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns all vectors without filter", async () => {
|
||||
const [results, count] = await store.list();
|
||||
expect(count).toBe(3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const [results, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(2);
|
||||
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
|
||||
});
|
||||
|
||||
test("respects limit", async () => {
|
||||
const [results] = await store.list(undefined, 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - userId tracking", () => {
|
||||
test("getUserId generates and persists a random ID", async () => {
|
||||
const store = createStore();
|
||||
const id = await store.getUserId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
// Calling again returns same ID
|
||||
expect(await store.getUserId()).toBe(id);
|
||||
});
|
||||
|
||||
test("setUserId overrides the stored ID", async () => {
|
||||
const store = createStore();
|
||||
await store.setUserId("custom-id");
|
||||
expect(await store.getUserId()).toBe("custom-id");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
@@ -46,3 +47,8 @@ class LLMRerankerConfig(BaseRerankerConfig):
|
||||
default=None,
|
||||
description="Custom prompt template for scoring documents"
|
||||
)
|
||||
llm: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Nested LLM configuration with 'provider' and 'config' keys. "
|
||||
"Overrides top-level provider/model/api_key when provided.",
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ class NeptuneBase(ABC):
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
@@ -144,7 +144,7 @@ class NeptuneBase(ABC):
|
||||
|
||||
entities = []
|
||||
if extracted_entities["tool_calls"]:
|
||||
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
|
||||
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
||||
|
||||
entities = self._remove_spaces_from_entities(entities)
|
||||
logger.debug(f"Extracted entities: {entities}")
|
||||
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -8,6 +9,7 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class OllamaLLM(LLMBase):
|
||||
@@ -61,7 +63,28 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
if isinstance(response, dict):
|
||||
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||
else:
|
||||
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||
|
||||
for tool_call in raw_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
else:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", "") if fn else ""
|
||||
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(extract_json(arguments))
|
||||
|
||||
processed_response["tool_calls"].append(
|
||||
{"name": name, "arguments": arguments}
|
||||
)
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -113,5 +136,8 @@ class OllamaLLM(LLMBase):
|
||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.chat(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
@@ -30,10 +30,10 @@ class MemoryGraph:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.graph = Neo4jGraph(
|
||||
self.config.graph_store.config.url,
|
||||
self.config.graph_store.config.username,
|
||||
self.config.graph_store.config.password,
|
||||
self.config.graph_store.config.database,
|
||||
url=self.config.graph_store.config.url,
|
||||
username=self.config.graph_store.config.username,
|
||||
password=self.config.graph_store.config.password,
|
||||
database=self.config.graph_store.config.database,
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
@@ -215,7 +215,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
@@ -241,7 +241,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
+45
-35
@@ -24,10 +24,12 @@ from mem0.exceptions import ValidationError as Mem0ValidationError
|
||||
from mem0.memory.base import MemoryBase
|
||||
from mem0.memory.setup import mem0_dir, setup_config
|
||||
from mem0.memory.storage import SQLiteManager
|
||||
from mem0.memory.telemetry import capture_event
|
||||
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
|
||||
from mem0.memory.utils import (
|
||||
ensure_json_instruction,
|
||||
extract_json,
|
||||
get_fact_retrieval_messages,
|
||||
normalize_facts,
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
@@ -37,8 +39,8 @@ from mem0.utils.factory import (
|
||||
EmbedderFactory,
|
||||
GraphStoreFactory,
|
||||
LlmFactory,
|
||||
VectorStoreFactory,
|
||||
RerankerFactory,
|
||||
VectorStoreFactory,
|
||||
)
|
||||
|
||||
# Suppress SWIG deprecation warnings globally
|
||||
@@ -204,32 +206,33 @@ class Memory(MemoryBase):
|
||||
self.enable_graph = True
|
||||
else:
|
||||
self.graph = None
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
if MEM0_TELEMETRY:
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
|
||||
# Set path for file-based vector stores
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
# Set path for file-based vector stores
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
capture_event("mem0.init", self, {"sync_type": "sync"})
|
||||
|
||||
@classmethod
|
||||
@@ -431,6 +434,9 @@ class Memory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = self.llm.generate_response(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -451,6 +457,7 @@ class Memory(MemoryBase):
|
||||
# Try extracting JSON from response using built-in function
|
||||
extracted_json = extract_json(response)
|
||||
new_retrieved_facts = json.loads(extracted_json)["facts"]
|
||||
new_retrieved_facts = normalize_facts(new_retrieved_facts)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in new_retrieved_facts: {e}")
|
||||
new_retrieved_facts = []
|
||||
@@ -1046,11 +1053,10 @@ class Memory(MemoryBase):
|
||||
|
||||
keys, encoded_ids = process_telemetry_filters(filters)
|
||||
capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"})
|
||||
# delete all vector memories and reset the collections
|
||||
# delete matching vector memories individually (do NOT reset the collection)
|
||||
memories = self.vector_store.list(filters=filters)[0]
|
||||
for memory in memories:
|
||||
self._delete_memory(memory.id)
|
||||
self.vector_store.reset()
|
||||
|
||||
logger.info(f"Deleted {len(memories)} memories")
|
||||
|
||||
@@ -1272,14 +1278,14 @@ class AsyncMemory(MemoryBase):
|
||||
else:
|
||||
self.graph = None
|
||||
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
|
||||
if MEM0_TELEMETRY:
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
capture_event("mem0.init", self, {"sync_type": "async"})
|
||||
|
||||
@classmethod
|
||||
@@ -1460,6 +1466,9 @@ class AsyncMemory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
self.llm.generate_response,
|
||||
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
|
||||
@@ -1477,6 +1486,7 @@ class AsyncMemory(MemoryBase):
|
||||
# Try extracting JSON from response using built-in function
|
||||
extracted_json = extract_json(response)
|
||||
new_retrieved_facts = json.loads(extracted_json)["facts"]
|
||||
new_retrieved_facts = normalize_facts(new_retrieved_facts)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in new_retrieved_facts: {e}")
|
||||
new_retrieved_facts = []
|
||||
|
||||
@@ -218,7 +218,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
if "entity" in item and "entity_type" in item:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
|
||||
+53
-1
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mem0.configs.prompts import (
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
FACT_RETRIEVAL_PROMPT,
|
||||
USER_MEMORY_EXTRACTION_PROMPT,
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_fact_retrieval_messages(message, is_agent_memory=False):
|
||||
"""Get fact retrieval messages based on the memory type.
|
||||
@@ -29,6 +32,31 @@ def get_fact_retrieval_messages_legacy(message):
|
||||
return FACT_RETRIEVAL_PROMPT, f"Input:\n{message}"
|
||||
|
||||
|
||||
def ensure_json_instruction(system_prompt, user_prompt):
|
||||
"""Ensure the word 'json' appears in the prompts when using json_object response format.
|
||||
|
||||
OpenAI's API requires the word 'json' to appear in the messages when
|
||||
response_format is set to {"type": "json_object"}. When users provide a
|
||||
custom_fact_extraction_prompt that doesn't include 'json', this causes a
|
||||
400 error. This function appends a JSON format instruction to the system
|
||||
prompt if 'json' is not already present in either prompt.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt string
|
||||
user_prompt: The user prompt string
|
||||
|
||||
Returns:
|
||||
tuple: (system_prompt, user_prompt) with JSON instruction added if needed
|
||||
"""
|
||||
combined = (system_prompt + user_prompt).lower()
|
||||
if "json" not in combined:
|
||||
system_prompt += (
|
||||
"\n\nYou must return your response in valid JSON format "
|
||||
"with a 'facts' key containing an array of strings."
|
||||
)
|
||||
return system_prompt, user_prompt
|
||||
|
||||
|
||||
def parse_messages(messages):
|
||||
response = ""
|
||||
for msg in messages:
|
||||
@@ -52,6 +80,30 @@ def format_entities(entities):
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def normalize_facts(raw_facts):
|
||||
"""Normalize LLM-extracted facts to a list of strings.
|
||||
|
||||
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
|
||||
like {"fact": "..."} or {"text": "..."} instead of plain strings.
|
||||
This mirrors the TypeScript FactRetrievalSchema validation.
|
||||
"""
|
||||
if not raw_facts:
|
||||
return []
|
||||
normalized = []
|
||||
for item in raw_facts:
|
||||
if isinstance(item, str):
|
||||
fact = item
|
||||
elif isinstance(item, dict):
|
||||
fact = item.get("fact") or item.get("text")
|
||||
if fact is None:
|
||||
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
|
||||
continue
|
||||
else:
|
||||
fact = str(item)
|
||||
if fact:
|
||||
normalized.append(fact)
|
||||
return normalized
|
||||
|
||||
|
||||
def remove_code_blocks(content: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
from typing import List, Dict, Any, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
class LLMReranker(BaseReranker):
|
||||
@@ -33,19 +33,30 @@ class LLMReranker(BaseReranker):
|
||||
|
||||
self.config = config
|
||||
|
||||
# Create LLM configuration for the factory
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
|
||||
# Add API key if provided
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
# If a nested ``llm`` dict is provided (e.g. for non-OpenAI providers
|
||||
# like Ollama that need provider-specific fields such as
|
||||
# ``ollama_base_url``), use it to configure the LLM factory.
|
||||
if self.config.llm:
|
||||
nested = self.config.llm
|
||||
llm_provider = nested.get("provider", self.config.provider)
|
||||
llm_config: dict = dict(nested.get("config") or {})
|
||||
llm_config.setdefault("model", self.config.model)
|
||||
llm_config.setdefault("temperature", self.config.temperature)
|
||||
llm_config.setdefault("max_tokens", self.config.max_tokens)
|
||||
if self.config.api_key:
|
||||
llm_config.setdefault("api_key", self.config.api_key)
|
||||
else:
|
||||
llm_provider = self.config.provider
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(self.config.provider, llm_config)
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
@@ -159,7 +159,7 @@ class RedisDB(VectorStoreBase):
|
||||
return [
|
||||
MemoryResult(
|
||||
id=result["memory_id"],
|
||||
score=result["vector_distance"],
|
||||
score=float(result["vector_distance"]),
|
||||
payload={
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package-manager-strict-version=false
|
||||
approve-builds=esbuild
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
|
||||
|
||||
## [0.3.1] - 2026-03-12
|
||||
|
||||
### Fixed
|
||||
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
|
||||
|
||||
## [0.3.0] - 2026-03-10
|
||||
|
||||
### Fixed
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
|
||||
## How it works
|
||||
|
||||
<p align="center">
|
||||
<img src="../docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
||||
@@ -180,11 +180,11 @@ Works with zero extra config. The `oss` block lets you swap out any component:
|
||||
| Key | Type | Default | |
|
||||
|-----|------|---------|---|
|
||||
| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` |
|
||||
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) |
|
||||
| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
|
||||
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
|
||||
|
||||
|
||||
+34
-6
@@ -41,6 +41,7 @@ type Mem0Config = {
|
||||
vectorStore?: { provider: string; config: Record<string, unknown> };
|
||||
llm?: { provider: string; config: Record<string, unknown> };
|
||||
historyDbPath?: string;
|
||||
disableHistory?: boolean;
|
||||
};
|
||||
// Shared
|
||||
userId: string;
|
||||
@@ -132,13 +133,16 @@ class PlatformProvider implements Mem0Provider {
|
||||
private async ensureClient(): Promise<void> {
|
||||
if (this.client) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this._init();
|
||||
this.initPromise = this._init().catch((err) => {
|
||||
this.initPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const { default: MemoryClient } = await import("mem0ai");
|
||||
const opts: Record<string, string> = { apiKey: this.apiKey };
|
||||
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
|
||||
if (this.orgId) opts.org_id = this.orgId;
|
||||
if (this.projectId) opts.project_id = this.projectId;
|
||||
this.client = new MemoryClient(opts);
|
||||
@@ -225,7 +229,10 @@ class OSSProvider implements Mem0Provider {
|
||||
private async ensureMemory(): Promise<void> {
|
||||
if (this.memory) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this._init();
|
||||
this.initPromise = this._init().catch((err) => {
|
||||
this.initPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
@@ -246,9 +253,30 @@ class OSSProvider implements Mem0Provider {
|
||||
config.historyDbPath = dbPath;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.disableHistory) {
|
||||
config.disableHistory = true;
|
||||
}
|
||||
|
||||
if (this.customPrompt) config.customPrompt = this.customPrompt;
|
||||
|
||||
this.memory = new Memory(config);
|
||||
try {
|
||||
this.memory = new Memory(config);
|
||||
} catch (err) {
|
||||
// If initialization fails (e.g. native SQLite binding resolution under
|
||||
// jiti), retry with history disabled — the history DB is the most common
|
||||
// source of native-binding failures and is not required for core
|
||||
// memory operations.
|
||||
if (!config.disableHistory) {
|
||||
console.warn(
|
||||
"[mem0] Memory initialization failed, retrying with history disabled:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
config.disableHistory = true;
|
||||
this.memory = new Memory(config);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async add(
|
||||
@@ -521,7 +549,7 @@ function assertAllowedKeys(
|
||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
const mem0ConfigSchema = {
|
||||
export const mem0ConfigSchema = {
|
||||
parse(value: unknown): Mem0Config {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("openclaw-mem0 config required");
|
||||
@@ -587,7 +615,7 @@ const mem0ConfigSchema = {
|
||||
// Provider Factory
|
||||
// ============================================================================
|
||||
|
||||
function createProvider(
|
||||
export function createProvider(
|
||||
cfg: Mem0Config,
|
||||
api: OpenClawPluginApi,
|
||||
): Mem0Provider {
|
||||
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
declare module "openclaw/plugin-sdk" {
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: {
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
debug(msg: string): void;
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(
|
||||
definition: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void;
|
||||
on(
|
||||
event: string,
|
||||
handler: (event: any, ctx: any) => any,
|
||||
): void;
|
||||
registerCli(
|
||||
handler: (context: { program: any }) => void,
|
||||
options?: Record<string, unknown>,
|
||||
): void;
|
||||
registerService(service: {
|
||||
id: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
@@ -11,7 +11,20 @@
|
||||
"mem0",
|
||||
"long-term-memory"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,10 +33,14 @@
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
"./dist/index.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- protobufjs
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Tests for SQLite resilience fixes:
|
||||
* 1. disableHistory config passthrough
|
||||
* 2. initPromise poisoning fix (retry after failure)
|
||||
* 3. Graceful SQLite fallback in OSSProvider
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mem0ConfigSchema, createProvider } from "./index.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config: disableHistory passthrough
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema — disableHistory", () => {
|
||||
const baseConfig = {
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
|
||||
},
|
||||
};
|
||||
|
||||
it("preserves oss.disableHistory: true through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves oss.disableHistory: false through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: false },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(false);
|
||||
});
|
||||
|
||||
it("omits disableHistory when not provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse(baseConfig);
|
||||
expect(cfg.oss?.disableHistory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reject unknown keys inside oss object", () => {
|
||||
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
|
||||
expect(() =>
|
||||
mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. OSSProvider: disableHistory flows to Memory constructor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — disableHistory passthrough to Memory", () => {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
let memoryCallCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfig = undefined;
|
||||
memoryCallCount = 0;
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
memoryCallCount++;
|
||||
capturedConfig = { ...config };
|
||||
}
|
||||
async add() { return { results: [] }; }
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("passes disableHistory: true to Memory when configured", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Trigger lazy init by calling search
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch { /* provider may fail on mock, that's ok */ }
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set disableHistory when not configured", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch { }
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
|
||||
}
|
||||
// Second+ call succeeds
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call: _init throws, but initPromise is cleared so retry is possible
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("SQLITE_CANTOPEN");
|
||||
|
||||
// Second call: should retry _init (not return cached rejection)
|
||||
// callCount === 1 threw, so callCount === 2 should succeed
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OSSProvider: graceful fallback disables history on init failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — graceful SQLite fallback", () => {
|
||||
let capturedConfigs: Record<string, unknown>[];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfigs = [];
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
capturedConfigs.push({ ...config });
|
||||
if (!config.disableHistory) {
|
||||
throw new Error("Could not locate the bindings file");
|
||||
}
|
||||
// Succeeds when disableHistory is true
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries with disableHistory: true when initial construction fails", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should succeed — first attempt fails, fallback with disableHistory succeeds
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
|
||||
// Memory constructor was called twice
|
||||
expect(capturedConfigs).toHaveLength(2);
|
||||
expect(capturedConfigs[0].disableHistory).toBeFalsy();
|
||||
expect(capturedConfigs[1].disableHistory).toBe(true);
|
||||
|
||||
// Warning was logged
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[mem0] Memory initialization failed"),
|
||||
expect.stringContaining("bindings file"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not retry when disableHistory is already true", async () => {
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
// Fail even with disableHistory (e.g. vector store issue)
|
||||
throw new Error("vector store connection refused");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should throw — no fallback possible when disableHistory was already set
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("vector store connection refused");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. PlatformProvider — initPromise retry after failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("PlatformProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
|
||||
vi.doMock("mem0ai", () => ({
|
||||
default: class MockMemoryClient {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("Network timeout");
|
||||
}
|
||||
}
|
||||
async search() { return []; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "platform",
|
||||
apiKey: "test-api-key",
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call fails
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("Network timeout");
|
||||
|
||||
// Second call should retry (not return cached rejection)
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
});
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "founders@mem0.ai" }
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"posthog>=3.5.0",
|
||||
"pytz>=2024.1",
|
||||
"sqlalchemy>=2.0.31",
|
||||
"protobuf>=5.29.0,<6.0.0",
|
||||
"protobuf>=5.29.6,<7.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but not
|
||||
limited to compiled object code, generated documentation, and
|
||||
conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Mem0.ai
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Mem0 Skill for Claude
|
||||
|
||||
Add persistent memory to any AI application in minutes using [Mem0 Platform](https://app.mem0.ai).
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
When installed, Claude can:
|
||||
|
||||
- **Set up Mem0** in your Python or TypeScript project
|
||||
- **Integrate memory** into your existing AI app (LangChain, CrewAI, Vercel AI, OpenAI Agents, LangGraph, LlamaIndex, etc.)
|
||||
- **Generate working code** using real API references and tested patterns
|
||||
- **Search live docs** on demand for the latest Mem0 documentation
|
||||
|
||||
## Installation
|
||||
|
||||
### CLI (Claude Code, OpenCode, OpenClaw, or any tool that supports skills)
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
```
|
||||
|
||||
### Claude.ai
|
||||
|
||||
1. Download this `skills/mem0` folder as a ZIP
|
||||
2. Go to **Settings > Capabilities > Skills**
|
||||
3. Click **Upload skill** and select the ZIP
|
||||
|
||||
### Claude API (Skills API)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.anthropic.com/v1/skills \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "mem0", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0"}'
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||
- Python 3.10+ or Node.js 18+
|
||||
- Set the environment variable:
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
After installing, just ask Claude:
|
||||
|
||||
- "Set up mem0 in my project"
|
||||
- "Add memory to my chatbot"
|
||||
- "Help me search user memories with filters"
|
||||
- "Integrate mem0 with my LangChain app"
|
||||
- "Add graph memory to track entity relationships"
|
||||
|
||||
## What's Inside
|
||||
|
||||
```text
|
||||
skills/mem0/
|
||||
├── SKILL.md # Skill definition and instructions
|
||||
├── README.md # This file
|
||||
├── LICENSE # Apache-2.0
|
||||
├── scripts/
|
||||
│ └── mem0_doc_search.py # Search live Mem0 docs on demand
|
||||
└── references/ # Documentation (loaded on demand)
|
||||
├── quickstart.md # Full quickstart (Python, TS, cURL)
|
||||
├── sdk-guide.md # All SDK methods (Python + TypeScript)
|
||||
├── api-reference.md # REST endpoints, filters, memory object
|
||||
├── architecture.md # Processing pipeline, lifecycle, scoping, performance
|
||||
├── features.md # Retrieval, graph, categories, MCP, webhooks, multimodal
|
||||
├── integration-patterns.md # LangChain, CrewAI, Vercel AI, LangGraph, LlamaIndex, etc.
|
||||
└── use-cases.md # 7 real-world patterns with Python + TypeScript code
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Mem0 Platform Dashboard](https://app.mem0.ai)
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
|
||||
- [API Reference](https://docs.mem0.ai/api-reference)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: mem0
|
||||
description: >
|
||||
Integrate Mem0 Platform into AI applications for persistent memory, personalization, and semantic search.
|
||||
Use this skill when the user mentions "mem0", "memory layer", "remember user preferences",
|
||||
"persistent context", "personalization", or needs to add long-term memory to chatbots, agents,
|
||||
or AI apps. Covers Python and TypeScript SDKs, framework integrations (LangChain, CrewAI,
|
||||
Vercel AI SDK, OpenAI Agents SDK, Pipecat), and the full Platform API. Use even when the user
|
||||
doesn't explicitly say "mem0" but describes needing conversation memory, user context retention,
|
||||
or knowledge retrieval across sessions.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: mem0ai
|
||||
version: "1.0.0"
|
||||
category: ai-memory
|
||||
tags: "memory, personalization, ai, python, typescript, vector-search"
|
||||
compatibility: Requires Python 3.10+ or Node.js 18+, pip install mem0ai or npm install mem0ai, MEM0_API_KEY env var, and internet access to api.mem0.ai
|
||||
---
|
||||
|
||||
# Mem0 Platform Integration
|
||||
|
||||
Mem0 is a managed memory layer for AI applications. It stores, retrieves, and manages user memories via API — no infrastructure to deploy.
|
||||
|
||||
## Step 1: Install and authenticate
|
||||
|
||||
**Python:**
|
||||
```bash
|
||||
pip install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
**TypeScript/JavaScript:**
|
||||
```bash
|
||||
npm install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
Get an API key at: https://app.mem0.ai/dashboard/api-keys
|
||||
|
||||
## Step 2: Initialize the client
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
client = MemoryClient(api_key="m0-xxx")
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||
```
|
||||
|
||||
For async Python, use `AsyncMemoryClient`.
|
||||
|
||||
## Step 3: Core operations
|
||||
|
||||
Every Mem0 integration follows the same pattern: **retrieve → generate → store**.
|
||||
|
||||
### Add memories
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||
]
|
||||
client.add(messages, user_id="alice")
|
||||
```
|
||||
|
||||
### Search memories
|
||||
```python
|
||||
results = client.search("dietary preferences", user_id="alice")
|
||||
for mem in results.get("results", []):
|
||||
print(mem["memory"])
|
||||
```
|
||||
|
||||
### Get all memories
|
||||
```python
|
||||
all_memories = client.get_all(user_id="alice")
|
||||
```
|
||||
|
||||
### Update a memory
|
||||
```python
|
||||
client.update("memory-uuid", text="Updated: vegetarian, nut allergy, prefers organic")
|
||||
```
|
||||
|
||||
### Delete a memory
|
||||
```python
|
||||
client.delete("memory-uuid")
|
||||
client.delete_all(user_id="alice") # delete all for a user
|
||||
```
|
||||
|
||||
## Common integration pattern
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai = OpenAI()
|
||||
|
||||
def chat(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve relevant memories
|
||||
memories = mem0.search(user_input, user_id=user_id)
|
||||
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||
|
||||
# 2. Generate response with memory context
|
||||
response = openai.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": f"User context:\n{context}"},
|
||||
{"role": "user", "content": user_input},
|
||||
]
|
||||
)
|
||||
reply = response.choices[0].message.content
|
||||
|
||||
# 3. Store interaction for future context
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": reply}],
|
||||
user_id=user_id
|
||||
)
|
||||
return reply
|
||||
```
|
||||
|
||||
## Common edge cases
|
||||
|
||||
- **Search returns empty:** Memories process asynchronously. Wait 2-3s after `add()` before searching. Also verify `user_id` matches exactly (case-sensitive).
|
||||
- **AND filter with user_id + agent_id returns empty:** Entities are stored separately. Use `OR` instead, or query separately.
|
||||
- **Duplicate memories:** Don't mix `infer=True` (default) and `infer=False` for the same data. Stick to one mode.
|
||||
- **Wrong import:** Always use `from mem0 import MemoryClient` (or `AsyncMemoryClient` for async). Do not use `from mem0 import Memory`.
|
||||
- **Immutable memories:** Cannot be updated or deleted once created. Use `client.history(memory_id)` to track changes over time.
|
||||
|
||||
## Live documentation search
|
||||
|
||||
For the latest docs beyond what's in the references, use the doc search tool:
|
||||
|
||||
```bash
|
||||
python scripts/mem0_doc_search.py --query "topic"
|
||||
python scripts/mem0_doc_search.py --page "/platform/features/graph-memory"
|
||||
python scripts/mem0_doc_search.py --index
|
||||
```
|
||||
|
||||
No API key needed — searches docs.mem0.ai directly.
|
||||
|
||||
## References
|
||||
|
||||
Load these on demand for deeper detail:
|
||||
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Quickstart (Python, TS, cURL) | [references/quickstart.md](references/quickstart.md) |
|
||||
| SDK guide (all methods, both languages) | [references/sdk-guide.md](references/sdk-guide.md) |
|
||||
| API reference (endpoints, filters, object schema) | [references/api-reference.md](references/api-reference.md) |
|
||||
| Architecture (pipeline, lifecycle, scoping, performance) | [references/architecture.md](references/architecture.md) |
|
||||
| Platform features (retrieval, graph, categories, MCP, etc.) | [references/features.md](references/features.md) |
|
||||
| Framework integrations (LangChain, CrewAI, Vercel AI, etc.) | [references/integration-patterns.md](references/integration-patterns.md) |
|
||||
| Use cases & examples (real-world patterns with code) | [references/use-cases.md](references/use-cases.md) |
|
||||
@@ -0,0 +1,140 @@
|
||||
# Mem0 Platform API Reference
|
||||
|
||||
REST API endpoints for the Mem0 Platform. Base URL: `https://api.mem0.ai`
|
||||
|
||||
All endpoints require: `Authorization: Token <MEM0_API_KEY>`
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Operation | Method | URL |
|
||||
|-----------|--------|-----|
|
||||
| Add Memories | `POST` | `/v1/memories/` |
|
||||
| Search Memories | `POST` | `/v2/memories/search/` |
|
||||
| Get All Memories | `POST` | `/v2/memories/` |
|
||||
| Get Single Memory | `GET` | `/v1/memories/{memory_id}/` |
|
||||
| Update Memory | `PUT` | `/v1/memories/{memory_id}/` |
|
||||
| Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` |
|
||||
|
||||
## Memory Object Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string (UUID) | Unique memory identifier |
|
||||
| `memory` | string | Text content of the memory |
|
||||
| `user_id` | string | Associated user |
|
||||
| `agent_id` | string (nullable) | Agent identifier |
|
||||
| `app_id` | string (nullable) | Application identifier |
|
||||
| `run_id` | string (nullable) | Run/session identifier |
|
||||
| `metadata` | object | Custom key-value pairs |
|
||||
| `categories` | array of strings | Auto-assigned category tags |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `expiration_date` | datetime (nullable) | Auto-expiry date |
|
||||
| `hash` | string | Content hash |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
|
||||
Search results additionally include `score` (relevance metric).
|
||||
|
||||
## Scoping Identifiers
|
||||
|
||||
Memories can be scoped to different levels:
|
||||
|
||||
| Scope | Parameter | Use Case |
|
||||
|-------|-----------|----------|
|
||||
| User | `user_id` | Per-user memory isolation |
|
||||
| Agent | `agent_id` | Per-agent memory partitioning |
|
||||
| Application | `app_id` | Cross-agent app-level memory |
|
||||
| Run/Session | `run_id` | Session-scoped temporary memory |
|
||||
|
||||
**Critical:** Combining `user_id` and `agent_id` in a single AND filter yields empty results. Entities are stored separately. Use `OR` logic or separate queries.
|
||||
|
||||
## Processing Model
|
||||
|
||||
- Memories are processed **asynchronously by default** (`async_mode=true`)
|
||||
- Add responses return queued events (`ADD`, `UPDATE`, `DELETE`) for tracking
|
||||
- Set `async_mode=false` for synchronous processing when needed
|
||||
- Graph metadata is processed asynchronously -- use `get_all()` for complete graph data
|
||||
|
||||
## Filter System
|
||||
|
||||
Filters use nested JSON with a logical operator at the root:
|
||||
|
||||
```json
|
||||
{
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"categories": {"contains": "finance"}},
|
||||
{"created_at": {"gte": "2024-01-01"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Root must be `AND`, `OR`, or `NOT`. Simple shorthand `{"user_id": "alice"}` also works.
|
||||
|
||||
### Supported Operators
|
||||
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `eq` | Equal to (default) |
|
||||
| `ne` | Not equal to |
|
||||
| `in` | Matches any value in array |
|
||||
| `gt`, `gte` | Greater than / greater than or equal |
|
||||
| `lt`, `lte` | Less than / less than or equal |
|
||||
| `contains` | Case-sensitive containment |
|
||||
| `icontains` | Case-insensitive containment |
|
||||
| `*` | Wildcard -- matches any non-null value |
|
||||
|
||||
### Filterable Fields
|
||||
|
||||
| Field | Valid Operators |
|
||||
|-------|-----------------|
|
||||
| `user_id`, `agent_id`, `app_id`, `run_id` | `eq`, `ne`, `in`, `*` |
|
||||
| `created_at`, `updated_at`, `timestamp` | `gt`, `gte`, `lt`, `lte`, `eq`, `ne` |
|
||||
| `categories` | `eq`, `ne`, `in`, `contains` |
|
||||
| `metadata` | `eq`, `ne`, `contains` (top-level keys only) |
|
||||
| `keywords` | `contains`, `icontains` |
|
||||
| `memory_ids` | `in` |
|
||||
|
||||
### Filter Constraints
|
||||
|
||||
1. **Entity scope partitioning:** `user_id` AND `agent_id` in one `AND` block yields empty results.
|
||||
2. **Metadata limitations:** Only top-level keys. Only `eq`, `contains`, `ne`. No `in` or `gt`.
|
||||
3. **Operator syntax:** Use `gte`, `lt`, `ne`. SQL-style (`>=`, `!=`) rejected.
|
||||
4. **Entity filter required for get-all:** At least one of `user_id`, `agent_id`, `app_id`, or `run_id`.
|
||||
5. **Wildcard excludes null:** `*` matches only non-null values.
|
||||
6. **Date format:** ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`). Timezone-naive defaults to UTC.
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Add Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "mem_01JF8ZS4Y0R0SPM13R5R6H32CJ",
|
||||
"event": "ADD",
|
||||
"data": { "memory": "The user moved to Austin in 2025." }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Event types: `ADD`, `UPDATE`, `DELETE`. A single add can trigger multiple events.
|
||||
|
||||
### Search Response
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "ea925981-...",
|
||||
"memory": "Is a vegetarian and allergic to nuts.",
|
||||
"user_id": "user123",
|
||||
"categories": ["food", "health"],
|
||||
"score": 0.89,
|
||||
"created_at": "2024-07-26T10:29:36.630547-07:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With `enable_graph=true`, includes additional `relations` array with entity relationships.
|
||||
@@ -0,0 +1,386 @@
|
||||
# Mem0 Platform Architecture
|
||||
|
||||
How Mem0 processes, stores, and retrieves memories under the hood.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Concept](#core-concept)
|
||||
- [Memory Processing Pipeline](#memory-processing-pipeline)
|
||||
- [Retrieval Pipeline](#retrieval-pipeline)
|
||||
- [Memory Lifecycle](#memory-lifecycle)
|
||||
- [Memory Object Structure](#memory-object-structure)
|
||||
- [Scoping & Multi-Tenancy](#scoping--multi-tenancy)
|
||||
- [Memory Layers](#memory-layers)
|
||||
- [Performance Characteristics](#performance-characteristics)
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
Mem0 is a managed memory layer that sits between your AI application and users. Every integration follows the same 3-step loop:
|
||||
|
||||
```
|
||||
User Input → Retrieve relevant memories → Enrich LLM prompt → Generate response → Store new memories
|
||||
```
|
||||
|
||||
Mem0 handles the complexity of extraction, deduplication, conflict resolution, and semantic retrieval so your application only needs to call `search()` and `add()`.
|
||||
|
||||
**Dual storage architecture:**
|
||||
- **Vector store**: Embeddings for semantic similarity search
|
||||
- **Graph store** (optional): Entity nodes and relationship edges for structured knowledge
|
||||
|
||||
---
|
||||
|
||||
## Memory Processing Pipeline
|
||||
|
||||
### What happens when you call `client.add()`
|
||||
|
||||
```
|
||||
Messages In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. EXTRACTION │ LLM analyzes messages, extracts key facts
|
||||
│ (infer=True) │ If infer=False, stores raw text as-is
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. CONFLICT │ Checks existing memories for duplicates
|
||||
│ RESOLUTION │ Latest truth wins (newer overrides older)
|
||||
│ │ Only runs when infer=True
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 3. STORAGE │ Generates embeddings → vector store
|
||||
│ │ Optional: entity extraction → graph store
|
||||
│ │ Indexes metadata, categories, timestamps
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Memory Object
|
||||
(id, memory, categories, structured_attributes)
|
||||
```
|
||||
|
||||
### Processing modes
|
||||
|
||||
**Async (default, `async_mode=True`):**
|
||||
- API returns immediately: `{"status": "PENDING", "event_id": "..."}`
|
||||
- Processing happens in background
|
||||
- Use webhooks for completion notifications
|
||||
- Best for: high-throughput, non-blocking workflows
|
||||
|
||||
**Sync (`async_mode=False`):**
|
||||
- API waits for full processing
|
||||
- Returns complete memory object with `id`, `event`, `memory`
|
||||
- Best for: real-time access immediately after add
|
||||
|
||||
### Extraction modes
|
||||
|
||||
**Inferred (`infer=True`, default):**
|
||||
- LLM extracts structured facts from conversation
|
||||
- Conflict resolution deduplicates and resolves contradictions
|
||||
- Best for: natural conversation → memory
|
||||
|
||||
**Raw (`infer=False`):**
|
||||
- Stores text exactly as provided, no LLM processing
|
||||
- Skips conflict resolution — same fact can be stored twice
|
||||
- Only `user` role messages are stored; `assistant` messages ignored
|
||||
- Best for: bulk imports, pre-structured data, migrations
|
||||
|
||||
**Warning:** Don't mix `infer=True` and `infer=False` for the same data — the same fact will be stored twice.
|
||||
|
||||
---
|
||||
|
||||
## Retrieval Pipeline
|
||||
|
||||
### What happens when you call `client.search()`
|
||||
|
||||
```
|
||||
Query In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. QUERY EMBEDDING │ Convert query to vector representation
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. VECTOR SEARCH │ Cosine similarity across stored embeddings
|
||||
│ │ Scoped by filters (user_id, agent_id, etc.)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (optional enhancements)
|
||||
┌─────────────────────┐
|
||||
│ 3a. KEYWORD SEARCH │ Expands results with specific terms (+10ms)
|
||||
│ 3b. RERANKING │ Deep semantic reordering (+150-200ms)
|
||||
│ 3c. FILTER MEMORIES │ Precision filtering, removes low-relevance (+200-300ms)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (if enable_graph=True)
|
||||
┌─────────────────────┐
|
||||
│ 4. GRAPH LOOKUP │ Finds entity relationships
|
||||
│ │ Appends relations WITHOUT reranking vector results
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Results + Relations
|
||||
```
|
||||
|
||||
### Retrieval enhancement combinations
|
||||
|
||||
| Configuration | Latency | Best for |
|
||||
|--------------|---------|----------|
|
||||
| Base search only | ~100ms | Simple lookups |
|
||||
| `keyword_search=True` | ~110ms | Entity-heavy queries, broad coverage |
|
||||
| `rerank=True` | ~250-300ms | User-facing results, top-N precision |
|
||||
| `keyword_search=True` + `rerank=True` | ~310ms | Balanced (recommended for most apps) |
|
||||
| `rerank=True` + `filter_memories=True` | ~400-500ms | Safety-critical, production systems |
|
||||
|
||||
### Implicit null scoping
|
||||
|
||||
When you search with `user_id="alice"` only, Mem0 returns memories where `agent_id`, `app_id`, and `run_id` are all null. This prevents cross-scope leakage by default.
|
||||
|
||||
To include memories with non-null fields, use explicit filters:
|
||||
```python
|
||||
# Gets memories for alice regardless of agent/app/run
|
||||
filters={"OR": [{"user_id": "alice"}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Lifecycle
|
||||
|
||||
```
|
||||
CREATE ──→ ACTIVE ──→ UPDATE ──→ ACTIVE
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ EXPIRED EXPIRED
|
||||
│ (still stored, (still stored,
|
||||
│ not retrieved) not retrieved)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
DELETE DELETE DELETE
|
||||
(permanent)
|
||||
```
|
||||
|
||||
### Creation
|
||||
- Triggered by `client.add(messages, user_id="...")`
|
||||
- Messages processed through extraction → conflict resolution → storage
|
||||
- Gets unique UUID, `created_at` timestamp
|
||||
- Optional: custom `timestamp`, `expiration_date`, `metadata`, `immutable`
|
||||
|
||||
### Updates
|
||||
- `client.update(memory_id, text="...")` replaces text and reindexes
|
||||
- `client.batch_update([...])` for up to 1000 memories at once
|
||||
- Immutable memories (`immutable=True`) cannot be updated — must delete and re-add
|
||||
|
||||
### Deduplication
|
||||
- Automatic during `add()` with `infer=True`
|
||||
- Conflict resolution merges duplicate facts
|
||||
- Latest truth wins when contradictions detected
|
||||
- Prevents memory bloat from repeated information
|
||||
|
||||
### Expiration
|
||||
- Optional `expiration_date` parameter (ISO 8601 or `YYYY-MM-DD`)
|
||||
- After expiration: memory NOT returned in searches but remains in storage
|
||||
- Useful for time-sensitive info (events, temporary preferences, session state)
|
||||
|
||||
### Deletion
|
||||
- Single: `client.delete(memory_id)` — permanent, no recovery
|
||||
- Batch: `client.batch_delete([memory_ids])` — up to 1000
|
||||
- Bulk: `client.delete_all(user_id="alice")` — all memories for entity
|
||||
- `delete_all()` without filters raises error to prevent accidental data loss
|
||||
|
||||
### History tracking
|
||||
- `client.history(memory_id)` returns version timeline
|
||||
- Shows all changes: `{previous_value, new_value, action, timestamps}`
|
||||
- Useful for audit trails and debugging
|
||||
|
||||
---
|
||||
|
||||
## Memory Object Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"memory": "Extracted memory text",
|
||||
"user_id": "user-identifier",
|
||||
"agent_id": null,
|
||||
"app_id": null,
|
||||
"run_id": null,
|
||||
"metadata": { "source": "chat", "priority": "high" },
|
||||
"categories": ["health", "preferences"],
|
||||
"created_at": "2025-03-12T12:34:56Z",
|
||||
"updated_at": "2025-03-12T12:34:56Z",
|
||||
"expiration_date": null,
|
||||
"immutable": false,
|
||||
"structured_attributes": {
|
||||
"day": 12, "month": 3, "year": 2025,
|
||||
"hour": 12, "minute": 34,
|
||||
"day_of_week": "wednesday",
|
||||
"is_weekend": false,
|
||||
"quarter": 1, "week_of_year": 11
|
||||
},
|
||||
"score": 0.85
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Unique identifier, used for update/delete |
|
||||
| `memory` | string | Extracted or stored text content |
|
||||
| `user_id` | string | Primary entity scope |
|
||||
| `agent_id` | string | Agent scope |
|
||||
| `app_id` | string | Application scope |
|
||||
| `run_id` | string | Session/run scope |
|
||||
| `metadata` | object | Custom key-value pairs for filtering |
|
||||
| `categories` | array | Auto-assigned or custom category tags |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
| `expiration_date` | datetime | Auto-expiry date (stops retrieval, data persists) |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `structured_attributes` | object | Temporal breakdown for time-based queries |
|
||||
| `score` | float | Semantic similarity (search results only, 0-1) |
|
||||
|
||||
---
|
||||
|
||||
## Scoping & Multi-Tenancy
|
||||
|
||||
Mem0 separates memories across four dimensions to prevent data mixing:
|
||||
|
||||
| Dimension | Field | Purpose | Example |
|
||||
|-----------|-------|---------|---------|
|
||||
| User | `user_id` | Persistent persona or account | `"customer_6412"` |
|
||||
| Agent | `agent_id` | Distinct agent or tool | `"meal_planner"` |
|
||||
| App | `app_id` | Product surface or deployment | `"ios_retail_app"` |
|
||||
| Session | `run_id` | Short-lived flow or thread | `"ticket-9241"` |
|
||||
|
||||
### Storage model
|
||||
|
||||
Each entity combination creates separate records. A memory with `user_id="alice"` is stored separately from one with `user_id="alice"` + `agent_id="bot"`.
|
||||
|
||||
### Critical: cross-entity queries
|
||||
|
||||
```python
|
||||
# This returns NOTHING — user and agent memories are stored separately
|
||||
filters={"AND": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||
|
||||
# Use OR to query multiple scopes
|
||||
filters={"OR": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||
|
||||
# Use wildcard to include any non-null value
|
||||
filters={"AND": [{"user_id": "*"}]} # All users (excludes null)
|
||||
```
|
||||
|
||||
### Recommended scoping patterns
|
||||
|
||||
```python
|
||||
# User-level: persistent preferences
|
||||
client.add(messages, user_id="alice")
|
||||
|
||||
# Session-level: temporary context
|
||||
client.add(messages, user_id="alice", run_id="session_123")
|
||||
# Clean up when done: client.delete_all(run_id="session_123")
|
||||
|
||||
# Agent-level: agent-specific knowledge
|
||||
client.add(messages, agent_id="support_bot", app_id="helpdesk")
|
||||
|
||||
# Multi-tenant: full isolation
|
||||
client.add(messages, user_id="alice", agent_id="bot", app_id="acme_corp", run_id="ticket_42")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Layers
|
||||
|
||||
Mem0 supports three layers of memory, from shortest to longest lived:
|
||||
|
||||
### Conversation memory
|
||||
- In-flight messages within a single turn
|
||||
- Tool calls, chain-of-thought reasoning
|
||||
- **Lifetime:** Single response — lost after turn finishes
|
||||
- **Managed by:** Your application, not Mem0
|
||||
|
||||
### Session memory
|
||||
- Short-lived facts for current task or channel
|
||||
- Multi-step flows (onboarding, debugging, support tickets)
|
||||
- **Lifetime:** Minutes to hours
|
||||
- **Managed by:** Mem0 via `run_id` parameter
|
||||
- Clean up with `client.delete_all(run_id="session_id")`
|
||||
|
||||
### User memory
|
||||
- Long-lived knowledge tied to a person or account
|
||||
- Personal preferences, account state, compliance details
|
||||
- **Lifetime:** Weeks to forever
|
||||
- **Managed by:** Mem0 via `user_id` parameter
|
||||
- Persists across all sessions and interactions
|
||||
|
||||
### How layering works in practice
|
||||
|
||||
```python
|
||||
def chat(user_input: str, user_id: str, session_id: str) -> str:
|
||||
# 1. Retrieve user memories (long-term preferences)
|
||||
user_mems = mem0.search(user_input, user_id=user_id)
|
||||
|
||||
# 2. Retrieve session memories (current task context)
|
||||
session_mems = mem0.search(user_input, filters={
|
||||
"AND": [{"user_id": user_id}, {"run_id": session_id}]
|
||||
})
|
||||
|
||||
# 3. Combine both layers for LLM context
|
||||
context = format_memories(user_mems) + format_memories(session_mems)
|
||||
|
||||
# 4. Generate response
|
||||
response = llm.generate(context=context, input=user_input)
|
||||
|
||||
# 5. Store in session scope (temporary) + user scope (persistent)
|
||||
messages = [{"role": "user", "content": user_input}, {"role": "assistant", "content": response}]
|
||||
mem0.add(messages, user_id=user_id, run_id=session_id)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Latency
|
||||
|
||||
| Operation | Typical Latency |
|
||||
|-----------|----------------|
|
||||
| Base vector search | ~100ms |
|
||||
| + keyword_search | +10ms |
|
||||
| + reranking | +150-200ms |
|
||||
| + filter_memories | +200-300ms |
|
||||
| Add (async, default) | < 50ms response, background processing |
|
||||
| Add (sync) | 500ms-2s depending on extraction complexity |
|
||||
| Graph operations | Slight overhead for large stores |
|
||||
|
||||
### Processing
|
||||
|
||||
- **Async mode (default):** Returns immediately, processes in background
|
||||
- **Sync mode:** Waits for full extraction + storage pipeline
|
||||
- **Batch operations:** Up to 1000 memories per batch_update/batch_delete
|
||||
- **Webhooks:** Real-time notifications when async processing completes
|
||||
|
||||
### Scoping strategy for performance
|
||||
|
||||
- Use `user_id` for all user-facing queries (most common, fastest)
|
||||
- Add `run_id` for session isolation (narrows search space)
|
||||
- Avoid wildcard `"*"` filters on large datasets (scans all non-null records)
|
||||
- Use `top_k` to limit result count when you only need a few memories
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Alternatives
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **Raw vector DB** | Fast, full control | No extraction, no dedup, no conflict resolution |
|
||||
| **In-memory chat history** | Zero latency | Lost on restart, no cross-session, grows unbounded |
|
||||
| **RAG over documents** | Good for static knowledge | No personalization, no memory updates |
|
||||
| **Mem0 Platform** | Managed extraction + dedup + graph + scoping | External dependency, async processing delay |
|
||||
|
||||
Mem0 combines the best of vector search (semantic retrieval) with automatic extraction (LLM-powered), conflict resolution (deduplication), and structured scoping (multi-tenancy) — in a single managed API.
|
||||
@@ -0,0 +1,496 @@
|
||||
# Platform Features -- Mem0 Platform
|
||||
|
||||
Additional platform capabilities beyond core CRUD operations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Advanced Retrieval](#advanced-retrieval)
|
||||
- [Graph Memory](#graph-memory)
|
||||
- [Custom Categories](#custom-categories)
|
||||
- [Custom Instructions](#custom-instructions)
|
||||
- [Criteria Retrieval](#criteria-retrieval)
|
||||
- [Feedback Mechanism](#feedback-mechanism)
|
||||
- [Memory Export](#memory-export)
|
||||
- [Group Chat](#group-chat)
|
||||
- [MCP Integration](#mcp-integration)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Multimodal Support](#multimodal-support)
|
||||
|
||||
## Advanced Retrieval
|
||||
|
||||
Three enhancement options for tuning search precision, recall, and latency.
|
||||
|
||||
### Keyword Search (`keyword_search=True`)
|
||||
|
||||
Expands results to include memories with specific terms, names, and technical keywords.
|
||||
|
||||
- Latency: +10ms
|
||||
- Recall: Significantly increased
|
||||
- Best for: entity-heavy queries, comprehensive coverage
|
||||
|
||||
### Reranking (`rerank=True`)
|
||||
|
||||
Deep semantic reordering of results — most relevant first.
|
||||
|
||||
- Latency: +150-200ms
|
||||
- Accuracy: Significantly improved
|
||||
- Best for: user-facing results, top-N precision
|
||||
|
||||
### Filter Memories (`filter_memories=True`)
|
||||
|
||||
Precision filtering — removes low-relevance results entirely.
|
||||
|
||||
- Latency: +200-300ms
|
||||
- Precision: Maximized
|
||||
- Best for: safety-critical applications, production systems
|
||||
|
||||
### Recommended Combinations
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
# Fast & broad
|
||||
results = client.search(query, keyword_search=True, user_id="user123")
|
||||
|
||||
# Balanced (recommended for most apps)
|
||||
results = client.search(query, keyword_search=True, rerank=True, user_id="user123")
|
||||
|
||||
# High precision (critical apps)
|
||||
results = client.search(query, rerank=True, filter_memories=True, user_id="user123")
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const results = await client.search(query, {
|
||||
user_id: 'user123',
|
||||
keyword_search: true,
|
||||
rerank: true,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graph Memory
|
||||
|
||||
Entity-level knowledge graph that creates relationships between memories.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Extraction**: LLM analyzes conversation and identifies entities and relationships
|
||||
2. **Storage**: Embeddings go to vector store; entity nodes and edges go to graph store
|
||||
3. **Retrieval**: Vector search returns semantic matches; graph relations are appended to results
|
||||
|
||||
Graph relations **augment** vector results without reordering them. Vector similarity always determines hit sequence.
|
||||
|
||||
### Enabling Graph Memory
|
||||
|
||||
**Per request:**
|
||||
```python
|
||||
client.add(messages, user_id="alice", enable_graph=True)
|
||||
client.search("query", user_id="alice", enable_graph=True)
|
||||
client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||
```
|
||||
|
||||
**Project-level (default for all operations):**
|
||||
```python
|
||||
client.project.update(enable_graph=True)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ enable_graph: true });
|
||||
```
|
||||
|
||||
### Relation Structure
|
||||
|
||||
Each relation in the response contains:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `source` | string | Source entity name |
|
||||
| `source_type` | string | Source entity type (e.g., "Person") |
|
||||
| `relationship` | string | Relationship label (e.g., "lives_in") |
|
||||
| `target` | string | Target entity name |
|
||||
| `target_type` | string | Target entity type (e.g., "City") |
|
||||
| `score` | number | Confidence score |
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"relations": [
|
||||
{
|
||||
"source": "Joseph",
|
||||
"source_type": "Person",
|
||||
"relationship": "lives_in",
|
||||
"target": "Seattle",
|
||||
"target_type": "City",
|
||||
"score": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- Graph Memory adds processing time; see docs for current plan availability
|
||||
- Works optimally with rich conversation histories containing entity relationships
|
||||
- Best suited for long-running assistants tracking evolving information
|
||||
- Graph writes and reads toggle independently per request
|
||||
- Multi-agent context supported via `user_id`, `agent_id`, `run_id` scoping
|
||||
- Add operations are asynchronous; graph metadata may not be immediately available
|
||||
|
||||
---
|
||||
|
||||
## Custom Categories
|
||||
|
||||
Replace Mem0's default 15 labels with domain-specific categories. The system automatically tags memories to the closest matching category.
|
||||
|
||||
### Default Categories (15)
|
||||
|
||||
`personal_details`, `family`, `professional_details`, `sports`, `travel`, `food`, `music`, `health`, `technology`, `hobbies`, `fashion`, `entertainment`, `milestones`, `user_preferences`, `misc`
|
||||
|
||||
### Configuration
|
||||
|
||||
**Set project-level categories:**
|
||||
```python
|
||||
new_categories = [
|
||||
{"lifestyle_management": "Tracks daily routines, habits, wellness activities"},
|
||||
{"seeking_structure": "Documents goals around creating routines and systems"},
|
||||
{"personal_information": "Basic information about the user"}
|
||||
]
|
||||
client.project.update(custom_categories=new_categories)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_categories: new_categories });
|
||||
```
|
||||
|
||||
**Retrieve active categories:**
|
||||
```python
|
||||
categories = client.project.get(fields=["custom_categories"])
|
||||
```
|
||||
|
||||
### Key Constraint
|
||||
|
||||
Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field.
|
||||
|
||||
---
|
||||
|
||||
## Custom Instructions
|
||||
|
||||
Natural language filters that control what information Mem0 extracts when creating memories.
|
||||
|
||||
### Set Instructions
|
||||
|
||||
```python
|
||||
client.project.update(custom_instructions="Your guidelines here...")
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_instructions: "Your guidelines here..." });
|
||||
```
|
||||
|
||||
### Template Structure
|
||||
|
||||
1. **Task Description** -- brief extraction overview
|
||||
2. **Information Categories** -- numbered sections with specific details to capture
|
||||
3. **Processing Guidelines** -- quality and handling rules
|
||||
4. **Exclusion List** -- sensitive/irrelevant data to filter out
|
||||
|
||||
### Domain Examples
|
||||
|
||||
**E-commerce:** Capture product issues, preferences, service experience; exclude payment data.
|
||||
|
||||
**Education:** Extract learning progress, student preferences, performance patterns; exclude specific grades.
|
||||
|
||||
**Finance:** Track financial goals, life events, investment interests; exclude account numbers and SSNs.
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Start simply, test with sample messages, iterate based on results
|
||||
- Avoid overly lengthy instructions
|
||||
- Be specific about what to include AND exclude
|
||||
|
||||
---
|
||||
|
||||
## Criteria Retrieval
|
||||
|
||||
Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals.
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# Define criteria at project level
|
||||
retrieval_criteria = [
|
||||
{"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3},
|
||||
{"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2},
|
||||
{"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4},
|
||||
]
|
||||
client.project.update(retrieval_criteria=retrieval_criteria)
|
||||
```
|
||||
|
||||
```typescript
|
||||
await client.updateProject({
|
||||
retrieval_criteria: [
|
||||
{ name: 'joy', description: 'Positive emotions', weight: 3 },
|
||||
{ name: 'urgency', description: 'Time-sensitive items', weight: 4 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Once configured, `client.search()` automatically applies criteria ranking:
|
||||
|
||||
```python
|
||||
# Criteria-weighted results returned automatically
|
||||
results = client.search("Why am I feeling happy?", filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
**Best for:** Wellness assistants, tutoring platforms, productivity tools — any app needing intent-aware retrieval.
|
||||
|
||||
---
|
||||
|
||||
## Feedback Mechanism
|
||||
|
||||
Provide feedback on extracted memories to improve system quality over time.
|
||||
|
||||
### Feedback Types
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `POSITIVE` | Memory is useful and accurate |
|
||||
| `NEGATIVE` | Memory is not useful |
|
||||
| `VERY_NEGATIVE` | Memory is harmful or completely wrong |
|
||||
| `None` | Clear existing feedback |
|
||||
|
||||
### Usage
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
client.feedback(
|
||||
memory_id="mem-123",
|
||||
feedback="POSITIVE",
|
||||
feedback_reason="Accurately captured dietary preference"
|
||||
)
|
||||
|
||||
# Bulk feedback
|
||||
for item in feedback_data:
|
||||
client.feedback(**item)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.feedback('mem-123', {
|
||||
feedback: 'POSITIVE',
|
||||
feedback_reason: 'Accurately captured dietary preference',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Export
|
||||
|
||||
Create structured exports of memories using customizable schemas with filters.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
# Define export schema
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"preferences": {"type": "array", "items": {"type": "string"}},
|
||||
"health_info": {"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
# Create export
|
||||
response = client.create_memory_export(
|
||||
schema=json.dumps(schema),
|
||||
filters={"user_id": "alice"},
|
||||
export_instructions="Create comprehensive profile based on all memories"
|
||||
)
|
||||
|
||||
# Retrieve export (may take a moment to process)
|
||||
result = client.get_memory_export(memory_export_id=response["id"])
|
||||
```
|
||||
|
||||
**Best for:** Data analytics, user profile generation, compliance audits, CRM sync.
|
||||
|
||||
---
|
||||
|
||||
## Group Chat
|
||||
|
||||
Process multi-participant conversations and automatically attribute memories to individual speakers.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "name": "Alice", "content": "I think we should use React for the frontend"},
|
||||
{"role": "user", "name": "Bob", "content": "I prefer Vue.js, it's simpler for our use case"},
|
||||
{"role": "assistant", "content": "Both are great choices. Let me note your preferences."},
|
||||
]
|
||||
|
||||
# Mem0 automatically attributes memories to each speaker
|
||||
response = client.add(messages, run_id="team_meeting_1")
|
||||
|
||||
# Retrieve Alice's memories from that session
|
||||
alice_mems = client.get_all(
|
||||
filters={"AND": [{"user_id": "alice"}, {"run_id": "team_meeting_1"}]}
|
||||
)
|
||||
```
|
||||
|
||||
Use the `name` field in messages to identify speakers. Mem0 maps names to entity scopes automatically.
|
||||
|
||||
---
|
||||
|
||||
## MCP Integration
|
||||
|
||||
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-your-api-key",
|
||||
"MEM0_DEFAULT_USER_ID": "your-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available MCP Tools
|
||||
|
||||
The MCP server exposes 9 memory tools that AI agents can use autonomously:
|
||||
- Add, search, get, update, delete memories
|
||||
- Get history, list users, delete users
|
||||
- Search Mem0 documentation
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Configure the MCP server in your AI client
|
||||
2. The agent autonomously decides when to store/retrieve memories
|
||||
3. No manual API calls needed — the agent manages memory as part of its reasoning
|
||||
|
||||
**Best for:** Universal AI client integration — one protocol works everywhere.
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
Real-time event notifications for memory operations.
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event | Trigger |
|
||||
|-------|---------|
|
||||
| `memory_add` | Memory created |
|
||||
| `memory_update` | Memory modified |
|
||||
| `memory_delete` | Memory removed |
|
||||
| `memory_categorize` | Memory tagged |
|
||||
|
||||
### Create Webhook
|
||||
|
||||
Note: `project_id` here refers to the Mem0 dashboard project scope for webhooks — not the deprecated client init parameter.
|
||||
|
||||
```python
|
||||
webhook = client.create_webhook(
|
||||
url="https://your-app.com/webhook",
|
||||
name="Memory Logger",
|
||||
project_id="proj_123",
|
||||
event_types=["memory_add", "memory_categorize"]
|
||||
)
|
||||
```
|
||||
|
||||
### Manage Webhooks
|
||||
|
||||
```python
|
||||
# Retrieve
|
||||
webhooks = client.get_webhooks(project_id="proj_123")
|
||||
|
||||
# Update
|
||||
client.update_webhook(
|
||||
name="Updated Logger",
|
||||
url="https://your-app.com/new-webhook",
|
||||
event_types=["memory_update", "memory_add"],
|
||||
webhook_id="wh_123"
|
||||
)
|
||||
|
||||
# Delete
|
||||
client.delete_webhook(webhook_id="wh_123")
|
||||
```
|
||||
|
||||
### Payload Structure
|
||||
|
||||
Memory events contain: ID, data object with memory content, event type (`ADD`/`UPDATE`/`DELETE`).
|
||||
Categorization events contain: memory ID, event type (`CATEGORIZE`), assigned category labels.
|
||||
|
||||
---
|
||||
|
||||
## Multimodal Support
|
||||
|
||||
Mem0 can process images and documents alongside text.
|
||||
|
||||
### Supported Media Types
|
||||
|
||||
- Images: JPG, PNG
|
||||
- Documents: MDX, TXT, PDF
|
||||
|
||||
### Image via URL
|
||||
|
||||
```python
|
||||
image_message = {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/image.jpg"}
|
||||
}
|
||||
}
|
||||
client.add([image_message], user_id="alice")
|
||||
```
|
||||
|
||||
### Image via Base64
|
||||
|
||||
```python
|
||||
import base64
|
||||
with open("photo.jpg", "rb") as f:
|
||||
base64_image = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
image_message = {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
|
||||
}
|
||||
}
|
||||
client.add([image_message], user_id="alice")
|
||||
```
|
||||
|
||||
### Document (MDX/TXT)
|
||||
|
||||
```python
|
||||
doc_message = {
|
||||
"role": "user",
|
||||
"content": {"type": "mdx_url", "mdx_url": {"url": document_url}}
|
||||
}
|
||||
client.add([doc_message], user_id="alice")
|
||||
```
|
||||
|
||||
### PDF Document
|
||||
|
||||
```python
|
||||
pdf_message = {
|
||||
"role": "user",
|
||||
"content": {"type": "pdf_url", "pdf_url": {"url": pdf_url}}
|
||||
}
|
||||
client.add([pdf_message], user_id="alice")
|
||||
```
|
||||
@@ -0,0 +1,444 @@
|
||||
# Mem0 Integration Patterns
|
||||
|
||||
Working code examples for integrating Mem0 Platform with popular AI frameworks.
|
||||
All examples use `MemoryClient` (Platform API key).
|
||||
|
||||
Code examples are sourced from official Mem0 integration docs at docs.mem0.ai, simplified for quick reference.
|
||||
|
||||
---
|
||||
|
||||
## Common Pattern
|
||||
|
||||
Every integration follows the same 3-step loop:
|
||||
|
||||
1. **Retrieve** -- search relevant memories before generating a response
|
||||
2. **Generate** -- include memories as context in the LLM prompt
|
||||
3. **Store** -- save the interaction back to Mem0 for future use
|
||||
|
||||
---
|
||||
|
||||
## LangChain
|
||||
|
||||
Source: [docs.mem0.ai/integrations/langchain](https://docs.mem0.ai/integrations/langchain)
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from mem0 import MemoryClient
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4.1-nano-2025-04-14")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
SystemMessage(content="You are a helpful travel agent AI. Use the provided context to personalize your responses."),
|
||||
MessagesPlaceholder(variable_name="context"),
|
||||
HumanMessage(content="{input}")
|
||||
])
|
||||
|
||||
def retrieve_context(query: str, user_id: str):
|
||||
"""Retrieve relevant memories from Mem0"""
|
||||
memories = mem0.search(query, user_id=user_id)
|
||||
memory_list = memories['results']
|
||||
serialized = ' '.join([m["memory"] for m in memory_list])
|
||||
return [
|
||||
{"role": "system", "content": f"Relevant information: {serialized}"},
|
||||
{"role": "user", "content": query}
|
||||
]
|
||||
|
||||
def chat_turn(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve
|
||||
context = retrieve_context(user_input, user_id)
|
||||
# 2. Generate
|
||||
chain = prompt | llm
|
||||
response = chain.invoke({"context": context, "input": user_input})
|
||||
# 3. Store
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": response.content}],
|
||||
user_id=user_id
|
||||
)
|
||||
return response.content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CrewAI
|
||||
|
||||
Source: [docs.mem0.ai/integrations/crewai](https://docs.mem0.ai/integrations/crewai)
|
||||
|
||||
CrewAI has native Mem0 integration via `memory_config`:
|
||||
|
||||
```python
|
||||
from crewai import Agent, Task, Crew, Process
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
# Store user preferences first
|
||||
messages = [
|
||||
{"role": "user", "content": "I am more of a beach person than a mountain person."},
|
||||
{"role": "assistant", "content": "Noted! I'll recommend beach destinations."},
|
||||
{"role": "user", "content": "I like Airbnb more than hotels."},
|
||||
]
|
||||
client.add(messages, user_id="crew_user_1")
|
||||
|
||||
# Create agent
|
||||
travel_agent = Agent(
|
||||
role="Personalized Travel Planner",
|
||||
goal="Plan personalized travel itineraries",
|
||||
backstory="You are a seasoned travel planner.",
|
||||
memory=True,
|
||||
)
|
||||
|
||||
# Create task
|
||||
task = Task(
|
||||
description="Find places to live, eat, and visit in San Francisco.",
|
||||
expected_output="A detailed list of places to live, eat, and visit.",
|
||||
agent=travel_agent,
|
||||
)
|
||||
|
||||
# Setup crew with Mem0 memory
|
||||
crew = Crew(
|
||||
agents=[travel_agent],
|
||||
tasks=[task],
|
||||
process=Process.sequential,
|
||||
memory=True,
|
||||
memory_config={
|
||||
"provider": "mem0",
|
||||
"config": {"user_id": "crew_user_1"},
|
||||
}
|
||||
)
|
||||
|
||||
result = crew.kickoff()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vercel AI SDK
|
||||
|
||||
Source: [docs.mem0.ai/integrations/vercel-ai-sdk](https://docs.mem0.ai/integrations/vercel-ai-sdk)
|
||||
|
||||
Install: `npm install @mem0/vercel-ai-provider`
|
||||
|
||||
### Basic Text Generation with Memory
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0({
|
||||
provider: "openai",
|
||||
mem0ApiKey: "m0-xxx",
|
||||
apiKey: "openai-api-key",
|
||||
});
|
||||
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
```
|
||||
|
||||
### Streaming with Memory
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { textStream } = streamText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
|
||||
for await (const textPart of textStream) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Memory Utilities Standalone
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText } from "ai";
|
||||
import { retrieveMemories, addMemories } from "@mem0/vercel-ai-provider";
|
||||
|
||||
// Retrieve memories and inject into any provider
|
||||
const prompt = "Suggest me a good car to buy.";
|
||||
const memories = await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4-turbo"),
|
||||
prompt: prompt,
|
||||
system: memories,
|
||||
});
|
||||
|
||||
// Store new memories
|
||||
await addMemories(
|
||||
[{ role: "user", content: [{ type: "text", text: "I love red cars." }] }],
|
||||
{ user_id: "borat", mem0ApiKey: "m0-xxx" }
|
||||
);
|
||||
```
|
||||
|
||||
### Supported Providers
|
||||
|
||||
`openai`, `anthropic`, `google`, `groq`
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Agents SDK
|
||||
|
||||
Source: [docs.mem0.ai/integrations/openai-agents-sdk](https://docs.mem0.ai/integrations/openai-agents-sdk)
|
||||
|
||||
```python
|
||||
from agents import Agent, Runner, function_tool
|
||||
from mem0 import MemoryClient
|
||||
|
||||
mem0 = MemoryClient()
|
||||
|
||||
@function_tool
|
||||
def search_memory(query: str, user_id: str) -> str:
|
||||
"""Search through past conversations and memories"""
|
||||
memories = mem0.search(query, user_id=user_id, top_k=3)
|
||||
if memories and memories.get('results'):
|
||||
return "\n".join([f"- {mem['memory']}" for mem in memories['results']])
|
||||
return "No relevant memories found."
|
||||
|
||||
@function_tool
|
||||
def save_memory(content: str, user_id: str) -> str:
|
||||
"""Save important information to memory"""
|
||||
mem0.add([{"role": "user", "content": content}], user_id=user_id)
|
||||
return "Information saved to memory."
|
||||
|
||||
agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="""You are a helpful personal assistant with memory capabilities.
|
||||
Use search_memory to recall past conversations.
|
||||
Use save_memory to store important information.""",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(agent, "I love Italian food and I'm planning a trip to Rome next month")
|
||||
print(result.final_output)
|
||||
```
|
||||
|
||||
### Multi-Agent with Handoffs
|
||||
|
||||
```python
|
||||
from agents import Agent, Runner, function_tool
|
||||
|
||||
travel_agent = Agent(
|
||||
name="Travel Planner",
|
||||
instructions="You are a travel planning specialist. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
health_agent = Agent(
|
||||
name="Health Advisor",
|
||||
instructions="You are a health and wellness advisor. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
triage_agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="""Route travel questions to Travel Planner, health questions to Health Advisor.""",
|
||||
handoffs=[travel_agent, health_agent],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(triage_agent, "Plan a healthy meal for my Italy trip")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipecat (Voice / Real-Time)
|
||||
|
||||
Source: [docs.mem0.ai/integrations/pipecat](https://docs.mem0.ai/integrations/pipecat)
|
||||
|
||||
```python
|
||||
from pipecat.services.mem0 import Mem0MemoryService
|
||||
|
||||
memory = Mem0MemoryService(
|
||||
api_key=os.getenv("MEM0_API_KEY"),
|
||||
user_id="alice",
|
||||
agent_id="voice_bot",
|
||||
params={
|
||||
"search_limit": 10,
|
||||
"search_threshold": 0.1,
|
||||
"system_prompt": "Here are your past memories:",
|
||||
"add_as_system_message": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Use in pipeline
|
||||
pipeline = Pipeline([
|
||||
transport.input(),
|
||||
stt,
|
||||
user_context,
|
||||
memory, # Memory enhances context automatically
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_context
|
||||
])
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## LangGraph
|
||||
|
||||
Source: [docs.mem0.ai/integrations/langgraph](https://docs.mem0.ai/integrations/langgraph)
|
||||
|
||||
State-based agent workflows with memory persistence. Best for complex conversation flows with branching logic.
|
||||
|
||||
```python
|
||||
from typing import Annotated, TypedDict, List
|
||||
from langgraph.graph import StateGraph, START
|
||||
from langgraph.graph.message import add_messages
|
||||
from langchain_openai import ChatOpenAI
|
||||
from mem0 import MemoryClient
|
||||
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[List[HumanMessage | AIMessage], add_messages]
|
||||
mem0_user_id: str
|
||||
|
||||
def chatbot(state: State):
|
||||
messages = state["messages"]
|
||||
user_id = state["mem0_user_id"]
|
||||
|
||||
# Retrieve relevant memories
|
||||
memories = mem0.search(messages[-1].content, user_id=user_id)
|
||||
context = "Relevant context:\n"
|
||||
for memory in memories["results"]:
|
||||
context += f"- {memory['memory']}\n"
|
||||
|
||||
system_message = SystemMessage(content=f"""You are a helpful support assistant.
|
||||
{context}""")
|
||||
|
||||
response = llm.invoke([system_message] + messages)
|
||||
|
||||
# Store the interaction
|
||||
mem0.add(
|
||||
[{"role": "user", "content": messages[-1].content},
|
||||
{"role": "assistant", "content": response.content}],
|
||||
user_id=user_id
|
||||
)
|
||||
return {"messages": [response]}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("chatbot", chatbot)
|
||||
graph.add_edge(START, "chatbot")
|
||||
app = graph.compile()
|
||||
|
||||
# Usage
|
||||
result = app.invoke({
|
||||
"messages": [HumanMessage(content="I need help with my order")],
|
||||
"mem0_user_id": "customer_123"
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LlamaIndex
|
||||
|
||||
Source: [docs.mem0.ai/integrations/llama-index](https://docs.mem0.ai/integrations/llama-index)
|
||||
|
||||
Install: `pip install llama-index-core llama-index-memory-mem0`
|
||||
|
||||
LlamaIndex has native Mem0 support via `Mem0Memory`. Works with ReAct and FunctionCalling agents.
|
||||
|
||||
```python
|
||||
from llama_index.memory.mem0 import Mem0Memory
|
||||
|
||||
context = {"user_id": "alice", "agent_id": "llama_agent_1"}
|
||||
memory = Mem0Memory.from_client(
|
||||
context=context,
|
||||
search_msg_limit=4, # messages from chat history used for retrieval (default: 5)
|
||||
)
|
||||
|
||||
# Use with LlamaIndex agent
|
||||
from llama_index.core.agent import FunctionCallingAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
llm = OpenAI(model="gpt-4")
|
||||
agent = FunctionCallingAgent.from_tools(
|
||||
tools=[],
|
||||
llm=llm,
|
||||
memory=memory,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("I prefer vegetarian restaurants")
|
||||
# Memory automatically stores and retrieves context
|
||||
response = agent.chat("What kind of food do I like?")
|
||||
# Agent retrieves the vegetarian preference from Mem0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AutoGen
|
||||
|
||||
Source: [docs.mem0.ai/integrations/autogen](https://docs.mem0.ai/integrations/autogen)
|
||||
|
||||
Install: `pip install autogen mem0ai`
|
||||
|
||||
Multi-agent conversational systems with memory persistence.
|
||||
|
||||
```python
|
||||
from autogen import ConversableAgent
|
||||
from mem0 import MemoryClient
|
||||
|
||||
memory_client = MemoryClient()
|
||||
USER_ID = "alice"
|
||||
|
||||
agent = ConversableAgent(
|
||||
"chatbot",
|
||||
llm_config={"config_list": [{"model": "gpt-4", "api_key": os.environ["OPENAI_API_KEY"]}]},
|
||||
code_execution_config=False,
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
def get_context_aware_response(question: str) -> str:
|
||||
# Retrieve memories for context
|
||||
relevant_memories = memory_client.search(question, user_id=USER_ID)
|
||||
context = "\n".join([m["memory"] for m in relevant_memories.get("results", [])])
|
||||
|
||||
prompt = f"""Answer considering previous interactions:
|
||||
Previous context: {context}
|
||||
Question: {question}"""
|
||||
|
||||
reply = agent.generate_reply(messages=[{"content": prompt, "role": "user"}])
|
||||
|
||||
# Store the new interaction
|
||||
memory_client.add(
|
||||
[{"role": "user", "content": question}, {"role": "assistant", "content": reply}],
|
||||
user_id=USER_ID
|
||||
)
|
||||
return reply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Supported Frameworks
|
||||
|
||||
Beyond the examples above, Mem0 integrates with:
|
||||
|
||||
| Framework | Type | Install |
|
||||
|-----------|------|---------|
|
||||
| [Mastra](https://docs.mem0.ai/integrations/mastra) | TS agent framework | `npm install @mastra/mem0` |
|
||||
| [ElevenLabs](https://docs.mem0.ai/integrations/elevenlabs) | Voice AI | `pip install elevenlabs mem0ai` |
|
||||
| [LiveKit](https://docs.mem0.ai/integrations/livekit) | Real-time voice/video | `pip install livekit-agents mem0ai` |
|
||||
| [Camel AI](https://docs.mem0.ai/integrations/camel-ai) | Multi-agent framework | `pip install camel-ai[all] mem0ai` |
|
||||
| [AWS Bedrock](https://docs.mem0.ai/integrations/aws-bedrock) | Cloud LLM provider | `pip install boto3 mem0ai` |
|
||||
| [Dify](https://docs.mem0.ai/integrations/dify) | Low-code AI platform | Plugin-based |
|
||||
| [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) | Google agent framework | `pip install google-adk mem0ai` |
|
||||
|
||||
For the general Python pattern (no framework), see the "Common integration pattern" in [SKILL.md](../SKILL.md).
|
||||
@@ -0,0 +1,119 @@
|
||||
# Mem0 Platform Quickstart
|
||||
|
||||
Get running with Mem0 in 2 minutes. No infrastructure to deploy -- just an API key.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+ or Node.js 18+
|
||||
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||
|
||||
## Python Setup
|
||||
|
||||
```bash
|
||||
pip install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Add a memory
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
]
|
||||
client.add(messages, user_id="user123")
|
||||
|
||||
# Search memories
|
||||
results = client.search("What are my dietary restrictions?", user_id="user123")
|
||||
print(results)
|
||||
```
|
||||
|
||||
### Async Client
|
||||
|
||||
```python
|
||||
from mem0 import AsyncMemoryClient
|
||||
|
||||
client = AsyncMemoryClient(api_key="your-api-key")
|
||||
|
||||
await client.add(messages, user_id="user123")
|
||||
results = await client.search("query", user_id="user123")
|
||||
```
|
||||
|
||||
## TypeScript / JavaScript Setup
|
||||
|
||||
```bash
|
||||
npm install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
```javascript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: 'your-api-key' });
|
||||
|
||||
// Add a memory
|
||||
const messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
];
|
||||
await client.add(messages, { user_id: "user123" });
|
||||
|
||||
// Search memories
|
||||
const results = await client.search("What are my dietary restrictions?", {
|
||||
user_id: "user123"
|
||||
});
|
||||
console.log(results);
|
||||
```
|
||||
|
||||
## cURL
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
|
||||
# Add memory
|
||||
curl -X POST https://api.mem0.ai/v1/memories/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": "I am a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I will remember your dietary preferences."}
|
||||
],
|
||||
"user_id": "user123"
|
||||
}'
|
||||
|
||||
# Search memories
|
||||
curl -X POST https://api.mem0.ai/v2/memories/search/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "What are my dietary restrictions?",
|
||||
"filters": {"user_id": "user123"}
|
||||
}'
|
||||
```
|
||||
|
||||
## Sample Response
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d",
|
||||
"memory": "Allergic to nuts",
|
||||
"user_id": "user123",
|
||||
"categories": ["health"],
|
||||
"created_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"score": 0.30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [SDK Guide](sdk-guide.md) -- all methods for Python and TypeScript
|
||||
- [API Reference](api-reference.md) -- REST endpoints and memory object structure
|
||||
- [Integration Patterns](integration-patterns.md) -- LangChain, CrewAI, Vercel AI, etc.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user