Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14470d6bb | ||
|
|
5154174342 | ||
|
|
577a5a2feb | ||
|
|
d7a34c24dd | ||
|
|
214d2a1d0d | ||
|
|
f0eb9e091f | ||
|
|
3cdcb6564c | ||
|
|
336fbce60a | ||
|
|
9eb5b9ed29 |
@@ -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/
|
||||
@@ -0,0 +1,766 @@
|
||||
---
|
||||
title: MiroFish Swarm Memory
|
||||
description: "Build a multi-agent swarm simulation with graph-powered memory using Mem0 and MiroFish patterns."
|
||||
---
|
||||
|
||||
<Snippet file="blank-notif.mdx" />
|
||||
|
||||
Build a multi-agent swarm simulation with graph-powered memory using Mem0 OSS and [MiroFish](https://github.com/666ghj/MiroFish) patterns. MiroFish is a graph-centric system — it extracts entities and relationships from documents, builds a knowledge graph, and queries it throughout its pipeline. Mem0's Graph Memory is a natural replacement for its Zep Cloud integration.
|
||||
|
||||
<Note>
|
||||
This cookbook demonstrates the **core memory patterns** using a simplified simulation. MiroFish's actual architecture uses a factory pattern (`memory_factory.py`) with abstract providers, batch buffering with retries in `ZepGraphMemoryUpdater`, and IPC-based agent interviews. This cookbook focuses on the Mem0 API integration points — wrap these calls in your own retry/batch logic for production use.
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
This cookbook implements a **Housing Policy Prediction Simulation** following MiroFish's five-stage workflow:
|
||||
|
||||
1. **Graph Building** — Ingest seed documents, extract entities and relationships
|
||||
2. **Environment Setup** — Query the knowledge graph to enrich agent profiles
|
||||
3. **Simulation** — Track agent interactions with per-agent memory isolation
|
||||
4. **Report Generation** — Semantic search + graph traversal for analysis
|
||||
5. **Deep Interaction** — Query post-simulation memory and relationships (MiroFish also supports live agent interviews via IPC — not covered here)
|
||||
|
||||
Three agents debate a housing policy reform:
|
||||
- **Mayor Chen** — Policy advocate pushing for zoning reform
|
||||
- **Wang (Homeowner)** — Opposition leader organizing resistance
|
||||
- **Professor Li** — Academic providing data-driven analysis
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install "mem0ai[graph]"
|
||||
```
|
||||
|
||||
You need a graph backend. Choose one:
|
||||
|
||||
| Backend | Setup | Best for |
|
||||
|---|---|---|
|
||||
| **Neo4j Aura** (free tier) | [Sign up](https://neo4j.com/product/auradb/), get Bolt URI | Production, closest to Zep |
|
||||
| **Neo4j Docker** | `docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5` | Local development |
|
||||
| **Kuzu** (embedded) | No setup needed — runs in-process | Quick testing, zero dependencies |
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
|
||||
# Option A: Neo4j Docker (local development)
|
||||
docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
|
||||
# Option B: Neo4j Aura (production — free tier available)
|
||||
export NEO4J_URL="neo4j+s://<your-instance>.databases.neo4j.io"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="your-aura-password"
|
||||
|
||||
# Option C: Kuzu (zero setup — auto-detected when NEO4J_URL is not set)
|
||||
# No exports needed
|
||||
```
|
||||
|
||||
## Complete Implementation
|
||||
|
||||
```python
|
||||
"""
|
||||
MiroFish Swarm Prediction Simulation with Mem0 Graph Memory
|
||||
|
||||
MiroFish uses Zep Cloud as its knowledge graph backend. This implementation
|
||||
replaces Zep with Mem0 OSS Graph Memory, which provides:
|
||||
- Automatic entity extraction from text
|
||||
- Relationship mining (source → relationship → destination triples)
|
||||
- Combined vector + graph search returning memories AND relations
|
||||
- Per-agent isolation via run_id
|
||||
- Self-hosted with no node caps
|
||||
|
||||
Follows MiroFish's 5-stage pipeline:
|
||||
1. Graph Building - Ingest seed documents, extract entities
|
||||
2. Environment Setup - Query graph to enrich agent profiles
|
||||
3. Simulation - Track agent actions with per-agent isolation
|
||||
4. Report Generation - Semantic + graph search for analysis
|
||||
5. Deep Interaction - Query post-simulation knowledge graph
|
||||
|
||||
Run:
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from mem0 import Memory
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# MiroFish Agent Action Types (matches OASIS simulation output)
|
||||
# ======================================================================
|
||||
|
||||
# Twitter actions
|
||||
TWITTER_ACTIONS = [
|
||||
"CREATE_POST", "LIKE_POST", "REPOST", "FOLLOW",
|
||||
"DO_NOTHING", "QUOTE_POST",
|
||||
]
|
||||
|
||||
# Reddit actions (superset — includes moderation + discovery)
|
||||
REDDIT_ACTIONS = [
|
||||
"LIKE_POST", "DISLIKE_POST", "CREATE_POST", "CREATE_COMMENT",
|
||||
"LIKE_COMMENT", "DISLIKE_COMMENT", "SEARCH_POSTS", "SEARCH_USER",
|
||||
"TREND", "REFRESH", "DO_NOTHING", "FOLLOW", "MUTE",
|
||||
]
|
||||
|
||||
# Combined (DO_NOTHING is skipped during memory storage)
|
||||
MIROFISH_ACTIONS = list(set(TWITTER_ACTIONS + REDDIT_ACTIONS) - {"DO_NOTHING"})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Graph Memory Configuration
|
||||
# ======================================================================
|
||||
|
||||
def build_config():
|
||||
"""Build Mem0 config with Graph Memory.
|
||||
|
||||
Uses Neo4j if credentials are set, otherwise falls back to Kuzu (embedded).
|
||||
"""
|
||||
neo4j_url = os.environ.get("NEO4J_URL")
|
||||
|
||||
# Shared config for LLM, embedder, and vector store
|
||||
base = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "gpt-4o-mini", "temperature": 0.1}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "text-embedding-3-small", "embedding_dims": 1536}
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "mirofish",
|
||||
"embedding_model_dims": 1536,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
custom_prompt = (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
)
|
||||
|
||||
if neo4j_url:
|
||||
base["graph_store"] = {
|
||||
"provider": "neo4j",
|
||||
"config": {
|
||||
"url": neo4j_url,
|
||||
"username": os.environ.get("NEO4J_USERNAME", "neo4j"),
|
||||
"password": os.environ.get("NEO4J_PASSWORD", "password"),
|
||||
},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
else:
|
||||
# Fallback: Kuzu embedded (no external services needed)
|
||||
print(" NEO4J_URL not set — using Kuzu (embedded) graph store")
|
||||
base["graph_store"] = {
|
||||
"provider": "kuzu",
|
||||
"config": {"db": "/tmp/mirofish_graph.kuzu"},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Simulation Engine
|
||||
# ======================================================================
|
||||
|
||||
class MiroFishSimulation:
|
||||
"""
|
||||
Multi-agent simulation with graph-powered memory.
|
||||
|
||||
Uses Mem0 Graph Memory to replace MiroFish's Zep Cloud integration:
|
||||
- Entities and relationships are extracted automatically from text
|
||||
- search() returns both semantic memories AND graph relations
|
||||
- Per-agent isolation via run_id
|
||||
- Project isolation via user_id
|
||||
"""
|
||||
|
||||
def __init__(self, project_id: str, config: dict):
|
||||
self.project_id = project_id
|
||||
self.memory = Memory.from_config(config)
|
||||
self.stats = {
|
||||
"documents_ingested": 0,
|
||||
"activities_recorded": 0,
|
||||
"rounds_completed": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 1: Graph Building — Seed Document Ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest_documents(self, documents: list[str]):
|
||||
"""Ingest seed documents and extract entities + relationships.
|
||||
|
||||
MiroFish equivalent: GraphBuilderService.build_graph()
|
||||
Zep equivalent: graph.add_batch() with episode polling
|
||||
|
||||
With Mem0 Graph Memory, each document is processed by the LLM
|
||||
to extract entities (people, orgs, policies) and relationships
|
||||
(supports, opposes, filed). These become nodes and edges in the
|
||||
graph store, alongside vector embeddings for semantic search.
|
||||
"""
|
||||
print(" Ingesting documents and building knowledge graph...")
|
||||
for i, doc in enumerate(documents):
|
||||
result = self.memory.add(
|
||||
[{"role": "user", "content": doc}],
|
||||
user_id=self.project_id,
|
||||
metadata={"stage": "graph_building", "source": "seed_document", "chunk_index": i}
|
||||
)
|
||||
# Graph Memory returns extracted relations
|
||||
relations = result.get("relations", {})
|
||||
added = relations.get("added_entities", [])
|
||||
if added:
|
||||
print(f" Doc {i}: extracted {len(added)} entities/relations")
|
||||
|
||||
self.stats["documents_ingested"] = len(documents)
|
||||
print(f" Ingested {len(documents)} documents")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 2: Environment Setup — Agent Profile Enrichment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enrich_agent_profile(self, agent_name: str, persona_query: str) -> dict:
|
||||
"""Search memory + graph for context relevant to an agent's persona.
|
||||
|
||||
MiroFish equivalent: OasisProfileGenerator using graph.search()
|
||||
|
||||
Returns both semantic memories and graph relations that can be
|
||||
injected into the agent's system prompt.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
persona_query,
|
||||
user_id=self.project_id,
|
||||
limit=10
|
||||
)
|
||||
facts = [r["memory"] for r in results.get("results", [])]
|
||||
relations = results.get("relations", [])
|
||||
|
||||
print(f" {agent_name}: {len(facts)} facts, {len(relations)} relations")
|
||||
return {"facts": facts, "relations": relations}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 3: Simulation — Agent Activity Tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_action(self, agent_id: str, agent_name: str,
|
||||
action_type: str, content: str,
|
||||
platform: str, round_num: int):
|
||||
"""Record a single agent action as a memory with graph extraction.
|
||||
|
||||
MiroFish equivalent: ZepGraphMemoryUpdater.add_activity()
|
||||
Zep equivalent: graph.add(type="text", data=episode_text)
|
||||
|
||||
Agent memories use run_id to group by agent (no assistant
|
||||
memories involved). Graph Memory extracts entities/relationships
|
||||
from the action content automatically.
|
||||
"""
|
||||
formatted = f"{agent_name} [{action_type}]: {content}"
|
||||
|
||||
self.memory.add(
|
||||
[{"role": "user", "content": formatted}],
|
||||
run_id=agent_id,
|
||||
metadata={
|
||||
"action_type": action_type,
|
||||
"platform": platform,
|
||||
"round": round_num,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
)
|
||||
self.stats["activities_recorded"] += 1
|
||||
|
||||
def run_round(self, round_num: int, activities: list[tuple]):
|
||||
"""Execute one simulation round."""
|
||||
print(f" Round {round_num}: {len(activities)} actions")
|
||||
for agent_id, agent_name, action_type, content, platform in activities:
|
||||
self.record_action(agent_id, agent_name, action_type, content, platform, round_num)
|
||||
self.stats["rounds_completed"] = max(self.stats["rounds_completed"], round_num)
|
||||
|
||||
def recall_agent_memory(self, agent_id: str, query: str) -> dict:
|
||||
"""Agent recalls its own memories mid-simulation.
|
||||
|
||||
Searches by run_id to match the scope used during add().
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=5
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 4: Report Generation — Semantic + Graph Retrieval
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def quick_search(self, query: str, limit: int = 10) -> dict:
|
||||
"""Semantic search + graph relations across all agents.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.quick_search()
|
||||
Returns both vector-matched memories and related graph triples.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
user_id=self.project_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def panorama_search(self) -> dict:
|
||||
"""Retrieve all memories + all graph relations.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.panorama_search()
|
||||
Returns the complete knowledge state for report generation.
|
||||
"""
|
||||
results = self.memory.get_all(user_id=self.project_id)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def agent_search(self, agent_id: str, query: str, limit: int = 10) -> dict:
|
||||
"""Search within a single agent's memory space."""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
"""Delete all memories and graph data for this simulation."""
|
||||
self.memory.delete_all(user_id=self.project_id)
|
||||
print(f" Cleaned up all memories for {self.project_id}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Run the full 5-stage pipeline
|
||||
# ======================================================================
|
||||
|
||||
def main():
|
||||
project_id = f"mirofish_housing_{int(time.time())}"
|
||||
config = build_config()
|
||||
sim = MiroFishSimulation(project_id=project_id, config=config)
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 1: Graph Building — Ingest seed documents
|
||||
# ==================================================================
|
||||
print("=" * 60)
|
||||
print("STAGE 1: Graph Building")
|
||||
print("=" * 60)
|
||||
|
||||
sim.ingest_documents([
|
||||
"The city council proposed a new zoning reform allowing higher "
|
||||
"density housing in suburban areas. Mayor Chen expressed strong "
|
||||
"support, citing a 40% housing shortage affecting young professionals. "
|
||||
"The reform would allow buildings up to 8 stories in previously "
|
||||
"restricted 3-story zones.",
|
||||
|
||||
"Local homeowners association president Wang opposes the reform, "
|
||||
"arguing it will decrease property values by 15-20%. The association "
|
||||
"represents 5,000 homeowners in the affected districts. Wang has "
|
||||
"organized three community meetings and collected 2,000 signatures.",
|
||||
|
||||
"Professor Li from Beijing University published research showing "
|
||||
"similar reforms in Shenzhen led to 15% price drops in existing "
|
||||
"homes but created 30% more affordable housing units within 3 years. "
|
||||
"The study covered 12 districts and 50,000 housing units.",
|
||||
])
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 2: Environment Setup — Enrich agent profiles
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 2: Environment Setup")
|
||||
print("=" * 60)
|
||||
|
||||
mayor_context = sim.enrich_agent_profile(
|
||||
"Mayor Chen",
|
||||
"Mayor Chen housing reform zoning policy"
|
||||
)
|
||||
wang_context = sim.enrich_agent_profile(
|
||||
"Wang",
|
||||
"Wang homeowner opposition property values petition"
|
||||
)
|
||||
li_context = sim.enrich_agent_profile(
|
||||
"Professor Li",
|
||||
"Professor Li research housing data Shenzhen"
|
||||
)
|
||||
|
||||
print("\n Example profile context for Mayor Chen:")
|
||||
for fact in mayor_context["facts"][:3]:
|
||||
print(f" Fact: {fact}")
|
||||
for rel in mayor_context["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 3: Simulation — Run agent interactions
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 3: Simulation")
|
||||
print("=" * 60)
|
||||
|
||||
# Round 1: Opening statements
|
||||
sim.run_round(1, [
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"This reform will create 10,000 new housing units by 2028. "
|
||||
"Young families deserve affordable homes. #HousingForAll",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Our property values will plummet! The council ignores the "
|
||||
"voices of 5,000 homeowners. #StopTheReform",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"New analysis: Shenzhen zoning data shows net positive outcomes "
|
||||
"after 3 years. Short-term pain, long-term gain for housing equity.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 2: Debate and interaction
|
||||
sim.run_round(2, [
|
||||
("wang_homeowner", "Wang", "CREATE_COMMENT",
|
||||
"Replied to Professor Li: 'Shenzhen is a tier-1 city with "
|
||||
"completely different dynamics. Your comparison is misleading.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "LIKE_POST",
|
||||
"Liked Professor Li's post about Shenzhen housing data.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_COMMENT",
|
||||
"Replied to Wang: 'The methodology controls for city tier "
|
||||
"and population density. I invite you to review the full dataset.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Data from @ProfLi confirms what we've been saying: zoning "
|
||||
"reform works. Let's move forward with evidence, not fear.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 3: Escalation and platform expansion
|
||||
sim.run_round(3, [
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Filing formal petition with 3,000 signatures against the "
|
||||
"zoning reform. Council meeting next Tuesday. All homeowners "
|
||||
"must attend!",
|
||||
"reddit"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Announcing public town hall on zoning reform this Saturday. "
|
||||
"All voices welcome. Data-driven decisions benefit everyone.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"Published full dataset and methodology on my university page. "
|
||||
"Transparency is essential for informed public debate.",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "FOLLOW",
|
||||
"Followed @MayorChen to monitor policy updates.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Mid-simulation: agent recalls own memory + graph
|
||||
print("\n Mid-simulation recall for Mayor Chen:")
|
||||
mayor_recall = sim.recall_agent_memory(
|
||||
"mayor_chen",
|
||||
"What positions have I taken on housing reform?"
|
||||
)
|
||||
for mem in mayor_recall["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in mayor_recall["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 4: Report Generation — Retrieve memories + graph for analysis
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 4: Report Generation")
|
||||
print("=" * 60)
|
||||
|
||||
# Quick search: targeted query
|
||||
print("\n Quick Search: 'opposition to housing reform'")
|
||||
opposition = sim.quick_search("opposition to housing reform", limit=5)
|
||||
for mem in opposition["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in opposition["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# Agent-specific search
|
||||
print("\n Agent Search: Wang's activities")
|
||||
wang_activities = sim.agent_search("wang_homeowner", "all actions and statements")
|
||||
for mem in wang_activities["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
|
||||
# Panorama: full overview
|
||||
print("\n Panorama Search: all memories + relations")
|
||||
panorama = sim.panorama_search()
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
for mem in panorama["memories"][:5]:
|
||||
print(f" Memory: {mem}")
|
||||
if len(panorama["memories"]) > 5:
|
||||
print(f" ... and {len(panorama['memories']) - 5} more")
|
||||
for rel in panorama["relations"][:5]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 5: Deep Interaction — Post-simulation queries
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 5: Deep Interaction")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"How did the debate evolve across the three rounds?",
|
||||
"What evidence was cited by each side?",
|
||||
"Who supports and who opposes the reform?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n Query: '{query}'")
|
||||
results = sim.quick_search(query, limit=3)
|
||||
for mem in results["memories"][:2]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in results["relations"][:2]:
|
||||
src = rel.get("source", rel.get("source_node", "?"))
|
||||
edge = rel.get("relationship", rel.get("relation", "?"))
|
||||
dst = rel.get("destination", rel.get("destination_node", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# Summary
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("SIMULATION COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f" Project ID: {project_id}")
|
||||
print(f" Documents ingested: {sim.stats['documents_ingested']}")
|
||||
print(f" Activities tracked: {sim.stats['activities_recorded']}")
|
||||
print(f" Rounds completed: {sim.stats['rounds_completed']}")
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
|
||||
# Cleanup (uncomment to delete all memories + graph data)
|
||||
# sim.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("MiroFish Swarm Prediction Simulation powered by Mem0 Graph Memory\n")
|
||||
main()
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Graph Memory: The Right Fit for MiroFish
|
||||
|
||||
MiroFish's entire pipeline revolves around a **knowledge graph** — it extracts entities from documents, builds relationships, and queries the graph throughout simulation and reporting. Mem0's Graph Memory provides the same capabilities:
|
||||
|
||||
| MiroFish needs | Zep Cloud | Mem0 Graph Memory |
|
||||
|---|---|---|
|
||||
| **Entity extraction** | Built-in via Zep API | Automatic via LLM extraction |
|
||||
| **Relationship mining** | Graph edges | `(source) --[relationship]--> (destination)` triples |
|
||||
| **Semantic + keyword search** | Semantic + BM25 | Vector similarity + graph relation retrieval |
|
||||
| **Graph traversal** | Node/edge queries | `relations` array in search results |
|
||||
| **Per-agent isolation** | Single shared graph in MiroFish | Native `run_id` scoping |
|
||||
| **Self-hosting** | No (cloud only) | Yes — Neo4j, Memgraph, Kuzu, Neptune |
|
||||
| **Node/memory limits** | Capped on free tier | Unlimited (self-hosted) |
|
||||
|
||||
### How search() Returns Both Memories and Relations
|
||||
|
||||
When Graph Memory is enabled, every `search()` call returns two arrays:
|
||||
|
||||
```python
|
||||
results = memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Vector-matched memories (ordered by similarity)
|
||||
results["results"] # [{"memory": "...", "score": 0.85, ...}, ...]
|
||||
|
||||
# Graph relations connected to query entities
|
||||
results["relations"] # [{"source": "mayor_chen", "relationship": "supports", "destination": "zoning_reform"}, ...]
|
||||
```
|
||||
|
||||
This is what makes Mem0 Graph Memory a natural replacement for Zep — you get semantic search AND structured graph data in a single call.
|
||||
|
||||
### Per-Agent Memory Isolation
|
||||
|
||||
`user_id` scopes the simulation project. `run_id` tags individual agent actions at storage time (we use `run_id` instead of `agent_id` since no assistant memories are involved). Searches use `user_id` for project-wide retrieval:
|
||||
|
||||
```python
|
||||
# Store project-level memories (seed documents)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen supports the zoning reform."}],
|
||||
user_id="my_sim"
|
||||
)
|
||||
|
||||
# Store agent-specific memories (simulation actions)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen [CREATE_POST]: Reform works!"}],
|
||||
run_id="mayor_chen"
|
||||
)
|
||||
|
||||
# Search project-level memories (seed docs)
|
||||
memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Search agent-specific memories (actions stored with run_id)
|
||||
memory.search("housing reform", run_id="mayor_chen")
|
||||
|
||||
# Get all project-level memories + graph relations
|
||||
memory.get_all(user_id="my_sim")
|
||||
```
|
||||
|
||||
<Note>
|
||||
Use `user_id` for project-level data (seed documents) and `run_id` for agent actions — both for `add()` and `search()`. Always match the scope: if you `add()` with `run_id`, `search()` with `run_id`. Use the message list format `[{"role": "user", "content": "..."}]` for all `add()` calls — it works on both OSS and Cloud.
|
||||
</Note>
|
||||
|
||||
### Stage Mapping
|
||||
|
||||
| MiroFish Stage | What Happens | Mem0 Graph Memory Call |
|
||||
|---|---|---|
|
||||
| **1. Graph Building** | Ingest docs, extract entities | `memory.add(doc, user_id=project)` — entities/relations extracted automatically |
|
||||
| **2. Environment Setup** | Enrich agent personas from graph | `memory.search(query, user_id=project)` — returns facts + relations |
|
||||
| **3. Simulation** | Track per-agent actions | `memory.add(messages, run_id=agent)` |
|
||||
| **3. Simulation** | Mid-round recall | `memory.search(query, run_id=agent)` |
|
||||
| **4. Report Generation** | Targeted analysis | `memory.search(query, user_id=project)` — memories + graph |
|
||||
| **4. Report Generation** | Full overview | `memory.get_all(user_id=project)` — all memories + all relations |
|
||||
| **5. Deep Interaction** | Follow-up queries | `memory.search(query, user_id=project)` |
|
||||
|
||||
### Zep-to-Mem0 Migration Reference
|
||||
|
||||
For developers replacing MiroFish's Zep integration. Note that Mem0 Graph Memory covers the core graph operations but some Zep features have no direct equivalent — see caveats below.
|
||||
|
||||
| MiroFish Service | Zep Call | Mem0 Graph Memory Equivalent | Caveat |
|
||||
|---|---|---|---|
|
||||
| GraphBuilderService | `client.graph.create()` | Implicit on first `memory.add()` | |
|
||||
| GraphBuilderService | `client.graph.set_ontology()` | `custom_prompt` in graph_store config | Freeform text, not a typed schema like Zep's `EntityModel`/`EdgeModel` |
|
||||
| GraphBuilderService | `client.graph.add_batch(episodes)` | `memory.add()` per chunk | No batch API — call per chunk |
|
||||
| GraphBuilderService | `client.graph.episode.get(uuid)` | Not needed (add is synchronous in OSS) | |
|
||||
| GraphBuilderService | `client.graph.delete(id)` | `memory.delete_all(user_id=...)` | |
|
||||
| ZepEntityReader | `client.graph.node.get_by_graph_id()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepEntityReader | `client.graph.node.get(uuid)` | `memory.search(entity_name, user_id=...)` | Semantic search, not exact ID lookup |
|
||||
| ZepEntityReader | `client.graph.node.get_entity_edges()` | `memory.search(entity_name, user_id=...)` → `relations` | Returns all matching relations, not edges for a specific node |
|
||||
| ZepGraphMemoryUpdater | `client.graph.add(type="text")` | `memory.add(messages, run_id=...)` | No batch buffering or retry — implement in your wrapper |
|
||||
| ZepToolsService | `search_graph(query, scope)` | `memory.search(query, user_id=...)` → memories + relations | |
|
||||
| ZepToolsService | `get_entities()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepToolsService | Panorama (all nodes + edges) | `memory.get_all(user_id=...)` | No temporal fact separation (active vs historical) |
|
||||
| ZepToolsService | InsightForge (multi-query decomposition) | Not available | Implement LLM-driven sub-query decomposition in your own ReportAgent |
|
||||
| OasisProfileGenerator | `client.graph.search()` | `memory.search(query, user_id=...)` | |
|
||||
|
||||
<Note>
|
||||
**What Mem0 Graph Memory does not cover**: Zep's typed ontology schemas (`EntityModel`, `EdgeModel`), temporal fact lifecycle (`valid_at`/`invalid_at`/`expired_at`), single-node-by-ID lookup, and InsightForge's multi-query decomposition. For InsightForge-like functionality, implement sub-query logic in your own ReportAgent using `memory.search()` as the retrieval primitive.
|
||||
</Note>
|
||||
|
||||
### Custom Extraction Prompts
|
||||
|
||||
Guide what entities and relationships Mem0 extracts — analogous to (but less structured than) Zep's `set_ontology()`:
|
||||
|
||||
```python
|
||||
config = {
|
||||
"graph_store": {
|
||||
"provider": "neo4j",
|
||||
"config": {"url": "...", "username": "...", "password": "..."},
|
||||
"custom_prompt": (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action Types
|
||||
|
||||
MiroFish's OASIS engine produces these agent action types. Format them as natural language when storing. Skip `DO_NOTHING` actions (no memory value). `TREND` and `REFRESH` are Reddit-only discovery actions — store if you want to track browsing behavior.
|
||||
|
||||
| Action Type | Platform | Example Memory Content |
|
||||
|---|---|---|
|
||||
| `CREATE_POST` | Both | `"Mayor Chen [CREATE_POST]: This reform will create 10,000 units"` |
|
||||
| `CREATE_COMMENT` | Reddit | `"Wang [CREATE_COMMENT]: Replied to Prof Li: 'Your data is misleading'"` |
|
||||
| `LIKE_POST` | Both | `"Mayor Chen [LIKE_POST]: Liked Prof Li's post about Shenzhen data"` |
|
||||
| `REPOST` | Twitter | `"Prof Li [REPOST]: Reposted Mayor Chen's town hall announcement"` |
|
||||
| `FOLLOW` | Both | `"Wang [FOLLOW]: Followed @MayorChen"` |
|
||||
| `QUOTE_POST` | Twitter | `"Mayor Chen [QUOTE_POST]: 'Data confirms reform works' quoting Prof Li"` |
|
||||
| `DISLIKE_POST` | Reddit | `"Wang [DISLIKE_POST]: Downvoted Mayor Chen's reform post"` |
|
||||
| `TREND` | Reddit | `"Prof Li [TREND]: Browsed trending topics"` |
|
||||
| `DO_NOTHING` | Both | Skip — no memory value |
|
||||
|
||||
## Running the Example
|
||||
|
||||
```bash
|
||||
# Option A: Neo4j (production)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
|
||||
# Option B: Kuzu (zero dependencies, just need OpenAI key)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
python mirofish_swarm_memory.py # auto-detects missing NEO4J_URL, uses Kuzu
|
||||
```
|
||||
|
||||
<Note>
|
||||
Exact output varies as Mem0 automatically extracts and deduplicates entities. The specific relations and memory counts depend on LLM extraction quality.
|
||||
</Note>
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Unique `user_id` per simulation** — Use timestamps or UUIDs (e.g., `mirofish_housing_1742198400`) to prevent memory collisions between runs
|
||||
2. **Always set `run_id` for agent actions** — Per-agent isolation prevents memory cross-contamination between agents
|
||||
3. **Use `custom_prompt`** — Guide entity extraction to capture domain-specific relationships (people, policies, stances)
|
||||
4. **Format actions as natural language** — `"Mayor Chen [CREATE_POST]: content"` extracts better entities than raw JSON
|
||||
5. **Query relations for reports** — The `relations` array in search results gives structured `(source, relationship, destination)` triples for building analytical reports
|
||||
6. **Cleanup old simulations** — Call `delete_all(user_id=...)` when a simulation run is no longer needed
|
||||
|
||||
## Resources
|
||||
|
||||
- [MiroFish GitHub](https://github.com/666ghj/MiroFish) — Source code and setup guide
|
||||
- [MiroFish Documentation](https://deepwiki.com/666ghj/MiroFish) — Full framework docs
|
||||
- [Mem0 Graph Memory](/open-source/features/graph-memory) — Graph Memory documentation
|
||||
- [Mem0 Documentation](https://docs.mem0.ai/) — Full API reference
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Graph Memory" icon="network-wired" href="/open-source/features/graph-memory">
|
||||
Full Graph Memory documentation with provider setup.
|
||||
</Card>
|
||||
<Card title="MiroFish GitHub" icon="fish" href="https://github.com/666ghj/MiroFish">
|
||||
MiroFish source code and setup guide.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
+2
-1
@@ -375,7 +375,8 @@
|
||||
"cookbooks/frameworks/multimodal-retrieval",
|
||||
"cookbooks/frameworks/eliza-os-character",
|
||||
"cookbooks/frameworks/chrome-extension",
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp"
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp",
|
||||
"cookbooks/frameworks/mirofish-swarm-memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
pnpm-lock.yaml
|
||||
@@ -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 .",
|
||||
|
||||
@@ -18,9 +18,26 @@ export type {
|
||||
AllUsers,
|
||||
User,
|
||||
FeedbackPayload,
|
||||
Feedback,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
export { Feedback, WebhookEvent } from "./mem0.types";
|
||||
|
||||
// Export the main client
|
||||
export { MemoryClient };
|
||||
export default MemoryClient;
|
||||
|
||||
// Export structured exceptions
|
||||
export {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
} from "../common/exceptions";
|
||||
|
||||
export type { MemoryErrorOptions } from "../common/exceptions";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -155,7 +156,7 @@ export default class MemoryClient {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new APIError(`API request failed: ${errorData}`);
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
}
|
||||
const jsonResponse = await response.json();
|
||||
return jsonResponse;
|
||||
@@ -200,8 +201,8 @@ export default class MemoryClient {
|
||||
if (project_id && !this.projectId) this.projectId = project_id;
|
||||
if (user_email) this.telemetryId = user_email;
|
||||
} catch (error: any) {
|
||||
// Convert generic errors to APIError with meaningful messages
|
||||
if (error instanceof APIError) {
|
||||
// Pass through structured exceptions and APIError
|
||||
if (error instanceof MemoryError || error instanceof APIError) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new APIError(
|
||||
@@ -310,7 +311,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_all", [payloadKeys]);
|
||||
const { api_version, page, page_size, ...otherOptions } = options!;
|
||||
const { api_version, page, page_size, ...otherOptions } = options ?? {};
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
otherOptions.org_name = this.organizationName;
|
||||
otherOptions.project_name = this.projectName;
|
||||
@@ -361,7 +362,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("search", [payloadKeys]);
|
||||
const { api_version, ...otherOptions } = options!;
|
||||
const { api_version, ...otherOptions } = options ?? {};
|
||||
const payload = { query, ...otherOptions };
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
payload.org_name = this.organizationName;
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface PromptUpdatePayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
enum WebhookEvent {
|
||||
export enum WebhookEvent {
|
||||
MEMORY_ADDED = "memory_add",
|
||||
MEMORY_UPDATED = "memory_update",
|
||||
MEMORY_DELETED = "memory_delete",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Test helpers for MemoryClient unit tests.
|
||||
* Provides mock fetch, factory functions, and constants.
|
||||
*/
|
||||
|
||||
// ─── Mock Fetch ──────────────────────────────────────────
|
||||
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock fetch function that matches URL patterns to responses.
|
||||
* Patterns are matched using string includes, sorted longest-first
|
||||
* so more specific routes (e.g. /v1/memories/search/) win over
|
||||
* broader ones (e.g. /v1/memories/) regardless of insertion order.
|
||||
*/
|
||||
export function createMockFetch(
|
||||
responses: Map<string, MockResponse>,
|
||||
): jest.Mock {
|
||||
return jest.fn(
|
||||
async (url: string | URL | Request, _options?: RequestInit) => {
|
||||
const urlStr =
|
||||
typeof url === "string"
|
||||
? url
|
||||
: url instanceof URL
|
||||
? url.toString()
|
||||
: url.url;
|
||||
|
||||
// Sort patterns longest-first so specific routes match before broad ones
|
||||
const sortedPatterns = [...responses.entries()].sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
);
|
||||
|
||||
for (const [pattern, response] of sortedPatterns) {
|
||||
if (urlStr.includes(pattern)) {
|
||||
return {
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
statusText: response.status === 200 ? "OK" : "Error",
|
||||
json: async () => response.body,
|
||||
text: async () =>
|
||||
typeof response.body === "string"
|
||||
? response.body
|
||||
: JSON.stringify(response.body),
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({ error: "Not found" }),
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Factory Functions ───────────────────────────────────
|
||||
|
||||
export interface MockMemory {
|
||||
id: string;
|
||||
memory?: string;
|
||||
data?: { memory: string } | null;
|
||||
event?: string;
|
||||
user_id?: string;
|
||||
agent_id?: string | null;
|
||||
app_id?: string | null;
|
||||
run_id?: string | null;
|
||||
hash?: string;
|
||||
categories?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
score?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
export function createMockMemory(
|
||||
overrides: Partial<MockMemory> = {},
|
||||
): MockMemory {
|
||||
return {
|
||||
id: "mem_test_123",
|
||||
memory: "Test memory content",
|
||||
user_id: "user_test",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
categories: [],
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockMemoryHistory {
|
||||
id: string;
|
||||
memory_id: string;
|
||||
input: Array<{ role: string; content: string }>;
|
||||
old_memory: string | null;
|
||||
new_memory: string | null;
|
||||
user_id: string;
|
||||
categories: string[];
|
||||
event: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function createMockMemoryHistory(
|
||||
overrides: Partial<MockMemoryHistory> = {},
|
||||
): MockMemoryHistory {
|
||||
return {
|
||||
id: "hist_test_123",
|
||||
memory_id: "mem_test_123",
|
||||
input: [{ role: "user", content: "test" }],
|
||||
old_memory: null,
|
||||
new_memory: "Test memory",
|
||||
user_id: "user_test",
|
||||
categories: [],
|
||||
event: "ADD",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_memories: number;
|
||||
owner: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
|
||||
return {
|
||||
id: "user_123",
|
||||
name: "test_user",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
total_memories: 5,
|
||||
owner: "owner_123",
|
||||
type: "user",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockAllUsers {
|
||||
count: number;
|
||||
results: MockUser[];
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export function createMockAllUsers(users: MockUser[] = []): MockAllUsers {
|
||||
return {
|
||||
count: users.length,
|
||||
results: users,
|
||||
next: null,
|
||||
previous: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
export const TEST_API_KEY = "test-api-key-12345";
|
||||
export const TEST_HOST = "https://api.test.mem0.ai";
|
||||
export const TEST_ORG_ID = "org_test_123";
|
||||
export const TEST_PROJECT_ID = "proj_test_456";
|
||||
|
||||
export const MOCK_PING_RESPONSE = {
|
||||
status: "ok",
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
user_email: "test@example.com",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standard set of mock responses for common MemoryClient operations.
|
||||
* Returns a Map that can be extended with additional patterns before passing to createMockFetch.
|
||||
*/
|
||||
export function createStandardMockResponses(): Map<string, MockResponse> {
|
||||
const responses = new Map<string, MockResponse>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
return responses;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — batchUpdate, batchDelete.
|
||||
* Tests verify payload transformation (memoryId → memory_id, string → object).
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── batchUpdate() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchUpdate()", () => {
|
||||
test("sends PUT to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([{ memoryId: "mem_1", text: "updated 1" }]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("transforms memoryId to memory_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([
|
||||
{ memoryId: "mem_1", text: "updated 1" },
|
||||
{ memoryId: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memories).toEqual([
|
||||
{ memory_id: "mem_1", text: "updated 1" },
|
||||
{ memory_id: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batchDelete() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchDelete()", () => {
|
||||
test("sends DELETE to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1"]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "DELETE")).toBeDefined();
|
||||
});
|
||||
|
||||
test("wraps string IDs into {memory_id} objects", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1", "mem_2", "mem_3"]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([
|
||||
{ memory_id: "mem_1" },
|
||||
{ memory_id: "mem_2" },
|
||||
{ memory_id: "mem_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* MemoryClient unit tests — add, get, getAll, update, delete, deleteAll, history.
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory, MemoryHistory } from "../mem0.types";
|
||||
import {
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── add() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - add()", () => {
|
||||
test("sends POST to /v1/memories/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "Hello" }], { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes messages in request body", async () => {
|
||||
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add(messages, { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual(messages);
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], {
|
||||
user_id: "user_1",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("user_1");
|
||||
});
|
||||
|
||||
test("attaches org_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("attaches project_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sends empty messages array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - get()", () => {
|
||||
test("sends GET to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws on 404 with error message from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/nonexistent/", {
|
||||
status: 404,
|
||||
body: "Memory not found",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getAll()", () => {
|
||||
test("uses v2 POST endpoint when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("uses v1 GET endpoint by default with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("appends page and page_size to URL as query params", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({
|
||||
user_id: "u1",
|
||||
api_version: "v2",
|
||||
page: 2,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("page="),
|
||||
);
|
||||
expect(call![0]).toContain("page=2");
|
||||
expect(call![0]).toContain("page_size=25");
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.getAll();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - update()", () => {
|
||||
test("sends PUT to /v1/memories/:id/ with text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { text: "Updated text" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(call).toBeDefined();
|
||||
expect(getFetchBody(call!).text).toBe("Updated text");
|
||||
});
|
||||
|
||||
test("sends metadata in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { metadata: { priority: "high" } });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).metadata).toEqual({ priority: "high" });
|
||||
});
|
||||
|
||||
test("sends timestamp in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { timestamp: 1710600000 });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("includes all fields when text + metadata + timestamp provided", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", {
|
||||
text: "Updated",
|
||||
metadata: { source: "test" },
|
||||
timestamp: 1710600000,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.text).toBe("Updated");
|
||||
expect(body.metadata).toEqual({ source: "test" });
|
||||
expect(body.timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("throws when no fields provided", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.update("mem_123", {})).rejects.toThrow(
|
||||
"At least one of text, metadata, or timestamp must be provided",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - delete()", () => {
|
||||
test("sends DELETE to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: { message: "Memory deleted successfully" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.delete("mem_123");
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem_123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteAll()", () => {
|
||||
test("sends DELETE to /v1/memories/ with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("URL-encodes special characters in user_id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "user@email.com" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - history()", () => {
|
||||
test("sends GET to /v1/memories/:id/history/", async () => {
|
||||
const historyEntries = [
|
||||
createMockMemoryHistory({
|
||||
memory_id: "mem_123",
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "I am Alex",
|
||||
}),
|
||||
];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", {
|
||||
status: 200,
|
||||
body: historyEntries,
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.history("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/history/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles empty history without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: MemoryHistory[] = await client.history("mem_123");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* MemoryClient E2E integration tests.
|
||||
*
|
||||
* These tests exercise realistic usage patterns with mock HTTP responses.
|
||||
* Skipped by default — run with MEM0_RUN_E2E=1 to enable.
|
||||
*
|
||||
* Run: MEM0_RUN_E2E=1 npx jest memoryClient.e2e.test.ts
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type {
|
||||
Memory,
|
||||
AllUsers,
|
||||
MemoryHistory,
|
||||
User,
|
||||
Messages,
|
||||
} from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
String(args[0] ?? "").match(
|
||||
/Telemetry|Failed to initialize|Failed to capture/,
|
||||
)
|
||||
)
|
||||
return;
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return;
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// Shared test data matching realistic API responses
|
||||
const userId = "test_user_abc123";
|
||||
const memoryId = "mem_550e8400";
|
||||
|
||||
const mockMemory = createMockMemory({
|
||||
id: memoryId,
|
||||
memory: "Alex is a vegetarian",
|
||||
user_id: userId,
|
||||
event: "ADD",
|
||||
data: { memory: "Alex is a vegetarian" },
|
||||
categories: ["personal"],
|
||||
metadata: null,
|
||||
created_at: "2026-03-17T10:00:00Z",
|
||||
updated_at: "2026-03-17T10:00:00Z",
|
||||
score: 0.95,
|
||||
});
|
||||
|
||||
function mockFetchForTest(
|
||||
extraPatterns?: Record<string, { status: number; body: unknown }>,
|
||||
) {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
responses.set("/v1/memories/search/", { status: 200, body: [mockMemory] });
|
||||
responses.set("/v2/memories/search/", { status: 200, body: [mockMemory] });
|
||||
responses.set("/history/", {
|
||||
status: 200,
|
||||
body: [
|
||||
createMockMemoryHistory({
|
||||
memory_id: memoryId,
|
||||
user_id: userId,
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "Alex is a vegetarian",
|
||||
}),
|
||||
],
|
||||
});
|
||||
responses.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([
|
||||
createMockUser({ id: "entity_1", name: userId, type: "user" }),
|
||||
]),
|
||||
});
|
||||
// This must come last — it's a broad pattern that matches /v1/memories/:id/ and /v1/memories/
|
||||
responses.set("/v1/memories/", { status: 200, body: [mockMemory] });
|
||||
|
||||
if (extraPatterns) {
|
||||
for (const [k, v] of Object.entries(extraPatterns)) {
|
||||
responses.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
global.fetch = createMockFetch(responses);
|
||||
}
|
||||
|
||||
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
|
||||
|
||||
describeOrSkip("MemoryClient API (E2E)", () => {
|
||||
beforeEach(() => mockFetchForTest());
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user" as const, content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant" as const, content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
describe("add messages", () => {
|
||||
let res: Memory[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
res = await client.add(messages1, { user_id: userId });
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
});
|
||||
|
||||
test("first message has a string id", () => {
|
||||
expect(typeof res[0].id).toBe("string");
|
||||
});
|
||||
|
||||
test("first message has a string data.memory", () => {
|
||||
expect(typeof res[0].data?.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first message has a string event", () => {
|
||||
expect(typeof res[0].event).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve specific memory by ID", () => {
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const memories = await client.getAll({ user_id: userId });
|
||||
memory = Array.isArray(memories) ? memories[0] : memories;
|
||||
});
|
||||
|
||||
test("returns string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("returns string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("each category is a string", () => {
|
||||
if (Array.isArray(memory.categories)) {
|
||||
expect(
|
||||
memory.categories.every((c: string) => typeof c === "string"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("updated_at is a valid date", () => {
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve all users", () => {
|
||||
let allUsers: AllUsers;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
allUsers = await client.users();
|
||||
});
|
||||
|
||||
test("count is a number", () => {
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
});
|
||||
|
||||
test("first user has a string id", () => {
|
||||
expect(typeof allUsers.results[0].id).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string name", () => {
|
||||
expect(typeof allUsers.results[0].name).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string created_at", () => {
|
||||
expect(typeof allUsers.results[0].created_at).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a string updated_at", () => {
|
||||
expect(typeof allUsers.results[0].updated_at).toBe("string");
|
||||
});
|
||||
|
||||
test("first user has a number total_memories", () => {
|
||||
expect(typeof allUsers.results[0].total_memories).toBe("number");
|
||||
});
|
||||
|
||||
test("first user has a string type", () => {
|
||||
expect(typeof allUsers.results[0].type).toBe("string");
|
||||
});
|
||||
|
||||
test("results contain an entity matching userId", () => {
|
||||
const entity = allUsers.results.find(
|
||||
(user: User) => user.name === userId,
|
||||
);
|
||||
expect(entity).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test("matched entity has a string id", () => {
|
||||
const entity = allUsers.results.find(
|
||||
(user: User) => user.name === userId,
|
||||
);
|
||||
expect(typeof entity?.id).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve all memories for the user", () => {
|
||||
let memories: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
memories = await client.getAll({ user_id: userId });
|
||||
memory = memories[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(memories)).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory has a string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first memory user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("first memory metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first memory created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("first memory updated_at is a valid date", () => {
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search with API version 2", () => {
|
||||
let results: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
results = await client.search("What do you know about me?", {
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
});
|
||||
memory = results[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("first result has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first result metadata is null or an object", () => {
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first result categories is an array or null", () => {
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("first result created_at is a valid date", () => {
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
test("first result has a number score", () => {
|
||||
expect(typeof memory.score).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search with API version 1", () => {
|
||||
let results: Memory[];
|
||||
let memory: Memory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
results = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
memory = results[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("first result has a string id", () => {
|
||||
expect(typeof memory.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string memory content", () => {
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("first result has a string user_id", () => {
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first result user_id matches the requested userId", () => {
|
||||
expect(memory.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("first result has a number score", () => {
|
||||
expect(typeof memory.score).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieve history of a specific memory", () => {
|
||||
let history: MemoryHistory[];
|
||||
let entry: MemoryHistory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
history = await client.history(memoryId);
|
||||
entry = history[0];
|
||||
});
|
||||
|
||||
test("returns an array", () => {
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
});
|
||||
|
||||
test("first entry has a string id", () => {
|
||||
expect(typeof entry.id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry has a string memory_id", () => {
|
||||
expect(typeof entry.memory_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry has a string user_id", () => {
|
||||
expect(typeof entry.user_id).toBe("string");
|
||||
});
|
||||
|
||||
test("first entry user_id matches the requested userId", () => {
|
||||
expect(entry.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
test("old_memory is null or a string", () => {
|
||||
expect(
|
||||
entry.old_memory === null || typeof entry.old_memory === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("new_memory is null or a string", () => {
|
||||
expect(
|
||||
entry.new_memory === null || typeof entry.new_memory === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("created_at is a valid date", () => {
|
||||
expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
test("updated_at is a valid date", () => {
|
||||
expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
test("event is one of ADD, UPDATE, DELETE, NOOP", () => {
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event);
|
||||
});
|
||||
|
||||
test("ADD event has null old_memory", () => {
|
||||
expect(entry.old_memory).toBeNull();
|
||||
});
|
||||
|
||||
test("ADD event has non-null new_memory", () => {
|
||||
expect(entry.new_memory).not.toBeNull();
|
||||
});
|
||||
|
||||
test("input is an array or null", () => {
|
||||
expect(Array.isArray(entry.input) || entry.input === null).toBe(true);
|
||||
});
|
||||
|
||||
test("each input item is an object", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(entry.input.every((i: Messages) => typeof i === "object")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("each input item has a string content", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(
|
||||
entry.input.every((i: Messages) => typeof i.content === "string"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("each input item has a valid role", () => {
|
||||
if (Array.isArray(entry.input)) {
|
||||
expect(
|
||||
entry.input.every((i: Messages) =>
|
||||
["user", "assistant"].includes(i.role),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete user", () => {
|
||||
test("returns success message", async () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "org_test",
|
||||
projectId: "proj_test",
|
||||
});
|
||||
client.client.delete = jest.fn().mockResolvedValue({
|
||||
data: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
|
||||
const result = await client.deleteUsers({ user_id: userId });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — constructor, validation, ping.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
MemoryNotFoundError,
|
||||
ValidationError,
|
||||
MemoryError,
|
||||
} from "../../common/exceptions";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_HOST,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
installConsoleSuppression,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Initialization", () => {
|
||||
beforeEach(() => setupMockFetch());
|
||||
|
||||
test("throws when API key is empty string", () => {
|
||||
expect(() => new MemoryClient({ apiKey: "" })).toThrow(
|
||||
"Mem0 API key is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is whitespace only", () => {
|
||||
expect(() => new MemoryClient({ apiKey: " " })).toThrow(
|
||||
"Mem0 API key cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is not a string", () => {
|
||||
expect(
|
||||
() => new MemoryClient({ apiKey: 123 as unknown as string }),
|
||||
).toThrow("Mem0 API key must be a string");
|
||||
});
|
||||
|
||||
test("sets default host to https://api.mem0.ai", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.host).toBe("https://api.mem0.ai");
|
||||
});
|
||||
|
||||
test("uses custom host when provided", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY, host: TEST_HOST });
|
||||
expect(client.host).toBe(TEST_HOST);
|
||||
});
|
||||
|
||||
test("sets organizationId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets Authorization header with Token prefix", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.headers["Authorization"]).toBe(`Token ${TEST_API_KEY}`);
|
||||
});
|
||||
|
||||
test("creates axios client with 60s timeout", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.client.defaults.timeout).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ping ────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - ping()", () => {
|
||||
test("sets organizationId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets telemetryId from user_email in response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.telemetryId).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("preserves constructor organizationId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe("my_org");
|
||||
});
|
||||
|
||||
test("preserves constructor projectId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe("my_proj");
|
||||
});
|
||||
|
||||
test("throws AuthenticationError on 401 response", async () => {
|
||||
const { AuthenticationError } = await import("../../common/exceptions");
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 401,
|
||||
body: "Invalid API key",
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: "bad-key" });
|
||||
await expect(client.ping()).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("throws on invalid (non-object) response format", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: "not an object" });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("Invalid response format");
|
||||
});
|
||||
|
||||
test("throws on status !== ok in response", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 200,
|
||||
body: { status: "error", message: "API Key is invalid" },
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("API Key is invalid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Error Handling", () => {
|
||||
test("404 throws MemoryNotFoundError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/gone/", { status: 404, body: "Memory not found" });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("gone")).rejects.toThrow(MemoryNotFoundError);
|
||||
await expect(client.get("gone")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
|
||||
test("500 throws MemoryError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/err/", {
|
||||
status: 500,
|
||||
body: "Internal server error",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("err")).rejects.toThrow(MemoryError);
|
||||
await expect(client.get("err")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
test("400 throws ValidationError with details from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/bad/", {
|
||||
status: 400,
|
||||
body: "Invalid request: user_id is required",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("bad")).rejects.toThrow(ValidationError);
|
||||
await expect(client.get("bad")).rejects.toThrow(
|
||||
"Invalid request: user_id is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("Authorization header is included in fetch calls", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_1/", {
|
||||
status: 200,
|
||||
body: { id: "mem_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_1");
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_1/"),
|
||||
);
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("network failure (fetch throws) is propagated", async () => {
|
||||
global.fetch = jest.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/v1/memories/net_err/")) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (urlStr.includes("/v1/ping/")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => MOCK_PING_RESPONSE,
|
||||
text: async () => JSON.stringify(MOCK_PING_RESPONSE),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("net_err")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getProject, updateProject, exports, feedback.
|
||||
* Tests verify request construction and validation behavior.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { Feedback } from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── getProject() ───────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getProject()", () => {
|
||||
test("throws when organizationId and projectId not set", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: { status: "ok" } });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
try {
|
||||
await client.ping();
|
||||
} catch {
|
||||
// ping might throw — but orgId stays null
|
||||
}
|
||||
|
||||
await expect(
|
||||
client.getProject({ fields: ["custom_instructions"] }),
|
||||
).rejects.toThrow("organizationId and projectId must be set");
|
||||
});
|
||||
|
||||
test("sends GET to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Be helpful" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getProject({ fields: ["custom_instructions"] });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/orgs/organizations/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("fields=custom_instructions");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProject() ────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - updateProject()", () => {
|
||||
test("sends PATCH to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes custom_instructions in PATCH body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(getFetchBody(call!).custom_instructions).toBe(
|
||||
"Updated instructions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── feedback() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - feedback()", () => {
|
||||
test("sends POST to /v1/feedback/ with payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes memory_id, feedback, and reason in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memory_id).toBe("mem_123");
|
||||
expect(body.feedback).toBe("POSITIVE");
|
||||
expect(body.feedback_reason).toBe("Very helpful");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Memory Exports ─────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Memory Exports", () => {
|
||||
test("createMemoryExport throws when missing filters or schema", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(
|
||||
client.createMemoryExport({
|
||||
filters: null as never,
|
||||
schema: null as never,
|
||||
}),
|
||||
).rejects.toThrow("Missing filters or schema");
|
||||
});
|
||||
|
||||
test("createMemoryExport sends POST to /v1/exports/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Export created", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory", "user_id"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createMemoryExport attaches org_id and project_id to body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Created", id: "exp_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/exports/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("getMemoryExport throws when missing both id and filters", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(client.getMemoryExport({} as never)).rejects.toThrow(
|
||||
"Missing memory_export_id or filters",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMemoryExport sends POST to /v1/exports/get/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/get/", {
|
||||
status: 200,
|
||||
body: { message: "Export data", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getMemoryExport({ memory_export_id: "exp_123" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/get/", "POST")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — search (v1/v2 routing, filters).
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory } from "../mem0.types";
|
||||
import { createMockMemory, TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - search()", () => {
|
||||
test("sends POST to /v1/memories/search/ by default", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes query in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).query).toBe("What is my name?");
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("u1");
|
||||
});
|
||||
|
||||
test("uses /v2/memories/search/ when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("passes filters through to the v2 API body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("query", {
|
||||
api_version: "v2",
|
||||
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.filters).toEqual({
|
||||
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("query");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty results array", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("nonexistent query", {
|
||||
user_id: "u1",
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
import { MemoryClient } from "../mem0";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const apiKey = process.env.MEM0_API_KEY || "";
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationId: "org_gRNd1RrQa4y52iK4tG8o59hXyVbaULikgq4kethC", projectId: "proj_7RfMkWs0PMgXYweGUNKqV9M9mgIRNt5XcupE7mSP" });
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationName: "saket-default-org", projectName: "default-project" });
|
||||
const client = new MemoryClient({ apiKey, host: "https://api.mem0.ai" });
|
||||
|
||||
// Generate a random string
|
||||
const randomString = () => {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
};
|
||||
|
||||
describe("MemoryClient API", () => {
|
||||
let userId: string, memoryId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
userId = randomString();
|
||||
});
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user", content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant", content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
it("should add messages successfully", async () => {
|
||||
const res = await client.add(messages1, { user_id: userId || "" });
|
||||
|
||||
// Validate the response contains an iterable list
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
|
||||
// Validate the fields of the first message in the response
|
||||
const message = res[0];
|
||||
expect(typeof message.id).toBe("string");
|
||||
expect(typeof message.data?.memory).toBe("string");
|
||||
expect(typeof message.event).toBe("string");
|
||||
|
||||
// Store the memory ID for later use
|
||||
memoryId = message.id;
|
||||
});
|
||||
|
||||
it("should retrieve the specific memory by ID", async () => {
|
||||
const memory = await client.get(memoryId);
|
||||
|
||||
// Validate that the memory fields have the correct types and values
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or any object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(Array.isArray(memory.categories) || memory.categories === null).toBe(
|
||||
true,
|
||||
);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
it("should retrieve all users successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
|
||||
// Validate the number of users is a number
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
|
||||
// Validate the structure of the first user
|
||||
const firstUser = allUsers.results[0];
|
||||
expect(typeof firstUser.id).toBe("string");
|
||||
expect(typeof firstUser.name).toBe("string");
|
||||
expect(typeof firstUser.created_at).toBe("string");
|
||||
expect(typeof firstUser.updated_at).toBe("string");
|
||||
expect(typeof firstUser.total_memories).toBe("number");
|
||||
expect(typeof firstUser.type).toBe("string");
|
||||
|
||||
// Find the user with the name matching userId
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
expect(entity).not.toBeUndefined();
|
||||
|
||||
// Store the entity ID for later use
|
||||
const entity_id = entity?.id;
|
||||
expect(typeof entity_id).toBe("string");
|
||||
});
|
||||
|
||||
it("should retrieve all memories for the user", async () => {
|
||||
const res3 = await client.getAll({ user_id: userId });
|
||||
|
||||
// Validate that res3 is an iterable list (array)
|
||||
expect(Array.isArray(res3)).toBe(true);
|
||||
|
||||
if (res3.length > 0) {
|
||||
// Iterate through the first memory for validation (you can loop through all if needed)
|
||||
const memory = res3[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
} else {
|
||||
// If there are no memories, assert that the list is empty
|
||||
expect(res3.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query and filters (API version 2)", async () => {
|
||||
const searchOptionsV2 = {
|
||||
query: "What do you know about me?",
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
};
|
||||
|
||||
const searchResultV2 = await client.search(
|
||||
"What do you know about me?",
|
||||
searchOptionsV2,
|
||||
);
|
||||
|
||||
// Validate that searchResultV2 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV2)).toBe(true);
|
||||
|
||||
if (searchResultV2.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV2[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
if (memory.user_id) {
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
}
|
||||
|
||||
if (memory.agent_id) {
|
||||
// Should be a string (agent_id)
|
||||
expect(typeof memory.agent_id).toBe("string");
|
||||
expect(memory.agent_id).toBe("shopping-assistant");
|
||||
}
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV2.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query (API version 1)", async () => {
|
||||
const searchResultV1 = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
// Validate that searchResultV1 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV1)).toBe(true);
|
||||
|
||||
if (searchResultV1.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV1[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV1.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retrieve history of a specific memory and validate the fields", async () => {
|
||||
const res22 = await client.history(memoryId);
|
||||
|
||||
// Validate that res22 is an iterable list (array)
|
||||
expect(Array.isArray(res22)).toBe(true);
|
||||
|
||||
if (res22.length > 0) {
|
||||
// Iterate through the first history entry for validation (you can loop through all if needed)
|
||||
const historyEntry = res22[0];
|
||||
|
||||
// Should be a string (history entry id)
|
||||
expect(typeof historyEntry.id).toBe("string");
|
||||
|
||||
// Should be a string (memory id related to the history entry)
|
||||
expect(typeof historyEntry.memory_id).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof historyEntry.user_id).toBe("string");
|
||||
expect(historyEntry.user_id).toBe(userId);
|
||||
|
||||
// Should be a string or null (old memory)
|
||||
expect(
|
||||
historyEntry.old_memory === null ||
|
||||
typeof historyEntry.old_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be a string or null (new memory)
|
||||
expect(
|
||||
historyEntry.new_memory === null ||
|
||||
typeof historyEntry.new_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(historyEntry.categories) ||
|
||||
historyEntry.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.categories)) {
|
||||
historyEntry.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(historyEntry.created_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(historyEntry.updated_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a string, one of: ADD, UPDATE, DELETE, NOOP
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(historyEntry.event);
|
||||
|
||||
// Validate conditions based on event type
|
||||
if (historyEntry.event === "ADD") {
|
||||
expect(historyEntry.old_memory).toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "UPDATE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "DELETE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).toBeNull();
|
||||
}
|
||||
|
||||
// Should be a list of objects or null (input)
|
||||
expect(
|
||||
Array.isArray(historyEntry.input) || historyEntry.input === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.input)) {
|
||||
historyEntry.input.forEach((input) => {
|
||||
// Each input should be an object
|
||||
expect(typeof input).toBe("object");
|
||||
|
||||
// Should have string content
|
||||
expect(typeof input.content).toBe("string");
|
||||
|
||||
// Should have a role that is either 'user' or 'assistant'
|
||||
expect(["user", "assistant"]).toContain(input.role);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If no history entries, assert that the list is empty
|
||||
expect(res22.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete the user successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
|
||||
if (entity) {
|
||||
const deletedUser = await client.deleteUser(entity.id);
|
||||
|
||||
// Validate the deletion message
|
||||
expect(deletedUser.message).toBe("Entity deleted successfully!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* MemoryClient unit tests — users, deleteUser, deleteUsers.
|
||||
* Tests verify entity type routing and request construction.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── users() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - users()", () => {
|
||||
test("sends GET to /v1/entities/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser()]),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.users();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/entities/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUsers() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
function createClientWithMockedAxios() {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
const axiosDeleteMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
client.client.delete = axiosDeleteMock;
|
||||
return { client, axiosDeleteMock };
|
||||
}
|
||||
|
||||
test("routes user_id to DELETE /v2/entities/user/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ user_id: "u1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith("/v2/entities/user/u1/", {
|
||||
params: expect.objectContaining({
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes agent_id to DELETE /v2/entities/agent/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ agent_id: "agent_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/agent/agent_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes app_id to DELETE /v2/entities/app/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ app_id: "app_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/app/app_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes run_id to DELETE /v2/entities/run/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ run_id: "run_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/run/run_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 'Entity deleted successfully.' for single entity", async () => {
|
||||
const { client } = createClientWithMockedAxios();
|
||||
const result = await client.deleteUsers({ user_id: "u1" });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
|
||||
test("returns 'All users, agents, apps and runs deleted.' when no params given", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser({ name: "u1", type: "user" })]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
|
||||
const result = await client.deleteUsers();
|
||||
expect(result.message).toBe("All users, agents, apps and runs deleted.");
|
||||
});
|
||||
|
||||
test("throws when no entities exist to delete", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest.fn();
|
||||
|
||||
await expect(client.deleteUsers()).rejects.toThrow("No entities to delete");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUser() (deprecated) ──────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
test("sends DELETE to /v1/entities/:type/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/123/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({
|
||||
entity_id: 123 as never,
|
||||
entity_type: "user",
|
||||
});
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("defaults entity_type to 'user' when empty", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/456/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({ entity_id: 456 as never, entity_type: "" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
|
||||
* Tests verify request URL and HTTP method, not mock response values.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { WebhookEvent } from "../mem0.types";
|
||||
import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - Webhooks", () => {
|
||||
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getWebhooks();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook includes webhook payload in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("new-hook");
|
||||
expect(body.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("updateWebhook includes updated fields in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("updated-hook");
|
||||
expect(body.url).toBe("https://new-url.com");
|
||||
});
|
||||
|
||||
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook deleted" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteWebhook({ webhookId: "wh_1" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared test setup for MemoryClient unit tests.
|
||||
* Provides mock fetch wiring, console suppression, and utility finders.
|
||||
*/
|
||||
import {
|
||||
createMockFetch,
|
||||
createStandardMockResponses,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
// ─── Global fetch mock + telemetry suppression ───────────
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
export function setupMockFetch(
|
||||
extraResponses?: Map<string, { status: number; body: unknown }>,
|
||||
): jest.Mock {
|
||||
const responses = createStandardMockResponses();
|
||||
if (extraResponses) {
|
||||
for (const [key, value] of extraResponses) {
|
||||
responses.set(key, value);
|
||||
}
|
||||
}
|
||||
const mockFetch = createMockFetch(responses);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
export function installConsoleSuppression(): void {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (
|
||||
msg.includes("Telemetry") ||
|
||||
msg.includes("Failed to initialize") ||
|
||||
msg.includes("Failed to capture")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (msg.includes("telemetry") || msg.includes("Telemetry")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helper: find specific fetch calls ───────────────────
|
||||
|
||||
export function findFetchCall(
|
||||
mock: jest.Mock,
|
||||
urlPattern: string,
|
||||
method?: string,
|
||||
): [string, RequestInit] | undefined {
|
||||
return mock.mock.calls.find((call: [string, RequestInit]) => {
|
||||
const urlMatch = call[0].includes(urlPattern);
|
||||
if (!method) return urlMatch;
|
||||
return urlMatch && call[1]?.method === method;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFetchBody(
|
||||
call: [string, RequestInit],
|
||||
): Record<string, unknown> {
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
export { MOCK_PING_RESPONSE };
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
HTTP_STATUS_TO_EXCEPTION,
|
||||
} from "./exceptions";
|
||||
|
||||
describe("MemoryError", () => {
|
||||
const error = new MemoryError("test error", "MEM_001", {
|
||||
details: { operation: "add" },
|
||||
suggestion: "Try again",
|
||||
debugInfo: { requestId: "req_123" },
|
||||
});
|
||||
|
||||
test("is an instance of Error", () => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test("has correct message", () => {
|
||||
expect(error.message).toBe("test error");
|
||||
});
|
||||
|
||||
test("has correct errorCode", () => {
|
||||
expect(error.errorCode).toBe("MEM_001");
|
||||
});
|
||||
|
||||
test("has correct details", () => {
|
||||
expect(error.details).toEqual({ operation: "add" });
|
||||
});
|
||||
|
||||
test("has correct suggestion", () => {
|
||||
expect(error.suggestion).toBe("Try again");
|
||||
});
|
||||
|
||||
test("has correct debugInfo", () => {
|
||||
expect(error.debugInfo).toEqual({ requestId: "req_123" });
|
||||
});
|
||||
|
||||
test("defaults details to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.details).toEqual({});
|
||||
});
|
||||
|
||||
test("defaults suggestion to undefined", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
test("defaults debugInfo to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.debugInfo).toEqual({});
|
||||
});
|
||||
|
||||
test("is throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new MemoryError("fail", "MEM_001");
|
||||
}).toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exception subclasses", () => {
|
||||
const subclasses = [
|
||||
{ Class: AuthenticationError, name: "AuthenticationError" },
|
||||
{ Class: RateLimitError, name: "RateLimitError" },
|
||||
{ Class: ValidationError, name: "ValidationError" },
|
||||
{ Class: MemoryNotFoundError, name: "MemoryNotFoundError" },
|
||||
{ Class: NetworkError, name: "NetworkError" },
|
||||
{ Class: ConfigurationError, name: "ConfigurationError" },
|
||||
{ Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" },
|
||||
] as const;
|
||||
|
||||
test.each(subclasses)("$name extends MemoryError", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name extends Error", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name has correct name", ({ Class, name }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error.name).toBe(name);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name supports instanceof checks", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error instanceof Class).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createExceptionFromResponse", () => {
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 401 to errorCode HTTP_401", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.errorCode).toBe("HTTP_401");
|
||||
});
|
||||
|
||||
test("maps 401 to authentication suggestion", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.suggestion).toBe(
|
||||
"Please check your API key and authentication credentials",
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error).toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 429 passes debugInfo through", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error.debugInfo).toEqual({ retryAfter: 60 });
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
const error = createExceptionFromResponse(404, "Not found");
|
||||
expect(error).toBeInstanceOf(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 400 to ValidationError", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request");
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
const error = createExceptionFromResponse(413, "Quota exceeded");
|
||||
expect(error).toBeInstanceOf(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test.each([502, 503, 504])("maps %i to NetworkError", (code) => {
|
||||
const error = createExceptionFromResponse(code, "Service unavailable");
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 500 to errorCode HTTP_500", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error.errorCode).toBe("HTTP_500");
|
||||
});
|
||||
|
||||
test("maps unknown status to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps unknown status to correct errorCode", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.errorCode).toBe("HTTP_418");
|
||||
});
|
||||
|
||||
test("maps unknown status to retry suggestion", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.suggestion).toBe("Please try again later");
|
||||
});
|
||||
|
||||
test("uses response text as message", () => {
|
||||
const error = createExceptionFromResponse(400, "Invalid user_id format");
|
||||
expect(error.message).toBe("Invalid user_id format");
|
||||
});
|
||||
|
||||
test("falls back to generic message when response text is empty", () => {
|
||||
const error = createExceptionFromResponse(500, "");
|
||||
expect(error.message).toBe("HTTP 500 error");
|
||||
});
|
||||
|
||||
test("passes details through", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request", {
|
||||
details: { field: "user_id", value: "" },
|
||||
});
|
||||
expect(error.details).toEqual({ field: "user_id", value: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP_STATUS_TO_EXCEPTION", () => {
|
||||
test("maps 400 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 403 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 408 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 409 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test("maps 422 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 502 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 503 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 504 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Structured exception classes for mem0 TypeScript SDK.
|
||||
*
|
||||
* Provides specific, actionable exceptions with error codes, suggestions,
|
||||
* and debug information. Maps HTTP status codes to appropriate exception types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { RateLimitError, MemoryNotFoundError } from 'mem0ai'
|
||||
*
|
||||
* try {
|
||||
* await client.get(memoryId)
|
||||
* } catch (e) {
|
||||
* if (e instanceof MemoryNotFoundError) {
|
||||
* console.log(e.suggestion) // "The requested resource was not found"
|
||||
* } else if (e instanceof RateLimitError) {
|
||||
* await sleep(e.debugInfo.retryAfter ?? 60)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface MemoryErrorOptions {
|
||||
details?: Record<string, unknown>;
|
||||
suggestion?: string;
|
||||
debugInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base exception for all memory-related errors.
|
||||
*
|
||||
* Every mem0 exception includes an error code for programmatic handling,
|
||||
* optional details, a user-friendly suggestion, and debug information.
|
||||
*/
|
||||
export class MemoryError extends Error {
|
||||
readonly errorCode: string;
|
||||
readonly details: Record<string, unknown>;
|
||||
readonly suggestion?: string;
|
||||
readonly debugInfo: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options: MemoryErrorOptions = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MemoryError";
|
||||
this.errorCode = errorCode;
|
||||
this.details = options.details ?? {};
|
||||
this.suggestion = options.suggestion;
|
||||
this.debugInfo = options.debugInfo ?? {};
|
||||
|
||||
// Fix prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when authentication fails (401, 403). */
|
||||
export class AuthenticationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when rate limits are exceeded (429). */
|
||||
export class RateLimitError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when input validation fails (400, 409, 422). */
|
||||
export class ValidationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a memory is not found (404). */
|
||||
export class MemoryNotFoundError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when network connectivity issues occur (408, 502, 503, 504). */
|
||||
export class NetworkError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when client configuration is invalid. */
|
||||
export class ConfigurationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when memory quota is exceeded (413). */
|
||||
export class MemoryQuotaExceededError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryQuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Status → Exception Mapping ─────────────────────
|
||||
|
||||
type MemoryErrorConstructor = new (
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) => MemoryError;
|
||||
|
||||
export const HTTP_STATUS_TO_EXCEPTION: Record<number, MemoryErrorConstructor> =
|
||||
{
|
||||
400: ValidationError,
|
||||
401: AuthenticationError,
|
||||
403: AuthenticationError,
|
||||
404: MemoryNotFoundError,
|
||||
408: NetworkError,
|
||||
409: ValidationError,
|
||||
413: MemoryQuotaExceededError,
|
||||
422: ValidationError,
|
||||
429: RateLimitError,
|
||||
500: MemoryError,
|
||||
502: NetworkError,
|
||||
503: NetworkError,
|
||||
504: NetworkError,
|
||||
};
|
||||
|
||||
const HTTP_SUGGESTIONS: Record<number, string> = {
|
||||
400: "Please check your request parameters and try again",
|
||||
401: "Please check your API key and authentication credentials",
|
||||
403: "You don't have permission to perform this operation",
|
||||
404: "The requested resource was not found",
|
||||
408: "Request timed out. Please try again",
|
||||
409: "Resource conflict. Please check your request",
|
||||
413: "Request too large. Please reduce the size of your request",
|
||||
422: "Invalid request data. Please check your input",
|
||||
429: "Rate limit exceeded. Please wait before making more requests",
|
||||
500: "Internal server error. Please try again later",
|
||||
502: "Service temporarily unavailable. Please try again later",
|
||||
503: "Service unavailable. Please try again later",
|
||||
504: "Gateway timeout. Please try again later",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an appropriate exception based on HTTP response status code.
|
||||
*
|
||||
* @param statusCode - HTTP status code from the response
|
||||
* @param responseText - Response body text
|
||||
* @param options - Additional error context (details, debugInfo)
|
||||
* @returns An instance of the appropriate MemoryError subclass
|
||||
*/
|
||||
export function createExceptionFromResponse(
|
||||
statusCode: number,
|
||||
responseText: string,
|
||||
options: Omit<MemoryErrorOptions, "suggestion"> = {},
|
||||
): MemoryError {
|
||||
const ExceptionClass = HTTP_STATUS_TO_EXCEPTION[statusCode] ?? MemoryError;
|
||||
const errorCode = `HTTP_${statusCode}`;
|
||||
const suggestion = HTTP_SUGGESTIONS[statusCode] ?? "Please try again later";
|
||||
|
||||
return new ExceptionClass(
|
||||
responseText || `HTTP ${statusCode} error`,
|
||||
errorCode,
|
||||
{ ...options, suggestion },
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,15 @@ export class ConfigManager {
|
||||
// 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 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;
|
||||
((userConf as Record<string, unknown>)?.embedding_dims as
|
||||
| number
|
||||
| undefined);
|
||||
|
||||
return {
|
||||
apiKey:
|
||||
@@ -103,7 +107,9 @@ export class ConfigManager {
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const llmBaseURL =
|
||||
userConf?.baseURL ??
|
||||
(userConf as Record<string, unknown>)?.lmstudio_base_url as string | undefined ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
defaultConf.baseURL;
|
||||
|
||||
return {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -190,7 +190,10 @@ describe("ConfigManager", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", embedding_dims: 768 } as any,
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
@@ -313,7 +316,11 @@ describe("ConfigManager", () => {
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { host: "192.168.200.12", port: 6333, checkCompatibility: false },
|
||||
config: {
|
||||
host: "192.168.200.12",
|
||||
port: 6333,
|
||||
checkCompatibility: false,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
@@ -326,7 +333,9 @@ describe("ConfigManager", () => {
|
||||
|
||||
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.model).toBe(
|
||||
"text-embedding-gte-qwen2-1.5b-instruct",
|
||||
);
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
@@ -409,12 +418,18 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
|
||||
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" },
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
@@ -423,11 +438,17 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }),
|
||||
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" }),
|
||||
expect.objectContaining({
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -435,10 +456,16 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
|
||||
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" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
@@ -462,7 +489,10 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
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,
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
@@ -471,32 +501,57 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({ model: "text-embedding-gte-qwen2-1.5b-instruct", baseURL: "http://192.168.200.83:1234/v1" }),
|
||||
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" }),
|
||||
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,
|
||||
}]);
|
||||
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 },
|
||||
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" },
|
||||
},
|
||||
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" });
|
||||
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();
|
||||
@@ -505,19 +560,31 @@ describe("Memory – LM Studio end-to-end flow", () => {
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce('{"facts":["User loves sushi"]}');
|
||||
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 },
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" },
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import {
|
||||
EmbedderFactory,
|
||||
VectorStoreFactory,
|
||||
} from "../src/utils/factory";
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create LM Studio embedder with baseURL", () => {
|
||||
const embedder = EmbedderFactory.create("lmstudio", {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
expect(embedder).toBeInstanceOf(LMStudioEmbedder);
|
||||
});
|
||||
|
||||
it("should throw error for unsupported embedder provider", () => {
|
||||
expect(() => {
|
||||
EmbedderFactory.create("unsupported-embedder", {});
|
||||
}).toThrow("Unsupported embedder provider: unsupported-embedder");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create Azure AI Search vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
serviceName: "test-service",
|
||||
apiKey: "test-api-key",
|
||||
embeddingModelDims: 1536,
|
||||
compressionType: "none" as const,
|
||||
useFloat16: false,
|
||||
hybridSearch: false,
|
||||
vectorFilterMode: "preFilter" as const,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
|
||||
|
||||
expect(vectorStore).toBeInstanceOf(AzureAISearch);
|
||||
});
|
||||
|
||||
it("should create memory vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("memory", config);
|
||||
|
||||
expect(vectorStore).toBeDefined();
|
||||
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
|
||||
});
|
||||
|
||||
it("should throw error for unsupported provider", () => {
|
||||
const config = {};
|
||||
|
||||
expect(() => {
|
||||
VectorStoreFactory.create("unsupported-provider", config);
|
||||
}).toThrow("Unsupported vector store provider: unsupported-provider");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory.
|
||||
* Mocks all provider modules to avoid external dependency crashes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
|
||||
// Mock all provider modules before importing factory
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/ollama", () => ({
|
||||
OllamaEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/langchain", () => ({
|
||||
LangchainEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/lmstudio", () => ({
|
||||
LMStudioEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-embedder", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/openai_structured", () => ({
|
||||
OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({
|
||||
type: "openai-structured-llm",
|
||||
config,
|
||||
})),
|
||||
}));
|
||||
jest.mock("../src/llms/anthropic", () => ({
|
||||
AnthropicLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "anthropic-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/groq", () => ({
|
||||
GroqLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "groq-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/ollama", () => ({
|
||||
OllamaLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/azure", () => ({
|
||||
AzureOpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/mistral", () => ({
|
||||
MistralLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "mistral-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/langchain", () => ({
|
||||
LangchainLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/lmstudio", () => ({
|
||||
LMStudioLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-llm", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/vector_stores/qdrant", () => ({
|
||||
Qdrant: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "qdrant", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/redis", () => ({
|
||||
RedisDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "redis", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/supabase", () => ({
|
||||
SupabaseDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/langchain", () => ({
|
||||
LangchainVectorStore: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-vs", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/vectorize", () => ({
|
||||
VectorizeDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "vectorize", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
AzureAISearch: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase-history", config })),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbedderFactory,
|
||||
LLMFactory,
|
||||
VectorStoreFactory,
|
||||
HistoryManagerFactory,
|
||||
} from "../src/utils/factory";
|
||||
import type {
|
||||
EmbeddingConfig,
|
||||
LLMConfig,
|
||||
VectorStoreConfig,
|
||||
HistoryStoreConfig,
|
||||
} from "../src/types";
|
||||
|
||||
const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" };
|
||||
const dummyLLMConfig: LLMConfig = { apiKey: "test" };
|
||||
const dummyVSConfig: VectorStoreConfig = {
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
// ─── EmbedderFactory ────────────────────────────────────
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates embedder for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create(provider, dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("OpenAI", dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("nonexistent", dummyEmbedConfig),
|
||||
).toThrow("Unsupported embedder provider: nonexistent");
|
||||
});
|
||||
|
||||
test("passes config to created embedder", () => {
|
||||
const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" };
|
||||
const result = EmbedderFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLMFactory ─────────────────────────────────────────
|
||||
|
||||
describe("LLMFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["openai_structured"],
|
||||
["anthropic"],
|
||||
["groq"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["mistral"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates LLM for provider '%s'", (provider) => {
|
||||
expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow(
|
||||
"Unsupported LLM provider: nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes config to created LLM", () => {
|
||||
const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" };
|
||||
const result = LLMFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VectorStoreFactory ─────────────────────────────────
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
test("creates memory vector store", () => {
|
||||
// MemoryVectorStore is real (not mocked) — needs valid config
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("memory", {
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["qdrant"],
|
||||
["redis"],
|
||||
["supabase"],
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("nonexistent", dummyVSConfig),
|
||||
).toThrow("Unsupported vector store provider: nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HistoryManagerFactory ──────────────────────────────
|
||||
|
||||
describe("HistoryManagerFactory", () => {
|
||||
test("creates SQLite history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: ":memory:" },
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates supabase history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "supabase",
|
||||
config: { supabaseUrl: "http://test", supabaseKey: "key" },
|
||||
};
|
||||
expect(() =>
|
||||
HistoryManagerFactory.create("supabase", config),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates memory history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "memory",
|
||||
config: {},
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
const config: HistoryStoreConfig = { provider: "bad", config: {} };
|
||||
expect(() => HistoryManagerFactory.create("bad", config)).toThrow(
|
||||
"Unsupported history store provider: bad",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,12 @@ describe("LMStudioEmbedder (unit)", () => {
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const mockBatch = [[0.1, 0.2], [0.3, 0.4]];
|
||||
const mockBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ embedding: mockBatch[0] },
|
||||
{ embedding: mockBatch[1] },
|
||||
],
|
||||
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
|
||||
});
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
|
||||
@@ -20,17 +20,26 @@ const describeIf = RUN_INTEGRATION ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
async function listModels(): Promise<{ embedding: string | null; chat: string | null }> {
|
||||
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"));
|
||||
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;
|
||||
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];
|
||||
@@ -46,7 +55,10 @@ describeIf("LM Studio Integration", () => {
|
||||
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));
|
||||
console.log(
|
||||
"Loaded models:",
|
||||
body.data.map((m: any) => m.id),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Embedder ────────────────────────────────────────────────────────
|
||||
@@ -58,7 +70,10 @@ describeIf("LM Studio Integration", () => {
|
||||
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 });
|
||||
embedder = new LMStudioEmbedder({
|
||||
baseURL: LMSTUDIO_BASE_URL,
|
||||
model: modelId,
|
||||
});
|
||||
});
|
||||
|
||||
it("embed() returns a numeric vector", async () => {
|
||||
@@ -94,7 +109,9 @@ describeIf("LM Studio Integration", () => {
|
||||
]);
|
||||
const simSimilar = cosineSim(v1, v2);
|
||||
const simDifferent = cosineSim(v1, v3);
|
||||
console.log(` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`);
|
||||
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);
|
||||
|
||||
@@ -18,9 +18,15 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateResponse() returns a text response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: { content: "Hello, world!", role: "assistant", tool_calls: null },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Hello, world!",
|
||||
role: "assistant",
|
||||
tool_calls: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -34,15 +40,22 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateResponse() handles tool calls", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [{
|
||||
function: { name: "get_weather", arguments: '{"city": "London"}' },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "London"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -71,9 +84,11 @@ describe("LMStudioLLM (unit)", () => {
|
||||
|
||||
it("generateChat() returns LLMResponse shape", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
@@ -81,7 +96,10 @@ describe("LMStudioLLM (unit)", () => {
|
||||
{ role: "user", content: "Help me" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ content: "I can help with that.", role: "assistant" });
|
||||
expect(result).toEqual({
|
||||
content: "I can help with that.",
|
||||
role: "assistant",
|
||||
});
|
||||
});
|
||||
|
||||
it("generateChat() wraps API errors with a clear message", async () => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* OSS Memory unit tests — add() with inference, without inference, filter validation, metadata.
|
||||
* Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["extracted fact from input"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "extracted fact from input",
|
||||
old_memory: "",
|
||||
new_memory: "extracted fact from input",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - add()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `add_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array for string input", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns at least one result with an id", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
test("result item has a memory string field", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(typeof result.results[0].memory).toBe("string");
|
||||
});
|
||||
|
||||
test("accepts Message[] input", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris." },
|
||||
];
|
||||
const result: SearchResult = await memory.add(messages, { userId });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with agentId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", {
|
||||
agentId: "agent_1",
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with runId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", { runId: "run_1" });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.add("test", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes metadata through to stored memory", async () => {
|
||||
const result: SearchResult = await memory.add("I love TypeScript", {
|
||||
userId,
|
||||
metadata: { source: "chat", tag: "programming" },
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ source: "chat", tag: "programming" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
// When infer=false, the literal message text is stored
|
||||
expect(result.results[0].memory).toBe("Direct storage content");
|
||||
});
|
||||
|
||||
test("with infer=false marks event as ADD in metadata", async () => {
|
||||
const result: SearchResult = await memory.add("Direct fact", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results[0].metadata).toEqual(
|
||||
expect.objectContaining({ event: "ADD" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history.
|
||||
* Content-based LLM mock. Tests verify real behavior, not mock echoes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["stored fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "stored fact",
|
||||
old_memory: "",
|
||||
new_memory: "stored fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("Memory - get()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `get_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns the memory matching the ID from add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("I love AI", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.id).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a string for the memory field", async () => {
|
||||
const addResult: SearchResult = await memory.add("Testing get", {
|
||||
userId,
|
||||
});
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const item = await memory.get("nonexistent-uuid-12345");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
test("returns hash and createdAt on stored memory", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash test", { userId });
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.hash).toBe("string");
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - update()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `update_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Original", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const result = await memory.update(id, "Updated");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
test("persists the updated text", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before update", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After update");
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item!.memory).toBe("After update");
|
||||
});
|
||||
|
||||
test("preserves createdAt and sets updatedAt", async () => {
|
||||
const addResult: SearchResult = await memory.add("Timestamp test", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
const originalCreatedAt = before!.createdAt;
|
||||
|
||||
await memory.update(id, "New text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.createdAt).toBe(originalCreatedAt);
|
||||
expect(after!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("updates the hash", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash change", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
await memory.update(id, "Completely different text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.hash).not.toBe(before!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - delete()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `delete_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Delete me", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const result = await memory.delete(addResult.results[0].id);
|
||||
expect(result.message).toBe("Memory deleted successfully!");
|
||||
});
|
||||
|
||||
test("get() returns null after deletion", async () => {
|
||||
const addResult: SearchResult = await memory.add("Temporary", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.delete(id);
|
||||
expect(await memory.get(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("Memory - deleteAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `deleteall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("removes all memories for the user and returns success", async () => {
|
||||
await memory.add("Fact A", { userId });
|
||||
await memory.add("Fact B", { userId });
|
||||
const result = await memory.deleteAll({ userId });
|
||||
expect(result.message).toBe("Memories deleted successfully!");
|
||||
const remaining: SearchResult = await memory.getAll({ userId });
|
||||
expect(remaining.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws when no filter is provided", async () => {
|
||||
await expect(memory.deleteAll({} as any)).rejects.toThrow(
|
||||
"At least one filter is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - getAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `getall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns all stored memories for the user", async () => {
|
||||
await memory.add("First", { userId });
|
||||
await memory.add("Second", { userId });
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("each result has id and memory fields", async () => {
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
for (const item of result.results) {
|
||||
expect(item.id).toBeDefined();
|
||||
expect(typeof item.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no memories exist", async () => {
|
||||
const result: SearchResult = await memory.getAll({
|
||||
userId: "no_such_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - search()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `search_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
await memory.add("I love TypeScript", { userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array", async () => {
|
||||
const result: SearchResult = await memory.search("TypeScript", { userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns results with score field", async () => {
|
||||
const result: SearchResult = await memory.search("content", { userId });
|
||||
if (result.results.length > 0) {
|
||||
expect(typeof result.results[0].score).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.search("query", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty results for user with no memories", async () => {
|
||||
const result: SearchResult = await memory.search("query", {
|
||||
userId: "empty_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("Memory - history()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `history_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("records ADD event after add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("New fact", { userId });
|
||||
const history = await memory.history(addResult.results[0].id);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("records additional entry after update()", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After");
|
||||
const history = await memory.history(id);
|
||||
expect(history.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for non-existent memory ID", async () => {
|
||||
const history = await memory.history("nonexistent-id");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder.
|
||||
* Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock LLM and embedder so tests run without API keys.
|
||||
// Content-based mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() }));
|
||||
jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() }));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({
|
||||
facts: ["John is a software engineer"],
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "John is a software engineer",
|
||||
old_memory: "",
|
||||
new_memory: "John is a software engineer",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
describeOrSkip("Memory Class (E2E)", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("add() single memory", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns a defined result", () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns result with an id", () => {
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("add() multiple messages", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
result = (await memory.add(messages, { userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() single memory", () => {
|
||||
let memoryItem: MemoryItem;
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
memoryItem = (await memory.get(memoryId)) as MemoryItem;
|
||||
});
|
||||
|
||||
it("returns the correct id", () => {
|
||||
expect(memoryItem.id).toBe(memoryId);
|
||||
});
|
||||
|
||||
it("returns a string memory", () => {
|
||||
expect(typeof memoryItem.memory).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update() memory", () => {
|
||||
let memoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
{ userId },
|
||||
)) as SearchResult;
|
||||
memoryId = addResult.results[0].id;
|
||||
});
|
||||
|
||||
it("returns success message", async () => {
|
||||
const result = await memory.update(memoryId, "Updated content");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
it("persists the updated content", async () => {
|
||||
await memory.update(memoryId, "Updated content");
|
||||
const updated = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updated.memory).toBe("Updated content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll() memories for user", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love visiting new places in the winters", { userId });
|
||||
await memory.add("I like to rule the world", { userId });
|
||||
result = (await memory.getAll({ userId })) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least two results", () => {
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search() memories", () => {
|
||||
let result: SearchResult;
|
||||
|
||||
beforeEach(async () => {
|
||||
await memory.add("I love programming in Python", { userId });
|
||||
await memory.add("JavaScript is my favorite language", { userId });
|
||||
result = (await memory.search("What programming languages do I know?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
});
|
||||
|
||||
it("returns results array", () => {
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one result", () => {
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history() of a memory", () => {
|
||||
let history: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const addResult = (await memory.add("I like swimming in warm water", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
history = await memory.history(memoryId);
|
||||
});
|
||||
|
||||
it("returns an array", () => {
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns at least one entry", () => {
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete() a memory", () => {
|
||||
it("returns null after deletion", async () => {
|
||||
const addResult = (await memory.add("I love to drink vodka in summers", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.delete(memoryId);
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("add() returns results with custom config", async () => {
|
||||
const result = (await customMemory.add("I love programming in Python", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("search() returns results with custom config", async () => {
|
||||
await customMemory.add("The weather in London is rainy today", {
|
||||
userId,
|
||||
});
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", {
|
||||
userId,
|
||||
});
|
||||
const result = (await customMemory.search("What is the weather like?", {
|
||||
userId,
|
||||
})) as SearchResult;
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* OSS Memory unit tests — constructor, initialization, config validation, reset.
|
||||
* Mocks LLM/Embedder at module level. No API keys needed.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
// ─── Content-based LLM mock (reviewer #9) ────────────────
|
||||
// Returns facts for system-prompt calls, memory actions for user-only calls.
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["test fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "test fact",
|
||||
old_memory: "",
|
||||
new_memory: "test fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-init", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - Initialization", () => {
|
||||
test("constructs without throwing with valid config", () => {
|
||||
expect(() => createMemory()).not.toThrow();
|
||||
});
|
||||
|
||||
test("fromConfig creates instance from config dict", () => {
|
||||
const config = {
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4" },
|
||||
},
|
||||
};
|
||||
const mem = Memory.fromConfig(config);
|
||||
expect(mem).toBeInstanceOf(Memory);
|
||||
});
|
||||
|
||||
test("fromConfig throws on invalid config", () => {
|
||||
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
|
||||
const mem = createMemory({ disableHistory: true });
|
||||
// If DummyHistoryManager is used, history returns [] without error
|
||||
const result = await mem.history("nonexistent-id");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory - reset()", () => {
|
||||
test("reset clears all stored memories", async () => {
|
||||
const mem = createMemory();
|
||||
const userId = `reset_test_${Date.now()}`;
|
||||
|
||||
await mem.add("Remember this fact", { userId });
|
||||
const before: SearchResult = await mem.getAll({ userId });
|
||||
expect(before.results.length).toBeGreaterThan(0);
|
||||
|
||||
await mem.reset();
|
||||
|
||||
const after: SearchResult = await mem.getAll({ userId });
|
||||
expect(after.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
jest.setTimeout(30000); // Increase timeout to 30 seconds
|
||||
|
||||
describe("Memory Class", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with default configuration
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
// Reset all memories before each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("Basic Memory Operations", () => {
|
||||
it("should add a single memory", async () => {
|
||||
const result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add multiple messages", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
|
||||
const result = (await memory.add(messages, userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get a single memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const result = (await memory.get(memoryId)) as MemoryItem;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(memoryId);
|
||||
expect(result.memory).toBeDefined();
|
||||
expect(typeof result.memory).toBe("string");
|
||||
});
|
||||
|
||||
it("should update a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const updatedContent = "Updated content";
|
||||
const result = await memory.update(memoryId, updatedContent);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
|
||||
// Verify the update by getting the memory
|
||||
const updatedMemory = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updatedMemory.memory).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it("should get all memories for a user", async () => {
|
||||
// Add a few memories
|
||||
await memory.add("I love visiting new places in the winters", userId);
|
||||
await memory.add("I like to rule the world", userId);
|
||||
|
||||
const result = (await memory.getAll(userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should search memories", async () => {
|
||||
// Add some test memories
|
||||
await memory.add("I love programming in Python", userId);
|
||||
await memory.add("JavaScript is my favorite language", userId);
|
||||
|
||||
const result = (await memory.search(
|
||||
"What programming languages do I know?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get memory history", async () => {
|
||||
// Add and update a memory to create history
|
||||
const addResult = (await memory.add(
|
||||
"I like swimming in warm water",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
|
||||
const history = await memory.history(memoryId);
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love to drink vodka in summers",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
|
||||
// Delete the memory
|
||||
await memory.delete(memoryId);
|
||||
|
||||
// Try to get the deleted memory - should throw or return null
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("should work with custom configuration", async () => {
|
||||
const result = (await customMemory.add(
|
||||
"I love programming in Python",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should perform semantic search with custom embeddings", async () => {
|
||||
// Add test memories
|
||||
await customMemory.add("The weather in London is rainy today", userId);
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", userId);
|
||||
|
||||
const result = (await customMemory.search(
|
||||
"What is the weather like?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
// Results should be ordered by relevance
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -8,6 +9,7 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class OllamaLLM(LLMBase):
|
||||
@@ -61,7 +63,28 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
if isinstance(response, dict):
|
||||
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||
else:
|
||||
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||
|
||||
for tool_call in raw_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
else:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", "") if fn else ""
|
||||
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(extract_json(arguments))
|
||||
|
||||
processed_response["tool_calls"].append(
|
||||
{"name": name, "arguments": arguments}
|
||||
)
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -113,5 +136,8 @@ class OllamaLLM(LLMBase):
|
||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.chat(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
@@ -29,6 +29,7 @@ from mem0.memory.utils import (
|
||||
ensure_json_instruction,
|
||||
extract_json,
|
||||
get_fact_retrieval_messages,
|
||||
normalize_facts,
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
@@ -456,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 = []
|
||||
@@ -1484,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 = []
|
||||
|
||||
+28
-1
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mem0.configs.prompts import (
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
FACT_RETRIEVAL_PROMPT,
|
||||
USER_MEMORY_EXTRACTION_PROMPT,
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_fact_retrieval_messages(message, is_agent_memory=False):
|
||||
"""Get fact retrieval messages based on the memory type.
|
||||
@@ -77,6 +80,30 @@ def format_entities(entities):
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def normalize_facts(raw_facts):
|
||||
"""Normalize LLM-extracted facts to a list of strings.
|
||||
|
||||
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
|
||||
like {"fact": "..."} or {"text": "..."} instead of plain strings.
|
||||
This mirrors the TypeScript FactRetrievalSchema validation.
|
||||
"""
|
||||
if not raw_facts:
|
||||
return []
|
||||
normalized = []
|
||||
for item in raw_facts:
|
||||
if isinstance(item, str):
|
||||
fact = item
|
||||
elif isinstance(item, dict):
|
||||
fact = item.get("fact") or item.get("text")
|
||||
if fact is None:
|
||||
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
|
||||
continue
|
||||
else:
|
||||
fact = str(item)
|
||||
if fact:
|
||||
normalized.append(fact)
|
||||
return normalized
|
||||
|
||||
|
||||
def remove_code_blocks(content: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
from typing import List, Dict, Any, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
class LLMReranker(BaseReranker):
|
||||
@@ -33,19 +33,30 @@ class LLMReranker(BaseReranker):
|
||||
|
||||
self.config = config
|
||||
|
||||
# Create LLM configuration for the factory
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
|
||||
# Add API key if provided
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
# If a nested ``llm`` dict is provided (e.g. for non-OpenAI providers
|
||||
# like Ollama that need provider-specific fields such as
|
||||
# ``ollama_base_url``), use it to configure the LLM factory.
|
||||
if self.config.llm:
|
||||
nested = self.config.llm
|
||||
llm_provider = nested.get("provider", self.config.provider)
|
||||
llm_config: dict = dict(nested.get("config") or {})
|
||||
llm_config.setdefault("model", self.config.model)
|
||||
llm_config.setdefault("temperature", self.config.temperature)
|
||||
llm_config.setdefault("max_tokens", self.config.max_tokens)
|
||||
if self.config.api_key:
|
||||
llm_config.setdefault("api_key", self.config.api_key)
|
||||
else:
|
||||
llm_provider = self.config.provider
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(self.config.provider, llm_config)
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
@@ -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"]}}]
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
+76
-1
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user