Compare commits

..

21 Commits

Author SHA1 Message Date
kartik-mem0 d14470d6bb chore: adding test for llm reranker 2026-03-19 00:31:30 +05:30
kartik-mem0 5154174342 chore: add nested llm config support to LLM reranker 2026-03-18 22:12:04 +05:30
Anisha Mahuli 577a5a2feb fix(oss): normalize malformed LLM fact output before embedding (#4224)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-03-18 20:19:29 +05:30
darrenxu d7a34c24dd fix(ollama): pass tools to client.chat and parse tool_calls from response (#4176)
Signed-off-by: sxu75374 <imshuaixu@gmail.com>
Signed-off-by: Small <imshuaixu@gmail.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-03-18 19:19:36 +05:30
Kartik 214d2a1d0d chore: remove the integration/mirofish path from docs (#4399) 2026-03-18 16:40:30 +05:30
Kartik f0eb9e091f docs: add MiroFish integration and swarm memory cookbook documentation (#4373) 2026-03-18 16:31:04 +05:30
Kartik 3cdcb6564c chore: end to end test coverage for ts sdk (#4357) 2026-03-17 21:13:52 +05:30
Utkarsh 336fbce60a feat(mem0-ts): add LM Studio embedder and LLM support (#4354)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:21:35 +05:30
Huvee 9eb5b9ed29 fix(qdrant): handle 401/403 in ensureCollection for scoped JWTs (#4356) 2026-03-17 18:21:00 +05:30
Utkarsh 8230a5dac7 fix: cast vector_distance to float in Redis search (#4377)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:34:46 +05:30
Saket Aryan 9864584c21 feat: add openclaw checks CI workflow (#4368) 2026-03-17 12:06:13 +05:30
Saket Aryan 9eea060db9 docs: fix mintlify build failing (#4363) 2026-03-16 23:02:54 +05:30
Kartik 15218d4a7f chore: bump mem0-ts to 2.4.1, pyproject to 1.0.6, update changelog with bug fixes (#4361)
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
2026-03-16 22:49:31 +05:30
Kartik 69001d7b1f chore(docs): adding skills.sh installation command in the readme. (#4350) 2026-03-16 22:05:15 +05:30
dhilip_binny 35fe30aabd fix: ensure JSON instruction in prompts for json_object response format (#3559) (#4271) 2026-03-16 21:57:57 +05:30
Kartik 11a7d8378c chore: update langchain dependencies to v1.0.0 (#4353) 2026-03-16 21:43:14 +05:30
Kartik b4b73deada fix(oss): OllamaLLM now respects configured url instead of always falling back to localhost (#4320) 2026-03-16 21:42:46 +05:30
Utkarsh bfe730aa38 fix(openclaw): add SQLite resilience for OSS mode initialization (#4337)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:36:27 +05:30
Kartik 82d67430dd fix: remove destructive vector_store.reset() from delete_all() (#4349) 2026-03-16 20:55:29 +05:30
Kartik 8fcf2b0b29 fix: skip telemetry vector store init when MEM0_TELEMETRY is disabled (#4351) 2026-03-16 20:55:04 +05:30
Anisha Mahuli 2e5e290434 fix: key error when llm omits entities key tool call (#4313) 2026-03-16 20:52:26 +05:30
76 changed files with 7868 additions and 822 deletions
+100
View File
@@ -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)
+75
View File
@@ -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/
+26
View File
@@ -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,16 @@ 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:**
@@ -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
View File
@@ -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"
]
}
]
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.db
.env
.env.*
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
pnpm-lock.yaml
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.4.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 .",
+65 -45
View File
@@ -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 -1
View File
@@ -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";
+6 -5
View File
@@ -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;
+1 -1
View File
@@ -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",
+189
View File
@@ -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();
});
});
+83
View File
@@ -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 };
+246
View File
@@ -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);
});
});
+205
View File
@@ -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 },
);
}
+25 -3
View File
@@ -20,15 +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: userConf?.baseURL,
baseURL,
url: userConf?.url,
embeddingDims: userConf?.embeddingDims,
embeddingDims,
modelProperties:
userConf?.modelProperties !== undefined
? userConf.modelProperties
@@ -91,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
@@ -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}`);
}
}
}
+1 -1
View File
@@ -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;
+2
View File
@@ -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";
+41
View File
@@ -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}`);
}
}
}
+1 -1
View File
@@ -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) => {
@@ -32,7 +32,7 @@ describe("backward compat: ConfigManager.mergeConfig", () => {
expect(cfg.embedder.provider).toBe("openai");
expect(cfg.vectorStore.provider).toBe("memory");
expect(cfg.vectorStore.config.collectionName).toBe("memories");
expect(cfg.vectorStore.config.dimension).toBe(1536);
expect(cfg.vectorStore.config.dimension).toBeUndefined();
expect(cfg.llm.provider).toBe("openai");
expect(cfg.historyStore).toBeDefined();
expect(cfg.historyStore!.provider).toBe("sqlite");
+2
View File
@@ -43,6 +43,7 @@ export interface HistoryStoreConfig {
export interface LLMConfig {
provider?: string;
baseURL?: string;
url?: string;
config?: Record<string, any>;
apiKey?: string;
model?: string | any;
@@ -143,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(),
+6
View File
@@ -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);
+5 -1
View File
@@ -282,7 +282,11 @@ export class Qdrant implements VectorStore {
},
});
} catch (error: any) {
if (error?.status === 409) {
if (
error?.status === 409 ||
error?.status === 401 ||
error?.status === 403
) {
// Collection already exists — verify configuration for the main collection
if (name === this.collectionName) {
try {
@@ -85,4 +85,513 @@ describe("ConfigManager", () => {
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();
});
});
-44
View File
@@ -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");
});
});
});
+287
View File
@@ -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,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)}`);
});
});
});
+114
View File
@@ -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");
});
});
+164
View File
@@ -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" }),
);
});
});
+358
View File
@@ -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);
});
});
+292
View File
@@ -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);
});
});
});
+125
View File
@@ -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);
});
});
-256
View File
@@ -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);
});
});
});
+228
View File
@@ -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");
});
});
+7 -1
View File
@@ -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.",
)
+2 -2
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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 = []
+1 -1
View File
@@ -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
View File
@@ -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:
"""
+25 -14
View File
@@ -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()
+1 -1
View File
@@ -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"],
+2 -2
View File
@@ -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 |
+33 -5
View File
@@ -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,7 +133,10 @@ 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;
}
@@ -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 {
+1
View File
@@ -38,6 +38,7 @@
},
"devDependencies": {
"@types/node": "^22.15.0",
"@vitest/coverage-v8": "^4.0.18",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
+117
View File
@@ -18,6 +18,9 @@ importers:
'@types/node':
specifier: ^22.15.0
version: 22.19.15
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))
tsup:
specifier: ^8.5.0
version: 8.5.1(postcss@8.5.8)(typescript@5.9.3)
@@ -96,10 +99,27 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
@@ -717,6 +737,15 @@ packages:
resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==}
engines: {node: '>=20.0.0'}
'@vitest/coverage-v8@4.1.0':
resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==}
peerDependencies:
'@vitest/browser': 4.1.0
vitest: 4.1.0
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.0':
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
@@ -790,6 +819,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1176,6 +1208,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1228,6 +1263,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -1258,6 +1305,9 @@ packages:
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1402,6 +1452,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2235,8 +2292,21 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@cfworker/json-schema@4.1.1': {}
'@cloudflare/workers-types@4.20260313.1': {}
@@ -2733,6 +2803,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4))
'@vitest/expect@4.1.0':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2802,6 +2886,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
asynckit@0.4.0: {}
axios@1.13.6:
@@ -3207,6 +3297,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -3251,6 +3343,19 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -3300,6 +3405,8 @@ snapshots:
dependencies:
base64-js: 1.5.1
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
json-bigint@1.0.0:
@@ -3418,6 +3525,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
math-intrinsics@1.1.0: {}
md5@2.3.0:
+2 -1
View File
@@ -1,4 +1,5 @@
approveBuilds: esbuild
packages:
- '.'
onlyBuiltDependencies:
- better-sqlite3
+288
View File
@@ -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);
});
});
+1 -1
View File
@@ -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" }
+6
View File
@@ -13,6 +13,12 @@ When installed, Claude can:
## 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
+108
View File
@@ -32,3 +32,111 @@ def test_generate_response_without_tools(mock_ollama_client):
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
)
assert response == "I'm doing well, thank you for asking!"
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
"""Tools should be forwarded to ollama client.chat()."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
tools = [
{
"type": "function",
"function": {
"name": "extract_entities",
"description": "Extract entities",
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
},
}
]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "extract_entities",
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
}
}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
# Verify tools were passed to client.chat
call_kwargs = mock_ollama_client.chat.call_args
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
assert call_kwargs[1]["tools"] == tools
# Verify tool_calls were parsed correctly
assert response["tool_calls"] == [
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
]
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
"""When model returns content without tool_calls, tool_calls should be empty list."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Hello"}]
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["content"] == "I cannot use tools for this."
assert response["tool_calls"] == []
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
"""When tool_call arguments come as JSON string, they should be parsed."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "test"}]
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
def test_parse_response_with_tools_object_style(mock_ollama_client):
"""Test _parse_response with object-style response (non-dict)."""
config = OllamaConfig(model="llama3.1:70b")
llm = OllamaLLM(config)
# Simulate object-style response
mock_fn = Mock()
mock_fn.name = "extract"
mock_fn.arguments = {"entities": ["Alice"]}
mock_tool_call = Mock()
mock_tool_call.function = mock_fn
mock_message = Mock()
mock_message.content = ""
mock_message.tool_calls = [mock_tool_call]
mock_response = Mock()
mock_response.message = mock_message
tools = [{"type": "function", "function": {"name": "extract"}}]
result = llm._parse_response(mock_response, tools)
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
+214
View File
@@ -0,0 +1,214 @@
"""
Tests for issue #3559: Custom prompts crash with response_format json_object
when the word 'json' is not present in the prompt.
OpenAI API requires the word 'json' to appear in messages when using
response_format: {"type": "json_object"}. Custom fact extraction prompts
may not include this word, causing BadRequestError.
This tests the ensure_json_instruction utility function and verifies
the fix is applied in both sync and async code paths.
"""
import pytest
from mem0.memory.utils import ensure_json_instruction
class TestEnsureJsonInstruction:
"""Tests for the ensure_json_instruction utility function."""
# -------------------------------------------------------------------
# Core behavior: append when missing, skip when present
# -------------------------------------------------------------------
def test_appends_when_json_missing_from_both_prompts(self):
"""When neither prompt contains 'json', instruction is appended to system prompt."""
system, user = ensure_json_instruction(
"Extract facts from the conversation and return them as a list.",
"Input:\nuser: Hi my name is John",
)
assert "json" in system.lower()
assert "facts" in system.lower()
def test_no_change_when_json_in_system_prompt(self):
"""When system prompt already contains 'json', no modification."""
original = "Extract facts and return in json format."
system, user = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_no_change_when_json_in_user_prompt(self):
"""When user prompt contains 'json', no modification to system prompt."""
original_system = "Extract facts from the conversation."
original_user = "Input (respond in json):\nuser: Hi"
system, user = ensure_json_instruction(original_system, original_user)
assert system == original_system
def test_user_prompt_never_modified(self):
"""The user prompt should never be modified regardless of content."""
original_user = "Input:\nuser: I like pizza"
_, user = ensure_json_instruction("Extract facts.", original_user)
assert user == original_user
# -------------------------------------------------------------------
# Case insensitivity
# -------------------------------------------------------------------
def test_case_insensitive_lowercase(self):
original = "Return results in json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_uppercase(self):
original = "Return results in JSON format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_mixed(self):
original = "Return results in Json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_in_user_prompt(self):
original_system = "Extract facts."
system, _ = ensure_json_instruction(original_system, "Return JSON.\nuser: Hi")
assert system == original_system
# -------------------------------------------------------------------
# Parametrized: various custom prompts
# -------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,should_append",
[
# Prompts WITHOUT json — should append
("Extract all facts from the conversation.", True),
("You are a memory extractor. Return facts as a list.", True),
("Analyze the input and find key information.", True),
("Return data in structured format.", True),
("List the user preferences.", True),
# Prompts WITH json — should NOT append
("Extract facts and return in json format.", False),
("Return a json object with facts.", False),
("Output must be valid JSON.", False),
("Respond with a JSON array of facts.", False),
("Format: json output expected.", False),
],
)
def test_various_custom_prompts(self, prompt, should_append):
user_prompt = "Input:\nuser: Hi my name is John"
system, _ = ensure_json_instruction(prompt, user_prompt)
if should_append:
assert system != prompt, f"Expected JSON instruction to be appended for: {prompt}"
assert "json" in system.lower()
else:
assert system == prompt, f"Did not expect modification for: {prompt}"
# -------------------------------------------------------------------
# Edge cases
# -------------------------------------------------------------------
def test_empty_system_prompt(self):
"""Empty system prompt should get JSON instruction."""
system, _ = ensure_json_instruction("", "Input:\nuser: test")
assert "json" in system.lower()
def test_whitespace_only_system_prompt(self):
"""Whitespace-only prompt should get JSON instruction."""
system, _ = ensure_json_instruction(" \n ", "Input:\nuser: test")
assert "json" in system.lower()
def test_preserves_original_prompt_content(self):
"""The fix should only append, never modify the original prompt content."""
original = "Extract all user preferences and habits from the conversation."
system, _ = ensure_json_instruction(original, "Input:\nuser: I like pizza")
assert system.startswith(original)
assert len(system) > len(original)
def test_appended_instruction_mentions_facts_key(self):
"""The appended instruction should guide the model to use the 'facts' key."""
system, _ = ensure_json_instruction(
"Extract information.", "Input:\nuser: test"
)
assert "facts" in system.lower()
def test_idempotent_when_already_has_json(self):
"""Calling ensure_json_instruction twice doesn't double-append."""
system1, user1 = ensure_json_instruction(
"Extract facts.", "Input:\nuser: test"
)
system2, user2 = ensure_json_instruction(system1, user1)
assert system1 == system2
assert user1 == user2
def test_json_in_curly_braces_not_detected(self):
"""A prompt with JSON-like structure but no 'json' word should get instruction.
e.g. '{"facts": [...]}' contains the characters j,s,o,n but not the word 'json'."""
prompt = 'Return format: {"facts": [...]}'
# This contains the substring "json" inside the key name — let's check
if "json" in prompt.lower():
# If it does contain json, it won't be modified
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt
else:
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system != prompt
# -------------------------------------------------------------------
# Default prompts verification
# -------------------------------------------------------------------
def test_default_prompts_already_contain_json(self):
"""Built-in prompts already contain 'json', so ensure_json_instruction is a no-op."""
from mem0.configs.prompts import (
FACT_RETRIEVAL_PROMPT,
USER_MEMORY_EXTRACTION_PROMPT,
AGENT_MEMORY_EXTRACTION_PROMPT,
)
for name, prompt in [
("FACT_RETRIEVAL_PROMPT", FACT_RETRIEVAL_PROMPT),
("USER_MEMORY_EXTRACTION_PROMPT", USER_MEMORY_EXTRACTION_PROMPT),
("AGENT_MEMORY_EXTRACTION_PROMPT", AGENT_MEMORY_EXTRACTION_PROMPT),
]:
assert "json" in prompt.lower(), (
f"{name} should contain 'json' — "
"if this fails, the default prompts have changed"
)
# ensure_json_instruction should be a no-op for defaults
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt, f"ensure_json_instruction modified {name} unexpectedly"
# -------------------------------------------------------------------
# Integration: verify fix is wired into both sync and async paths
# -------------------------------------------------------------------
def test_fix_applied_in_sync_memory_class(self):
"""Verify the ensure_json_instruction call exists in Memory._add_to_vector_store."""
import inspect
from mem0.memory.main import Memory
source = inspect.getsource(Memory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in Memory._add_to_vector_store (sync)"
)
def test_fix_applied_in_async_memory_class(self):
"""Verify the ensure_json_instruction call exists in AsyncMemory._add_to_vector_store."""
import inspect
from mem0.memory.main import AsyncMemory
source = inspect.getsource(AsyncMemory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in AsyncMemory._add_to_vector_store (async)"
)
def test_import_exists_in_main(self):
"""Verify ensure_json_instruction is imported in main.py."""
import inspect
import mem0.memory.main as main_module
source = inspect.getsource(main_module)
assert "from mem0.memory.utils import" in source
assert "ensure_json_instruction" in source
+45 -1
View File
@@ -1,6 +1,8 @@
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pytest
from unittest.mock import Mock, patch
from mem0.memory.kuzu_memory import MemoryGraph
@@ -190,6 +192,48 @@ class TestKuzu:
assert get_node_count(kuzu_memory) == 0
assert get_edge_count(kuzu_memory) == 0
def _make_kuzu_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
return instance
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in KuzuMemoryGraph."""
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_normal_entities_extracted(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_none_tool_calls_returns_empty(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
def get_node_count(kuzu_memory):
results = kuzu_memory.kuzu_execute(
"""
+11
View File
@@ -10,6 +10,7 @@ patch.dict("sys.modules", {
}).start()
from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402
MemoryGraph = MemgraphMemoryGraph
@@ -54,6 +55,16 @@ class TestRetrieveNodesFromData:
assert "relu" in result
assert "task" not in result
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
+11
View File
@@ -0,0 +1,11 @@
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_llm():
with patch("mem0.reranker.llm_reranker.LlmFactory") as mock_factory:
mock_llm_instance = MagicMock()
mock_factory.create.return_value = mock_llm_instance
yield mock_factory, mock_llm_instance
@@ -0,0 +1,63 @@
from mem0.configs.rerankers.base import BaseRerankerConfig
from mem0.configs.rerankers.llm import LLMRerankerConfig
from mem0.reranker.llm_reranker import LLMReranker
class TestLLMRerankerConfig:
def test_default_config(self):
config = LLMRerankerConfig()
assert config.model == "gpt-4o-mini"
assert config.provider == "openai"
assert config.temperature == 0.0
assert config.max_tokens == 100
assert config.llm is None
assert config.scoring_prompt is None
assert config.top_k is None
def test_nested_llm_field_accepted(self):
config = LLMRerankerConfig(
llm={"provider": "ollama", "config": {"ollama_base_url": "http://localhost:11434"}}
)
assert config.llm["provider"] == "ollama"
assert config.llm["config"]["ollama_base_url"] == "http://localhost:11434"
class TestLLMRerankerInit:
def test_init_with_dict_config(self, mock_llm):
mock_factory, _ = mock_llm
reranker = LLMReranker({"provider": "openai", "model": "gpt-4o", "api_key": "sk-test"})
assert reranker.config.provider == "openai"
assert reranker.config.model == "gpt-4o"
mock_factory.create.assert_called_once_with(
"openai",
{"model": "gpt-4o", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-test"},
)
def test_init_with_llm_reranker_config(self, mock_llm):
mock_factory, _ = mock_llm
config = LLMRerankerConfig(provider="anthropic", model="claude-3-haiku", api_key="sk-ant")
reranker = LLMReranker(config)
assert reranker.config.provider == "anthropic"
mock_factory.create.assert_called_once_with(
"anthropic",
{"model": "claude-3-haiku", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-ant"},
)
def test_init_converts_base_reranker_config(self, mock_llm):
mock_factory, _ = mock_llm
base_config = BaseRerankerConfig(provider="openai", model="gpt-4o-mini")
reranker = LLMReranker(base_config)
assert isinstance(reranker.config, LLMRerankerConfig)
assert reranker.config.temperature == 0.0
assert reranker.config.max_tokens == 100
def test_init_without_api_key(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({"provider": "openai", "model": "gpt-4o-mini"})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert "api_key" not in llm_config
@@ -0,0 +1,154 @@
from mem0.reranker.llm_reranker import LLMReranker
class TestNestedLLMConfig:
def test_nested_llm_overrides_provider(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {
"provider": "ollama",
"config": {"model": "llama3", "ollama_base_url": "http://localhost:11434"},
},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
def test_nested_llm_passes_provider_specific_config(self, mock_llm):
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"llm": {
"provider": "ollama",
"config": {
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["ollama_base_url"] == "http://localhost:11434"
assert llm_config["model"] == "llama3"
def test_nested_llm_inherits_top_level_defaults(self, mock_llm):
"""Nested config should inherit temperature/max_tokens from top-level if not overridden."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"temperature": 0.0,
"max_tokens": 100,
"llm": {
"provider": "ollama",
"config": {"model": "llama3"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["temperature"] == 0.0
assert llm_config["max_tokens"] == 100
def test_nested_llm_config_values_take_precedence(self, mock_llm):
"""Values explicitly set in nested config should not be overridden by top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.0,
"max_tokens": 100,
"llm": {
"provider": "ollama",
"config": {
"model": "custom-model",
"temperature": 0.5,
"max_tokens": 200,
},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["model"] == "custom-model"
assert llm_config["temperature"] == 0.5
assert llm_config["max_tokens"] == 200
def test_nested_llm_falls_back_to_top_level_provider(self, mock_llm):
"""If nested llm dict has no 'provider', use top-level provider."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "anthropic",
"model": "claude-3-haiku",
"llm": {
"config": {"model": "claude-3-sonnet"},
},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "anthropic"
assert call_args[0][1]["model"] == "claude-3-sonnet"
def test_nested_llm_with_empty_config(self, mock_llm):
"""Nested llm with no config dict should still work, using top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {"provider": "ollama"},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
llm_config = call_args[0][1]
assert llm_config["model"] == "gpt-4o-mini"
assert llm_config["temperature"] == 0.0
assert llm_config["max_tokens"] == 100
def test_nested_llm_with_none_config(self, mock_llm):
"""Nested llm with config: None should still work, using top-level defaults."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"model": "gpt-4o-mini",
"llm": {"provider": "ollama", "config": None},
})
call_args = mock_factory.create.call_args
assert call_args[0][0] == "ollama"
llm_config = call_args[0][1]
assert llm_config["model"] == "gpt-4o-mini"
def test_nested_llm_inherits_top_level_api_key(self, mock_llm):
"""Top-level api_key should be inherited by nested config if not already set."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"api_key": "sk-top-level",
"llm": {
"provider": "openai",
"config": {"model": "gpt-4o"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["api_key"] == "sk-top-level"
def test_nested_llm_config_api_key_not_overridden(self, mock_llm):
"""If nested config already has api_key, top-level api_key should not override it."""
mock_factory, _ = mock_llm
LLMReranker({
"provider": "openai",
"api_key": "sk-top-level",
"llm": {
"provider": "openai",
"config": {"model": "gpt-4o", "api_key": "sk-nested"},
},
})
call_args = mock_factory.create.call_args
llm_config = call_args[0][1]
assert llm_config["api_key"] == "sk-nested"
+125
View File
@@ -0,0 +1,125 @@
import pytest
from mem0.reranker.llm_reranker import LLMReranker
class TestExtractScore:
@pytest.fixture
def reranker(self, mock_llm):
return LLMReranker({"provider": "openai"})
@pytest.mark.parametrize(
"text,expected",
[
("0.85", 0.85),
("0.0", 0.0),
("1.0", 1.0),
("The score is 0.72.", 0.72),
("Score: 0.9 out of 1.0", 0.9),
],
)
def test_valid_scores(self, reranker, text, expected):
assert reranker._extract_score(text) == expected
def test_no_score_returns_fallback(self, reranker):
assert reranker._extract_score("no numbers here") == 0.5
def test_clamps_to_1(self, reranker):
assert reranker._extract_score("1.0") == 1.0
class TestRerank:
def test_empty_documents(self, mock_llm):
reranker = LLMReranker({"provider": "openai"})
result = reranker.rerank("query", [])
assert result == []
def test_documents_sorted_by_score_descending(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.3", "0.9", "0.6"]
reranker = LLMReranker({"provider": "openai"})
docs = [
{"memory": "low relevance"},
{"memory": "high relevance"},
{"memory": "mid relevance"},
]
result = reranker.rerank("test query", docs)
assert len(result) == 3
assert result[0]["rerank_score"] == 0.9
assert result[1]["rerank_score"] == 0.6
assert result[2]["rerank_score"] == 0.3
def test_top_k_limits_results(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"]
reranker = LLMReranker({"provider": "openai"})
docs = [{"memory": f"doc{i}"} for i in range(3)]
result = reranker.rerank("query", docs, top_k=2)
assert len(result) == 2
def test_config_top_k_used_when_arg_not_provided(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"]
reranker = LLMReranker({"provider": "openai", "top_k": 1})
docs = [{"memory": f"doc{i}"} for i in range(3)]
result = reranker.rerank("query", docs)
assert len(result) == 1
def test_text_field_extraction(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
reranker.rerank("query", [{"text": "some text"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert "some text" in prompt_sent
def test_content_field_extraction(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
reranker.rerank("query", [{"content": "some content"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert "some content" in prompt_sent
def test_fallback_score_on_llm_error(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.side_effect = RuntimeError("API error")
reranker = LLMReranker({"provider": "openai"})
result = reranker.rerank("query", [{"memory": "doc"}])
assert len(result) == 1
assert result[0]["rerank_score"] == 0.5
def test_custom_scoring_prompt(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.7"
custom_prompt = "Rate this: query={query} doc={document}"
reranker = LLMReranker({"provider": "openai", "scoring_prompt": custom_prompt})
reranker.rerank("my query", [{"memory": "my doc"}])
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
assert prompt_sent == "Rate this: query=my query doc=my doc"
def test_original_doc_not_mutated(self, mock_llm):
_, mock_llm_instance = mock_llm
mock_llm_instance.generate_response.return_value = "0.8"
reranker = LLMReranker({"provider": "openai"})
original_doc = {"memory": "test", "id": "123"}
result = reranker.rerank("query", [original_doc])
assert "rerank_score" not in original_doc
assert "rerank_score" in result[0]
+44 -1
View File
@@ -60,7 +60,7 @@ def memory_custom_instance():
config = MemoryConfig(
version="v1.1",
custom_fact_extraction_prompt="custom prompt extracting memory",
custom_fact_extraction_prompt="custom prompt extracting memory in json format",
custom_update_memory_prompt="custom prompt determining memory update",
)
config.graph_store.config = {"some_config": "value"}
@@ -196,12 +196,15 @@ def test_delete_all(memory_instance, version, enable_graph):
memory_instance.enable_graph = enable_graph
mock_memories = [Mock(id="1"), Mock(id="2")]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
memory_instance.vector_store.reset = Mock()
memory_instance._delete_memory = Mock()
memory_instance.graph.delete_all = Mock()
result = memory_instance.delete_all(user_id="test_user")
assert memory_instance._delete_memory.call_count == 2
# Ensure the collection is NOT dropped — only matched memories should be removed
memory_instance.vector_store.reset.assert_not_called()
if enable_graph:
memory_instance.graph.delete_all.assert_called_once_with({"user_id": "test_user"})
@@ -296,3 +299,43 @@ def test_custom_prompts(memory_custom_instance):
messages=[{"role": "user", "content": mock_get_update_memory_messages.return_value}],
response_format={"type": "json_object"},
)
def test_no_telemetry_vector_store_when_disabled():
"""VectorStoreFactory should only be called once (for user data) when telemetry is disabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", False),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
config = MemoryConfig(version="v1.1")
Memory(config)
# VectorStoreFactory.create should be called exactly once — for user data only, not telemetry
assert mock_vector_store.create.call_count == 1
def test_telemetry_vector_store_created_when_enabled():
"""VectorStoreFactory should be called twice (user data + telemetry) when telemetry is enabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", True),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
config = MemoryConfig(version="v1.1")
Memory(config)
# VectorStoreFactory.create should be called twice — user data + telemetry
assert mock_vector_store.create.call_count == 2
+76 -1
View File
@@ -1,9 +1,11 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from mem0 import Memory
from mem0.configs.base import MemoryConfig
from mem0.memory.utils import normalize_facts
class MockVectorMemory:
@@ -244,4 +246,77 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory,
assert len(result) == 2
assert result[0]["memory"] == "Memory 1"
assert result[1]["memory"] == "Memory 2"
assert result[1]["memory"] == "Memory 2"
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Repro for: 'list' object has no attribute 'replace' on infer=true.
When an LLM (especially smaller models like llama3.1:8b) returns facts as
objects ({"fact": "..."} or {"text": "..."}) instead of plain strings,
the embedding model's .replace() call crashes with AttributeError.
"""
mock_embedder = MagicMock()
mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw(
AttributeError("'dict' object has no attribute 'replace'")
) if not isinstance(text, str) else [0.1, 0.2, 0.3]
mock_embedder_factory.return_value = mock_embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_factory.return_value = mock_vector_store
# LLM returns malformed facts: dicts instead of strings
malformed_response = json.dumps({
"facts": [
{"fact": "User likes Python"},
{"text": "User is a developer"},
]
})
mock_llm = MagicMock()
mock_llm.generate_response.return_value = malformed_response
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
# This should NOT raise AttributeError
memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like Python and I'm a developer"}],
metadata={"user_id": "test_user"},
filters={"user_id": "test_user"},
infer=True,
)
def test_normalize_facts_plain_strings():
assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"]
def test_normalize_facts_dict_with_fact_key():
assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"]
def test_normalize_facts_dict_with_text_key():
assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"]
def test_normalize_facts_mixed():
raw = [
"plain string",
{"fact": "from fact key"},
{"text": "from text key"},
]
assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"]
def test_normalize_facts_filters_empty_strings():
assert normalize_facts(["", "valid", ""]) == ["valid"]