Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]anddeshraj eac9b473a9 feat: per-agent memory isolation for openclaw-mem0 plugin
Co-authored-by: deshraj <2945708+deshraj@users.noreply.github.com>
2026-02-23 01:39:50 +00:00
copilot-swe-agent[bot] ce05224bad Initial plan 2026-02-23 01:30:44 +00:00
143 changed files with 3269 additions and 22090 deletions
-100
View File
@@ -1,100 +0,0 @@
name: openclaw checks
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'openclaw/**'
- '.github/workflows/openclaw-checks.yml'
pull_request:
paths:
- 'openclaw/**'
- '.github/workflows/openclaw-checks.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Type check
run: cd openclaw && pnpm exec tsc --noEmit
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Run tests with coverage
run: cd openclaw && pnpm exec vitest run --coverage
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
flags: openclaw
directory: openclaw/coverage
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Build
run: cd openclaw && pnpm build
- name: Verify dist output exists
run: |
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
-75
View File
@@ -1,75 +0,0 @@
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/
+4 -4
View File
@@ -20,8 +20,8 @@ Mem0 provides a comprehensive REST API for integrating advanced memory capabilit
Get started with Mem0 API in three simple steps:
1. **[Add Memories](/api-reference/memory/add-memories)** - Store information and context from user conversations
2. **[Search Memories](/api-reference/memory/search-memories)** - Retrieve relevant memories using semantic search
3. **[Get Memories](/api-reference/memory/get-memories)** - Fetch all memories for a specific entity
2. **[Search Memories](/api-reference/memory/v2-search-memories)** - Retrieve relevant memories using semantic search
3. **[Get Memories](/api-reference/memory/v2-get-memories)** - Fetch all memories for a specific entity
---
@@ -32,7 +32,7 @@ Get started with Mem0 API in three simple steps:
Store new memories from conversations and interactions
</Card>
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/search-memories">
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/v2-search-memories">
Find relevant memories using semantic search with filters
</Card>
@@ -102,7 +102,7 @@ Get your API key from the [Mem0 Dashboard](https://app.mem0.ai/dashboard/api-key
Start storing memories via the REST API
</Card>
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/search-memories">
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/v2-search-memories">
Learn advanced search and filtering techniques
</Card>
</CardGroup>
-61
View File
@@ -7,27 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-16" description="v1.0.6">
**Bug Fixes:**
- **Telemetry:** Fixed telemetry vector store initialization still running when `MEM0_TELEMETRY` is disabled (#4351)
- **Core:** Removed destructive `vector_store.reset()` call from `delete_all()` that was wiping the entire vector store instead of deleting only the target memories (#4349)
- **OSS:** `OllamaLLM` now respects the configured URL instead of always falling back to localhost (#4320)
- **Core:** Fixed `KeyError` when LLM omits the `entities` key in tool call response (#4313)
- **Prompts:** Ensured JSON instruction is included in prompts when using `json_object` response format (#4271)
- **Core:** Fixed incorrect database parameter handling (#3913)
**Dependencies:**
- Updated LangChain dependencies to v1.0.0 (#4353)
- Bumped protobuf dependency to 5.29.6 and extended upper bound to `<7.0.0` (#4326)
</Update>
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
@@ -745,46 +724,6 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-03-16" description="v2.4.1">
**Bug Fixes:**
- **Core:** Fixed code block content extraction — content inside code blocks is now properly extracted instead of being deleted (#4317)
**Improvements:**
- **Code Quality:** Fixed linting issues across the SDK (#4334)
</Update>
<Update label="2026-03-14" description="v2.4.0">
**Bug Fixes:**
- **OSS Storage:** Fixed `SQLITE_CANTOPEN` errors when running as a LaunchAgent, systemd service, or in containers where `process.cwd()` is read-only (e.g. `/`). Default `vector_store.db` location changed from `process.cwd()/vector_store.db` to `~/.mem0/vector_store.db`.
- **OSS Storage:** Fixed `historyDbPath` config being silently ignored — config merging always overwrote it with defaults. Top-level `historyDbPath` is now correctly propagated into `historyStore.config` with proper precedence.
- **OSS Storage:** Added `ensureSQLiteDirectory()` — parent directories for SQLite database files are now auto-created before opening, preventing `SQLITE_CANTOPEN` when using nested paths.
**Improvements:**
- **Migration:** Added deprecation warning when an existing `vector_store.db` is found at the old `process.cwd()` location, guiding users to move it or set `vectorStore.config.dbPath` explicitly.
- **Config:** Limited default SQLite config spreading to only SQLite history providers, preventing config leaking into Supabase or other providers.
</Update>
<Update label="2026-03-09" description="v2.3.0">
**Breaking Changes:**
- **Dependencies:** Minimum Node.js version for OSS sqlite features is now Node 20+ (due to `better-sqlite3` v12)
**Bug Fixes:**
- **OSS Storage:** Replaced `sqlite3` with `better-sqlite3` to fix native binding resolution failures under jiti-based loaders (e.g. OpenClaw plugin system). Fixes issues where the `bindings` module walked V8 stack frames with synthetic filenames, failing to locate the native `.node` addon.
- **OSS Storage:** Fixed async init race condition in `SQLiteManager` — `init()` is now synchronous
- **OSS Vector Store:** Migrated `MemoryVectorStore` from `sqlite3` to `better-sqlite3` with transactional batch inserts
**Improvements:**
- **Performance:** Cached prepared statements in `SQLiteManager` for faster history operations
- **Performance:** Batch `insert()` in `MemoryVectorStore` wrapped in a transaction for atomicity
- **Build:** Updated `tsup.config.ts` externals from `sqlite3` to `better-sqlite3`
</Update>
<Update label="2026-02-17" description="v2.2.3">
**New Features & Updates:**
@@ -1,766 +0,0 @@
---
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>
@@ -107,12 +107,6 @@ client = MemoryClient(api_key="your-api-key")
# Delete all memories for a specific user
client.delete_all(user_id="alice")
# Delete all memories for a specific agent
client.delete_all(agent_id="support-bot")
# Delete all memories for a specific run
client.delete_all(run_id="session-xyz")
```
```javascript JavaScript
@@ -133,48 +127,7 @@ You can also filter by other parameters such as:
- `metadata` (as JSON string)
<Warning>
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
</Warning>
### Wildcard deletes
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Delete all memories across every user in the project
client.delete_all(user_id="*")
# Delete all memories across every agent in the project
client.delete_all(agent_id="*")
# Full project wipe — all four filters must be explicitly set to "*"
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
```
```javascript JavaScript
import MemoryClient from 'mem0ai';
const client = new MemoryClient({ apiKey: "your-api-key" });
// Delete all memories across every user in the project
client.deleteAll({ user_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
// Full project wipe — all four filters must be explicitly set to "*"
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
```
</CodeGroup>
<Warning>
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
</Warning>
## Delete with Mem0 OSS
+1 -194
View File
@@ -375,8 +375,7 @@
"cookbooks/frameworks/multimodal-retrieval",
"cookbooks/frameworks/eliza-os-character",
"cookbooks/frameworks/chrome-extension",
"cookbooks/frameworks/gemini-3-with-mem0-mcp",
"cookbooks/frameworks/mirofish-swarm-memory"
"cookbooks/frameworks/gemini-3-with-mem0-mcp"
]
}
]
@@ -859,198 +858,6 @@
{
"source": "/features/memory-export",
"destination": "/platform/features/memory-export"
},
{
"source": "/v0x/components/:a/:b/:c",
"destination": "/components/:a/:b/:c"
},
{
"source": "/v0x/components/:a/:b",
"destination": "/components/:a/:b"
},
{
"source": "/v0x/core-concepts/:a/:b",
"destination": "/core-concepts/:a/:b"
},
{
"source": "/v0x/integrations/:slug",
"destination": "/integrations/:slug"
},
{
"source": "/v0x/open-source/:slug",
"destination": "/open-source/:slug"
},
{
"source": "/v0x/introduction",
"destination": "/introduction"
},
{
"source": "/features/async-client",
"destination": "/platform/features/async-client"
},
{
"source": "/features/custom-prompts",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/selective-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/custom-categories",
"destination": "/platform/features/custom-categories"
},
{
"source": "/components/config",
"destination": "/open-source/configuration"
},
{
"source": "/concepts/memory-scoring",
"destination": "/core-concepts/memory-types"
},
{
"source": "/cookbooks/research-copilot",
"destination": "/cookbooks/operations/deep-research"
},
{
"source": "/platform/features/organizations-projects",
"destination": "/api-reference/organizations-projects"
},
{
"source": "/playground",
"destination": "/platform/quickstart"
},
{
"source": "/cdn-cgi/l/email-protection",
"destination": "/introduction"
},
{
"source": "/features/online-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/multimodal",
"destination": "/platform/features/multimodal-support"
},
{
"source": "/features/inferences",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/graph-memory",
"destination": "/platform/features/graph-memory"
},
{
"source": "/features/:slug",
"destination": "/platform/features/:slug"
},
{
"source": "/platform/features/online-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/platform/features/multimodal",
"destination": "/platform/features/multimodal-support"
},
{
"source": "/platform/features/inferences",
"destination": "/platform/features/platform-overview"
},
{
"source": "/platform/features/custom-prompts",
"destination": "/platform/features/custom-instructions"
},
{
"source": "/platform/features/rest-api",
"destination": "/open-source/features/rest-api"
},
{
"source": "/components/embedders/models/google_ai",
"destination": "/components/embedders/models/google_AI"
},
{
"source": "/components/embedders/models/lm_studio",
"destination": "/components/embedders/models/lmstudio"
},
{
"source": "/components/llms/models/xai",
"destination": "/components/llms/models/xAI"
},
{
"source": "/components/llms/models/google_ai",
"destination": "/components/llms/models/google_AI"
},
{
"source": "/components/llms/models/mistral_ai",
"destination": "/components/llms/models/mistral_AI"
},
{
"source": "/components/llms/models/lm_studio",
"destination": "/components/llms/models/lmstudio"
},
{
"source": "/components/vectordbs/dbs/neptune-analytics",
"destination": "/components/vectordbs/dbs/neptune_analytics"
},
{
"source": "/components/vectordbs/dbs/s3-vectors",
"destination": "/components/vectordbs/dbs/s3_vectors"
},
{
"source": "/open-source/python_quickstart",
"destination": "/open-source/python-quickstart"
},
{
"source": "/open-source/node_quickstart",
"destination": "/open-source/node-quickstart"
},
{
"source": "/open-source/rest-api",
"destination": "/open-source/features/rest-api"
},
{
"source": "/cookbooks/deep-research",
"destination": "/cookbooks/operations/deep-research"
},
{
"source": "/v0x/overview",
"destination": "/platform/overview"
},
{
"source": "/v0x/quickstart",
"destination": "/platform/quickstart"
},
{
"source": "/v0x/faqs",
"destination": "/platform/faqs"
},
{
"source": "/integrations/multion",
"destination": "/integrations"
},
{
"source": "/integrations/composio",
"destination": "/integrations"
},
{
"source": "/integrations/qdrant",
"destination": "/components/vectordbs/dbs/qdrant"
},
{
"source": "/integrations/anthropic",
"destination": "/components/llms/models/anthropic"
},
{
"source": "/llms",
"destination": "/components/llms/overview"
},
{
"source": "/open-source/graph-memory",
"destination": "/open-source/features/graph-memory"
},
{
"source": "/cookbooks/customer-support-agent",
"destination": "/cookbooks/operations/support-inbox"
}
]
}
+1 -1
View File
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
logger.info("About to await mem0_client.search for RAG context")
search_results = await mem0_client.search(
new_message.text_content,
filters={"user_id": RAG_USER_ID},
user_id=RAG_USER_ID,
)
logger.info(f"mem0_client.search returned: {search_results}")
if search_results and search_results.get('results', []):
+2 -16
View File
@@ -25,20 +25,6 @@ openclaw plugins install @mem0/openclaw-mem0
## Setup and Configuration
### Understanding `userId`
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
Pick any stable, unique identifier for the user. Common choices:
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
- A simple username (e.g. `"alice"`)
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
<Tip>In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.</Tip>
### Platform Mode (Mem0 Cloud)
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
@@ -51,7 +37,7 @@ Add to your `openclaw.json`:
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
@@ -65,7 +51,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
"enabled": true,
"config": {
"mode": "open-source",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
+4 -4
View File
@@ -104,7 +104,7 @@ Key differentiators:
- [Together](https://docs.mem0.ai/components/llms/models/together): Open-source model inference platform
- [DeepSeek](https://docs.mem0.ai/components/llms/models/deepseek): Advanced reasoning models
- [Sarvam](https://docs.mem0.ai/components/llms/models/sarvam): Indian language models
- [XAI](https://docs.mem0.ai/components/llms/models/xAI): xAI models integration
- [XAI](https://docs.mem0.ai/components/llms/models/xai): xAI models integration
- [LiteLLM](https://docs.mem0.ai/components/llms/models/litellm): Unified LLM interface and proxy
- [LangChain](https://docs.mem0.ai/components/llms/models/langchain): LangChain LLM integration
- [OpenAI Structured](https://docs.mem0.ai/components/llms/models/openai_structured): OpenAI with structured output support
@@ -120,7 +120,7 @@ Key differentiators:
- [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus): Open-source vector database for AI applications at scale
- [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis): Real-time vector storage and search with Redis Stack
- [Supabase](https://docs.mem0.ai/components/vectordbs/dbs/supabase): Open-source Firebase alternative with vector support
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash-vector): Serverless vector database
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash_vector): Serverless vector database
- [Elasticsearch](https://docs.mem0.ai/components/vectordbs/dbs/elasticsearch): Distributed search and analytics engine
- [OpenSearch](https://docs.mem0.ai/components/vectordbs/dbs/opensearch): Open-source search and analytics platform
- [FAISS](https://docs.mem0.ai/components/vectordbs/dbs/faiss): Facebook AI Similarity Search library
@@ -136,9 +136,9 @@ Key differentiators:
- [OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/openai): High-quality text embeddings with customizable dimensions
- [Azure OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/azure_openai): Enterprise Azure-hosted embedding models
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_AI): Gemini embedding models
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_ai): Gemini embedding models
- [AWS Bedrock](https://docs.mem0.ai/components/embedders/models/aws_bedrock): Amazon embedding models through Bedrock
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/huggingface): Open-source embedding models for local deployment
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/hugging_face): Open-source embedding models for local deployment
- [Vertex AI](https://docs.mem0.ai/components/embedders/models/vertexai): Google Cloud's enterprise embedding models
- [Ollama](https://docs.mem0.ai/components/embedders/models/ollama): Local embedding models for privacy-focused applications
- [Together](https://docs.mem0.ai/components/embedders/models/together): Open-source model embeddings
+6 -21
View File
@@ -255,28 +255,13 @@ def delete(
### delete_all() Method
#### Breaking Change — Empty filter no longer silently deletes everything
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
**After:**
- No filters → raises a validation error (prevents accidental full-project wipe).
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
#### No Breaking Changes
```python
# v0.x — no filter silently wiped all project memories
m.delete_all() # DANGER: deleted everything
m.delete_all(user_id="alice") # deleted alice's memories
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
m.delete_all() # ERROR: at least one filter required
m.delete_all(user_id="alice") # unchanged
m.delete_all(user_id="*") # NEW — delete all users' memories
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
# Same signature in both versions
def delete_all(
self,
user_id: str
) -> dict
```
## Platform Client (MemoryClient) Changes
+1 -1
View File
@@ -370,7 +370,7 @@ If you encounter issues, you can revert immediately by switching your import bac
- [Platform Dashboard](https://app.mem0.ai) - Monitor usage and manage settings.
- [Webhooks Setup](/platform/features/webhooks) - Configure real-time event notifications.
- [Organizations & Projects](/api-reference/organizations-projects) - Set up multi-tenancy for your team.
- [Organizations & Projects](/platform/features/organizations-projects) - Set up multi-tenancy for your team.
<CardGroup cols={2}>
<Card
+1 -1
View File
@@ -168,6 +168,6 @@ memory = Memory.from_config_file("config.yaml")
title="Deploy with Docker Compose"
description="Follow the end-to-end OSS deployment walkthrough."
icon="server"
href="/open-source/features/rest-api"
href="/cookbooks/companions/local-companion-ollama"
/>
</CardGroup>
+1 -1
View File
@@ -46,7 +46,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
</CardGroup>
<CardGroup cols={2}>
<Card title="Deploy with Docker Compose" icon="server" href="/open-source/features/rest-api">
<Card title="Deploy with Docker Compose" icon="server" href="/cookbooks/companions/local-companion-ollama">
Reference deployment with REST endpoints.
</Card>
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
+13 -13
View File
@@ -1180,7 +1180,7 @@
"tags": [
"memories"
],
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
"description": "Delete memories.",
"operationId": "memories_delete",
"parameters": [
{
@@ -1189,7 +1189,7 @@
"schema": {
"type": "string"
},
"description": "Filter by user ID. Pass `*` to delete memories for all users."
"description": "Filter memories by user ID."
},
{
"name": "agent_id",
@@ -1197,7 +1197,7 @@
"schema": {
"type": "string"
},
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
"description": "Filter memories by agent ID."
},
{
"name": "app_id",
@@ -1205,7 +1205,7 @@
"schema": {
"type": "string"
},
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
"description": "Filter memories by app ID."
},
{
"name": "run_id",
@@ -1213,7 +1213,7 @@
"schema": {
"type": "string"
},
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
"description": "Filter memories by run ID."
},
{
"name": "metadata",
@@ -1263,15 +1263,15 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
},
{
"lang": "cURL",
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
},
{
"lang": "Go",
@@ -5570,26 +5570,26 @@
},
"DeleteMemoriesInput": {
"type": "object",
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
"properties": {
"user_id": {
"type": "string",
"description": "User ID to delete memories for. Pass \"*\" for all users.",
"description": "The unique identifier of the user whose memories should be deleted.",
"nullable": true
},
"agent_id": {
"type": "string",
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
"description": "The unique identifier of the agent whose memories should be deleted.",
"nullable": true
},
"app_id": {
"type": "string",
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
"description": "The unique identifier of the application whose memories should be deleted.",
"nullable": true
},
"run_id": {
"type": "string",
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
"description": "The unique identifier of the run whose memories should be deleted.",
"nullable": true
}
},
-32
View File
@@ -117,38 +117,6 @@ OPENAI_API_KEY=sk-xxx
USER=<user-id> # The User ID you want to associate the memories with
```
#### LLM Configuration (optional)
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`:
| Variable | Description | Default |
|---|---|---|
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
**Example: Using Ollama (fully local)**
```bash
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:latest
EMBEDDER_PROVIDER=ollama
EMBEDDER_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434
```
**Example: Using Anthropic**
```bash
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_API_KEY=sk-ant-xxx
```
#### Example `/ui/.env`
```bash
NEXT_PUBLIC_API_URL=http://localhost:8765
-4
View File
@@ -123,10 +123,6 @@ await client.deleteAll({ user_id: "alice" });
</CodeGroup>
<Note>
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
</Note>
### History
Get the history of a specific memory asynchronously.
+1 -1
View File
@@ -123,7 +123,7 @@ const response = await fetch("https://api.mem0.ai/v1/memories", {
## Sample workflow
- [Build a Customer Support Agent](/cookbooks/operations/support-inbox)
- [Build a Customer Support Agent](/cookbooks/customer-support-agent)
{/* DEBUG: verify CTA targets */}
+2 -2
View File
@@ -124,10 +124,10 @@ Walk through a real request/response. Include sample payloads and highlight nota
{/* DEBUG: verify CTA targets */}
<CardGroup cols={2}>
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/concepts/memory-scoring">
Understand how Mem0 ranks memories under the hood.
</Card>
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/research-copilot">
See advanced retrieval driving a full knowledge assistant.
</Card>
</CardGroup>
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
coverage/
*.db
.env
.env.*
-4
View File
@@ -1,4 +0,0 @@
node_modules/
dist/
coverage/
pnpm-lock.yaml
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.4.1",
"version": "2.2.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -37,7 +37,6 @@
"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 .",
@@ -78,7 +77,6 @@
"author": "Deshraj Yadav",
"license": "Apache-2.0",
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.7.6",
"@types/uuid": "^9.0.8",
"dotenv": "^16.4.5",
@@ -93,7 +91,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.13.6",
"axios": "1.7.7",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
@@ -104,19 +102,20 @@
"@azure/search-documents": "^12.0.0",
"@cloudflare/workers-types": "^4.20250504.0",
"@google/genai": "^1.2.0",
"@langchain/core": "^1.0.0",
"@langchain/core": "^0.3.44",
"@mistralai/mistralai": "^1.5.2",
"@qdrant/js-client-rest": "1.13.0",
"@supabase/supabase-js": "^2.49.1",
"@types/jest": "29.5.14",
"@types/pg": "8.11.0",
"better-sqlite3": "^12.6.2",
"@types/sqlite3": "3.1.11",
"cloudflare": "^4.2.0",
"groq-sdk": "0.3.0",
"neo4j-driver": "^5.28.1",
"ollama": "^0.5.14",
"pg": "8.11.3",
"redis": "^4.6.13"
"redis": "^4.6.13",
"sqlite3": "5.1.7"
},
"engines": {
"node": ">=18"
@@ -128,7 +127,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"better-sqlite3"
"sqlite3"
]
}
}
+820 -119
View File
File diff suppressed because it is too large Load Diff
+1 -18
View File
@@ -18,26 +18,9 @@ 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";
+5 -6
View File
@@ -17,7 +17,6 @@ import {
GetMemoryExportPayload,
} from "./mem0.types";
import { captureClientEvent, generateHash } from "./telemetry";
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
class APIError extends Error {
constructor(message: string) {
@@ -156,7 +155,7 @@ export default class MemoryClient {
});
if (!response.ok) {
const errorData = await response.text();
throw createExceptionFromResponse(response.status, errorData);
throw new APIError(`API request failed: ${errorData}`);
}
const jsonResponse = await response.json();
return jsonResponse;
@@ -201,8 +200,8 @@ export default class MemoryClient {
if (project_id && !this.projectId) this.projectId = project_id;
if (user_email) this.telemetryId = user_email;
} catch (error: any) {
// Pass through structured exceptions and APIError
if (error instanceof MemoryError || error instanceof APIError) {
// Convert generic errors to APIError with meaningful messages
if (error instanceof APIError) {
throw error;
} else {
throw new APIError(
@@ -311,7 +310,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;
@@ -362,7 +361,7 @@ export default class MemoryClient {
this._validateOrgProject();
const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]);
const { api_version, ...otherOptions } = options ?? {};
const { api_version, ...otherOptions } = options!;
const payload = { query, ...otherOptions };
if (this.organizationName != null && this.projectName != null) {
payload.org_name = this.organizationName;
+1 -1
View File
@@ -170,7 +170,7 @@ export interface PromptUpdatePayload {
[key: string]: any;
}
export enum WebhookEvent {
enum WebhookEvent {
MEMORY_ADDED = "memory_add",
MEMORY_UPDATED = "memory_update",
MEMORY_DELETED = "memory_delete",
-189
View File
@@ -1,189 +0,0 @@
/**
* 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;
}
@@ -1,103 +0,0 @@
/**
* 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([]);
});
});
@@ -1,380 +0,0 @@
/**
* 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([]);
});
});
@@ -1,251 +0,0 @@
/**
* 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();
});
});
@@ -1,251 +0,0 @@
/**
* 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();
});
});
@@ -1,103 +0,0 @@
/**
* 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);
});
});
@@ -0,0 +1,391 @@
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!");
}
});
});
@@ -1,185 +0,0 @@
/**
* 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();
});
});
@@ -1,154 +0,0 @@
/**
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
* Tests verify request URL and HTTP method, not mock response values.
*/
import { MemoryClient } from "../mem0";
import { WebhookEvent } from "../mem0.types";
import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
import {
setupMockFetch,
findFetchCall,
getFetchBody,
installConsoleSuppression,
} from "./setup";
installConsoleSuppression();
describe("MemoryClient - Webhooks", () => {
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.getWebhooks();
const call = mock.mock.calls.find(
(c: [string, RequestInit]) =>
c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method,
);
expect(call).toBeDefined();
});
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
});
test("createWebhook includes webhook payload in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
const body = getFetchBody(call!);
expect(body.name).toBe("new-hook");
expect(body.url).toBe("https://example.com");
});
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
});
test("updateWebhook includes updated fields in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
const body = getFetchBody(call!);
expect(body.name).toBe("updated-hook");
expect(body.url).toBe("https://new-url.com");
});
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook deleted" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.deleteWebhook({ webhookId: "wh_1" });
expect(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"),
).toBeDefined();
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* Shared test setup for MemoryClient unit tests.
* Provides mock fetch wiring, console suppression, and utility finders.
*/
import {
createMockFetch,
createStandardMockResponses,
MOCK_PING_RESPONSE,
} from "./helpers";
// ─── Global fetch mock + telemetry suppression ───────────
const originalFetch = global.fetch;
export function setupMockFetch(
extraResponses?: Map<string, { status: number; body: unknown }>,
): jest.Mock {
const responses = createStandardMockResponses();
if (extraResponses) {
for (const [key, value] of extraResponses) {
responses.set(key, value);
}
}
const mockFetch = createMockFetch(responses);
global.fetch = mockFetch;
return mockFetch;
}
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
export function installConsoleSuppression(): void {
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
const msg = String(args[0] ?? "");
if (
msg.includes("Telemetry") ||
msg.includes("Failed to initialize") ||
msg.includes("Failed to capture")
) {
return;
}
originalConsoleError(...args);
});
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
const msg = String(args[0] ?? "");
if (msg.includes("telemetry") || msg.includes("Telemetry")) {
return;
}
originalConsoleWarn(...args);
});
});
afterAll(() => {
jest.restoreAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
}
// ─── Helper: find specific fetch calls ───────────────────
export function findFetchCall(
mock: jest.Mock,
urlPattern: string,
method?: string,
): [string, RequestInit] | undefined {
return mock.mock.calls.find((call: [string, RequestInit]) => {
const urlMatch = call[0].includes(urlPattern);
if (!method) return urlMatch;
return urlMatch && call[1]?.method === method;
});
}
export function getFetchBody(
call: [string, RequestInit],
): Record<string, unknown> {
return JSON.parse(call[1].body as string);
}
export { MOCK_PING_RESPONSE };
-246
View File
@@ -1,246 +0,0 @@
import {
MemoryError,
AuthenticationError,
RateLimitError,
ValidationError,
MemoryNotFoundError,
NetworkError,
ConfigurationError,
MemoryQuotaExceededError,
createExceptionFromResponse,
HTTP_STATUS_TO_EXCEPTION,
} from "./exceptions";
describe("MemoryError", () => {
const error = new MemoryError("test error", "MEM_001", {
details: { operation: "add" },
suggestion: "Try again",
debugInfo: { requestId: "req_123" },
});
test("is an instance of Error", () => {
expect(error).toBeInstanceOf(Error);
});
test("has correct message", () => {
expect(error.message).toBe("test error");
});
test("has correct errorCode", () => {
expect(error.errorCode).toBe("MEM_001");
});
test("has correct details", () => {
expect(error.details).toEqual({ operation: "add" });
});
test("has correct suggestion", () => {
expect(error.suggestion).toBe("Try again");
});
test("has correct debugInfo", () => {
expect(error.debugInfo).toEqual({ requestId: "req_123" });
});
test("defaults details to empty object", () => {
const err = new MemoryError("test error", "MEM_001");
expect(err.details).toEqual({});
});
test("defaults suggestion to undefined", () => {
const err = new MemoryError("test error", "MEM_001");
expect(err.suggestion).toBeUndefined();
});
test("defaults debugInfo to empty object", () => {
const err = new MemoryError("test error", "MEM_001");
expect(err.debugInfo).toEqual({});
});
test("is throwable and catchable", () => {
expect(() => {
throw new MemoryError("fail", "MEM_001");
}).toThrow("fail");
});
});
describe("Exception subclasses", () => {
const subclasses = [
{ Class: AuthenticationError, name: "AuthenticationError" },
{ Class: RateLimitError, name: "RateLimitError" },
{ Class: ValidationError, name: "ValidationError" },
{ Class: MemoryNotFoundError, name: "MemoryNotFoundError" },
{ Class: NetworkError, name: "NetworkError" },
{ Class: ConfigurationError, name: "ConfigurationError" },
{ Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" },
] as const;
test.each(subclasses)("$name extends MemoryError", ({ Class }) => {
const error = new Class("test", "CODE_001");
expect(error).toBeInstanceOf(MemoryError);
});
test.each(subclasses)("$name extends Error", ({ Class }) => {
const error = new Class("test", "CODE_001");
expect(error).toBeInstanceOf(Error);
});
test.each(subclasses)("$name has correct name", ({ Class, name }) => {
const error = new Class("test", "CODE_001");
expect(error.name).toBe(name);
});
test.each(subclasses)("$name supports instanceof checks", ({ Class }) => {
const error = new Class("test", "CODE_001");
expect(error instanceof Class).toBe(true);
});
});
describe("createExceptionFromResponse", () => {
test("maps 401 to AuthenticationError", () => {
const error = createExceptionFromResponse(401, "Unauthorized");
expect(error).toBeInstanceOf(AuthenticationError);
});
test("maps 401 to errorCode HTTP_401", () => {
const error = createExceptionFromResponse(401, "Unauthorized");
expect(error.errorCode).toBe("HTTP_401");
});
test("maps 401 to authentication suggestion", () => {
const error = createExceptionFromResponse(401, "Unauthorized");
expect(error.suggestion).toBe(
"Please check your API key and authentication credentials",
);
});
test("maps 429 to RateLimitError", () => {
const error = createExceptionFromResponse(429, "Too many requests", {
debugInfo: { retryAfter: 60 },
});
expect(error).toBeInstanceOf(RateLimitError);
});
test("maps 429 passes debugInfo through", () => {
const error = createExceptionFromResponse(429, "Too many requests", {
debugInfo: { retryAfter: 60 },
});
expect(error.debugInfo).toEqual({ retryAfter: 60 });
});
test("maps 404 to MemoryNotFoundError", () => {
const error = createExceptionFromResponse(404, "Not found");
expect(error).toBeInstanceOf(MemoryNotFoundError);
});
test("maps 400 to ValidationError", () => {
const error = createExceptionFromResponse(400, "Bad request");
expect(error).toBeInstanceOf(ValidationError);
});
test("maps 413 to MemoryQuotaExceededError", () => {
const error = createExceptionFromResponse(413, "Quota exceeded");
expect(error).toBeInstanceOf(MemoryQuotaExceededError);
});
test.each([502, 503, 504])("maps %i to NetworkError", (code) => {
const error = createExceptionFromResponse(code, "Service unavailable");
expect(error).toBeInstanceOf(NetworkError);
});
test("maps 500 to MemoryError", () => {
const error = createExceptionFromResponse(500, "Internal error");
expect(error).toBeInstanceOf(MemoryError);
});
test("maps 500 to errorCode HTTP_500", () => {
const error = createExceptionFromResponse(500, "Internal error");
expect(error.errorCode).toBe("HTTP_500");
});
test("maps unknown status to MemoryError", () => {
const error = createExceptionFromResponse(418, "I am a teapot");
expect(error).toBeInstanceOf(MemoryError);
});
test("maps unknown status to correct errorCode", () => {
const error = createExceptionFromResponse(418, "I am a teapot");
expect(error.errorCode).toBe("HTTP_418");
});
test("maps unknown status to retry suggestion", () => {
const error = createExceptionFromResponse(418, "I am a teapot");
expect(error.suggestion).toBe("Please try again later");
});
test("uses response text as message", () => {
const error = createExceptionFromResponse(400, "Invalid user_id format");
expect(error.message).toBe("Invalid user_id format");
});
test("falls back to generic message when response text is empty", () => {
const error = createExceptionFromResponse(500, "");
expect(error.message).toBe("HTTP 500 error");
});
test("passes details through", () => {
const error = createExceptionFromResponse(400, "Bad request", {
details: { field: "user_id", value: "" },
});
expect(error.details).toEqual({ field: "user_id", value: "" });
});
});
describe("HTTP_STATUS_TO_EXCEPTION", () => {
test("maps 400 to ValidationError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError);
});
test("maps 401 to AuthenticationError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError);
});
test("maps 403 to AuthenticationError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError);
});
test("maps 404 to MemoryNotFoundError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError);
});
test("maps 408 to NetworkError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError);
});
test("maps 409 to ValidationError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError);
});
test("maps 413 to MemoryQuotaExceededError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError);
});
test("maps 422 to ValidationError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError);
});
test("maps 429 to RateLimitError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError);
});
test("maps 500 to MemoryError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError);
});
test("maps 502 to NetworkError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError);
});
test("maps 503 to NetworkError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError);
});
test("maps 504 to NetworkError", () => {
expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError);
});
});
-205
View File
@@ -1,205 +0,0 @@
/**
* 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 },
);
}
+2 -1
View File
@@ -19,6 +19,7 @@
"@types/node": "^20.11.19",
"@types/pg": "^8.11.0",
"@types/redis": "^4.0.10",
"@types/sqlite3": "^3.1.11",
"@types/uuid": "^9.0.8",
"cloudflare": "^4.2.0",
"dotenv": "^16.4.4",
@@ -26,7 +27,7 @@
"openai": "^4.28.0",
"pg": "^8.11.3",
"redis": "^4.7.0",
"better-sqlite3": "^12.6.2",
"sqlite3": "^5.1.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
+11 -62
View File
@@ -20,28 +20,14 @@ export class ConfigManager {
finalModel = userConf.model;
}
// Normalize snake_case keys from Python SDK / OpenClaw configs
const baseURL =
userConf?.baseURL ??
((userConf as Record<string, unknown>)?.lmstudio_base_url as
| string
| undefined) ??
userConf?.url;
const embeddingDims =
userConf?.embeddingDims ??
((userConf as Record<string, unknown>)?.embedding_dims as
| number
| undefined);
return {
apiKey:
userConf?.apiKey !== undefined
? userConf.apiKey
: defaultConf.apiKey,
model: finalModel,
baseURL,
url: userConf?.url,
embeddingDims,
embeddingDims: userConf?.embeddingDims,
modelProperties:
userConf?.modelProperties !== undefined
? userConf.modelProperties
@@ -57,23 +43,13 @@ export class ConfigManager {
const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config;
const userConf = userConfig.vectorStore?.config;
// Resolve the vector store dimension. If the user explicitly
// provided one, use it. Otherwise leave it undefined so that
// Memory._autoInitialize() can auto-detect it by running a
// probe embedding at startup — this makes *any* embedder work
// out of the box without the user needing to know or set the
// dimension manually.
const explicitDimension =
userConf?.dimension ||
userConfig.embedder?.config?.embeddingDims ||
undefined;
// Prioritize user-provided client instance
if (userConf?.client && typeof userConf.client === "object") {
return {
client: userConf.client,
collectionName: userConf.collectionName,
dimension: explicitDimension,
// Include other fields from userConf if necessary, or omit defaults
collectionName: userConf.collectionName, // Can be undefined
dimension: userConf.dimension || defaultConf.dimension, // Merge dimension
...userConf, // Include any other passthrough fields from user
};
} else {
@@ -81,7 +57,7 @@ export class ConfigManager {
return {
collectionName:
userConf?.collectionName || defaultConf.collectionName,
dimension: explicitDimension,
dimension: userConf?.dimension || defaultConf.dimension,
// Ensure client is not carried over from defaults if not provided by user
client: undefined,
// Include other passthrough fields from userConf even if no client
@@ -104,17 +80,8 @@ export class ConfigManager {
finalModel = userConf.model;
}
// Normalize snake_case keys from Python SDK / OpenClaw configs
const llmBaseURL =
userConf?.baseURL ??
((userConf as Record<string, unknown>)?.lmstudio_base_url as
| string
| undefined) ??
defaultConf.baseURL;
return {
baseURL: llmBaseURL,
url: userConf?.url,
baseURL: userConf?.baseURL || defaultConf.baseURL,
apiKey:
userConf?.apiKey !== undefined
? userConf.apiKey
@@ -128,34 +95,16 @@ export class ConfigManager {
})(),
},
historyDbPath:
userConfig.historyDbPath ||
userConfig.historyStore?.config?.historyDbPath ||
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.historyDbPath,
customPrompt: userConfig.customPrompt,
graphStore: {
...DEFAULT_MEMORY_CONFIG.graphStore,
...userConfig.graphStore,
},
historyStore: (() => {
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
const historyProvider =
userConfig.historyStore?.provider || defaultHistoryStore.provider;
const isSqlite = historyProvider.toLowerCase() === "sqlite";
// Precedence: explicit historyStore.config > top-level historyDbPath > default
return {
...defaultHistoryStore,
...userConfig.historyStore,
provider: historyProvider,
config: {
...(isSqlite ? defaultHistoryStore.config : {}),
...(isSqlite && userConfig.historyDbPath
? { historyDbPath: userConfig.historyDbPath }
: {}),
...userConfig.historyStore?.config,
},
};
})(),
historyStore: {
...DEFAULT_MEMORY_CONFIG.historyStore,
...userConfig.historyStore,
},
disableHistory:
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
+1 -1
View File
@@ -28,7 +28,7 @@ export class GoogleEmbedder implements Embedder {
const response = await this.google.models.embedContent({
model: this.model,
contents: texts,
config: { outputDimensionality: this.embeddingDims },
config: { outputDimensionality: 768 },
});
return response.embeddings!.map((item) => item.values!);
}
@@ -1,53 +0,0 @@
import OpenAI from "openai";
import { Embedder } from "./base";
import { EmbeddingConfig } from "../types";
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
const DEFAULT_MODEL =
"nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf";
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
export class LMStudioEmbedder implements Embedder {
private openai: OpenAI;
private model: string;
constructor(config: EmbeddingConfig) {
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
this.model = config.model || DEFAULT_MODEL;
}
async embed(text: string): Promise<number[]> {
const normalized =
typeof text === "string" ? text.replace(/\n/g, " ") : String(text);
try {
const response = await this.openai.embeddings.create({
model: this.model,
input: normalized,
encoding_format: "float",
});
return response.data[0].embedding;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio embedder failed: ${message}`);
}
}
async embedBatch(texts: string[]): Promise<number[][]> {
const normalized = texts.map((t) =>
typeof t === "string" ? t.replace(/\n/g, " ") : String(t),
);
try {
const response = await this.openai.embeddings.create({
model: this.model,
input: normalized,
encoding_format: "float",
});
return response.data.map((item) => item.embedding);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio embedder failed: ${message}`);
}
}
}
+5 -21
View File
@@ -12,7 +12,7 @@ export class OllamaEmbedder implements Embedder {
constructor(config: EmbeddingConfig) {
this.ollama = new Ollama({
host: config.url || config.baseURL || "http://localhost:11434",
host: config.url || "http://localhost:11434",
});
this.model = config.model || "nomic-embed-text:latest";
this.embeddingDims = config.embeddingDims || 768;
@@ -27,18 +27,11 @@ export class OllamaEmbedder implements Embedder {
} catch (err) {
logger.error(`Error ensuring model exists: ${err}`);
}
// Coerce defensively since callers may pass values parsed from untrusted LLM JSON output.
const input = typeof text === "string" ? text : JSON.stringify(text);
const response = await this.ollama.embed({
const response = await this.ollama.embeddings({
model: this.model,
input,
prompt: text,
});
if (!response.embeddings || response.embeddings.length === 0) {
throw new Error(
`Ollama embed() returned no embeddings for model '${this.model}'`,
);
}
return response.embeddings[0];
return response.embedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
@@ -46,21 +39,12 @@ export class OllamaEmbedder implements Embedder {
return response;
}
private static normalizeModelName(name: string): string {
return name.includes(":") ? name : `${name}:latest`;
}
private async ensureModelExists(): Promise<boolean> {
if (this.initialized) {
return true;
}
const local_models = await this.ollama.list();
const target = OllamaEmbedder.normalizeModelName(this.model);
if (
!local_models.models.find(
(m: any) => OllamaEmbedder.normalizeModelName(m.name) === target,
)
) {
if (!local_models.models.find((m: any) => m.name === this.model)) {
logger.info(`Pulling model ${this.model}...`);
await this.ollama.pull({ model: this.model });
}
+1 -4
View File
@@ -8,10 +8,7 @@ export class OpenAIEmbedder implements Embedder {
private embeddingDims?: number;
constructor(config: EmbeddingConfig) {
this.openai = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL || config.url,
});
this.openai = new OpenAI({ apiKey: config.apiKey });
this.model = config.model || "text-embedding-3-small";
this.embeddingDims = config.embeddingDims || 1536;
}
-2
View File
@@ -88,8 +88,6 @@ Memory Format:
source -- relationship -- destination
Provide a list of deletion instructions, each specifying the relationship to be deleted.
Respond in JSON format.
`;
export function getDeleteMessages(
-2
View File
@@ -4,7 +4,6 @@ export * from "./types";
export * from "./embeddings/base";
export * from "./embeddings/openai";
export * from "./embeddings/ollama";
export * from "./embeddings/lmstudio";
export * from "./embeddings/google";
export * from "./embeddings/azure";
export * from "./embeddings/langchain";
@@ -15,7 +14,6 @@ export * from "./llms/openai_structured";
export * from "./llms/anthropic";
export * from "./llms/groq";
export * from "./llms/ollama";
export * from "./llms/lmstudio";
export * from "./llms/mistral";
export * from "./llms/langchain";
export * from "./vector_stores/base";
+16 -41
View File
@@ -16,49 +16,24 @@ export class GoogleLLM implements LLM {
responseFormat?: { type: string },
tools?: any[],
): Promise<string | LLMResponse> {
const contents = messages.map((msg) => ({
parts: [
{
text:
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content),
},
],
role: msg.role === "system" ? "model" : "user",
}));
// Build config with tools if provided
const config: Record<string, any> = {};
if (tools && tools.length > 0) {
config.tools = [
{
functionDeclarations: tools.map((tool) => ({
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
})),
},
];
}
const completion = await this.google.models.generateContent({
contents,
model: this.model,
config,
});
contents: messages.map((msg) => ({
parts: [
{
text:
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content),
},
],
role: msg.role === "system" ? "model" : "user",
})),
// Handle function call responses
if (completion.functionCalls && completion.functionCalls.length > 0) {
return {
content: completion.text || "",
role: "assistant",
toolCalls: completion.functionCalls.map((call) => ({
name: call.name!,
arguments: JSON.stringify(call.args),
})),
};
}
model: this.model,
// config: {
// responseSchema: {}, // Add response schema if needed
// },
});
const text = completion.text
?.replace(/^```json\n/, "")
-41
View File
@@ -1,41 +0,0 @@
import { OpenAILLM } from "./openai";
import { LLMConfig, Message } from "../types";
import { LLMResponse } from "./base";
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
const DEFAULT_MODEL =
"lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf";
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
export class LMStudioLLM extends OpenAILLM {
constructor(config: LLMConfig) {
super({
...config,
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
model: config.model || DEFAULT_MODEL,
});
}
async generateResponse(
messages: Message[],
responseFormat?: { type: string },
tools?: any[],
): Promise<string | LLMResponse> {
try {
return await super.generateResponse(messages, responseFormat, tools);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio LLM failed: ${message}`);
}
}
async generateChat(messages: Message[]): Promise<LLMResponse> {
try {
return await super.generateChat(messages);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio LLM failed: ${message}`);
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
constructor(config: LLMConfig) {
this.ollama = new Ollama({
host: config.url || config.baseURL || "http://localhost:11434",
host: config.config?.url || "http://localhost:11434",
});
this.model = config.model || "llama3.1:8b";
this.ensureModelExists().catch((err) => {
+1 -1
View File
@@ -212,7 +212,7 @@ export class MemoryGraph {
[
{
role: "system",
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.`,
},
{ role: "user", content: data },
],
+32 -94
View File
@@ -15,7 +15,6 @@ import {
HistoryManagerFactory,
} from "../utils/factory";
import {
FactRetrievalSchema,
getFactRetrievalMessages,
getUpdateMemoryMessages,
parseMessages,
@@ -41,7 +40,7 @@ export class Memory {
private config: MemoryConfig;
private customPrompt: string | undefined;
private embedder: Embedder;
private vectorStore!: VectorStore;
private vectorStore: VectorStore;
private llm: LLM;
private db: HistoryManager;
private collectionName: string | undefined;
@@ -49,8 +48,6 @@ export class Memory {
private graphMemory?: MemoryGraph;
private enableGraph: boolean;
telemetryId: string;
private _initPromise: Promise<void>;
private _initError?: Error;
constructor(config: Partial<MemoryConfig> = {}) {
// Merge and validate config
@@ -61,9 +58,10 @@ export class Memory {
this.config.embedder.provider,
this.config.embedder.config,
);
// Vector store creation is deferred to _autoInitialize() so that
// the embedding dimension can be auto-detected first when not
// explicitly configured.
this.vectorStore = VectorStoreFactory.create(
this.config.vectorStore.provider,
this.config.vectorStore.config,
);
this.llm = LLMFactory.create(
this.config.llm.provider,
this.config.llm.config,
@@ -71,10 +69,20 @@ export class Memory {
if (this.config.disableHistory) {
this.db = new DummyHistoryManager();
} else {
this.db = HistoryManagerFactory.create(
this.config.historyStore!.provider,
this.config.historyStore!,
);
const defaultConfig = {
provider: "sqlite",
config: {
historyDbPath: this.config.historyDbPath || ":memory:",
},
};
this.db =
this.config.historyStore && !this.config.disableHistory
? HistoryManagerFactory.create(
this.config.historyStore.provider,
this.config.historyStore,
)
: HistoryManagerFactory.create("sqlite", defaultConfig);
}
this.collectionName = this.config.vectorStore.config.collectionName;
@@ -87,67 +95,8 @@ export class Memory {
this.graphMemory = new MemoryGraph(this.config);
}
// Auto-detect embedding dimension (if needed), create vector store,
// and initialize it. All public methods await this before proceeding.
this._initPromise = this._autoInitialize().catch((error) => {
this._initError =
error instanceof Error ? error : new Error(String(error));
console.error(this._initError);
});
}
/**
* If no explicit dimension was provided, runs a probe embedding to
* detect it. Then creates and initializes the vector store.
*/
private async _autoInitialize(): Promise<void> {
if (!this.config.vectorStore.config.dimension) {
try {
const probe = await this.embedder.embed("dimension probe");
this.config.vectorStore.config.dimension = probe.length;
} catch (error: any) {
throw new Error(
`Failed to auto-detect embedding dimension from provider '${this.config.embedder.provider}': ${error.message}. ` +
`Please set 'dimension' in vectorStore.config or 'embeddingDims' in embedder.config explicitly.`,
);
}
}
this.vectorStore = VectorStoreFactory.create(
this.config.vectorStore.provider,
this.config.vectorStore.config,
);
// The vector store constructor may fire initialize() asynchronously
// (e.g. Qdrant). Explicitly await it here to guarantee the backing
// store (collections, tables, etc.) is ready before any public method
// attempts to read or write.
await this.vectorStore.initialize();
await this._initializeTelemetry();
}
/**
* Ensures that auto-initialization (dimension detection + vector store
* creation) has completed before any public method proceeds.
* If a previous init attempt failed, retries automatically.
*/
private async _ensureInitialized(): Promise<void> {
await this._initPromise;
if (this._initError) {
// Clear failed state and retry — the embedder or vector store
// may have been transiently unavailable at startup.
this._initError = undefined;
this._initPromise = this._autoInitialize().catch((error) => {
this._initError =
error instanceof Error ? error : new Error(String(error));
console.error(this._initError);
});
await this._initPromise;
if (this._initError) {
throw this._initError;
}
}
// Initialize telemetry if vector store is initialized
this._initializeTelemetry();
}
private async _initializeTelemetry() {
@@ -207,7 +156,6 @@ export class Memory {
messages: string | Message[],
config: AddMemoryOptions,
): Promise<SearchResult> {
await this._ensureInitialized();
await this._captureEvent("add", {
message_count: Array.isArray(messages) ? messages.length : 1,
has_metadata: !!config.metadata,
@@ -313,8 +261,7 @@ export class Memory {
const cleanResponse = removeCodeBlocks(response as string);
let facts: string[] = [];
try {
const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse));
facts = parsed.facts;
facts = JSON.parse(cleanResponse).facts || [];
} catch (e) {
console.error(
"Failed to parse facts from LLM response:",
@@ -433,7 +380,6 @@ export class Memory {
}
async get(memoryId: string): Promise<MemoryItem | null> {
await this._ensureInitialized();
const memory = await this.vectorStore.get(memoryId);
if (!memory) return null;
@@ -475,7 +421,6 @@ export class Memory {
query: string,
config: SearchMemoryOptions,
): Promise<SearchResult> {
await this._ensureInitialized();
await this._captureEvent("search", {
query_length: query.length,
limit: config.limit,
@@ -542,7 +487,6 @@ export class Memory {
}
async update(memoryId: string, data: string): Promise<{ message: string }> {
await this._ensureInitialized();
await this._captureEvent("update", { memory_id: memoryId });
const embedding = await this.embedder.embed(data);
await this.updateMemory(memoryId, data, { [data]: embedding });
@@ -550,7 +494,6 @@ export class Memory {
}
async delete(memoryId: string): Promise<{ message: string }> {
await this._ensureInitialized();
await this._captureEvent("delete", { memory_id: memoryId });
await this.deleteMemory(memoryId);
return { message: "Memory deleted successfully!" };
@@ -559,7 +502,6 @@ export class Memory {
async deleteAll(
config: DeleteAllMemoryOptions,
): Promise<{ message: string }> {
await this._ensureInitialized();
await this._captureEvent("delete_all", {
has_user_id: !!config.userId,
has_agent_id: !!config.agentId,
@@ -587,12 +529,10 @@ export class Memory {
}
async history(memoryId: string): Promise<any[]> {
await this._ensureInitialized();
return this.db.getHistory(memoryId);
}
async reset(): Promise<void> {
await this._ensureInitialized();
await this._captureEvent("reset");
await this.db.reset();
@@ -617,30 +557,28 @@ export class Memory {
await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check?
}
// Re-initialize factories/clients based on the original config.
// Dimension is already set in this.config from the initial probe,
// so _autoInitialize will skip the probe and just re-create the store.
// Re-initialize factories/clients based on the original config
this.embedder = EmbedderFactory.create(
this.config.embedder.provider,
this.config.embedder.config,
);
// Re-create vector store instance - crucial for Langchain to reset wrapper state if needed
this.vectorStore = VectorStoreFactory.create(
this.config.vectorStore.provider,
this.config.vectorStore.config, // This will pass the original client instance back
);
this.llm = LLMFactory.create(
this.config.llm.provider,
this.config.llm.config,
);
// Re-init DB if needed (though db.reset() likely handles its state)
// Re-init Graph if needed
// Re-create vector store via _autoInitialize (which handles dimension + creation)
this._initError = undefined;
this._initPromise = this._autoInitialize().catch((error) => {
this._initError =
error instanceof Error ? error : new Error(String(error));
console.error(this._initError);
});
await this._initPromise;
// Re-initialize telemetry
this._initializeTelemetry();
}
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
await this._ensureInitialized();
await this._captureEvent("get_all", {
limit: config.limit,
has_user_id: !!config.userId,
+2 -15
View File
@@ -1,18 +1,9 @@
import { z } from "zod";
// Accepts a string directly, or an object with a "fact" or "text" key
// (common malformed shapes from smaller LLMs like llama3.1:8b).
const factItem = z.union([
z.string(),
z.object({ fact: z.string() }).transform((o) => o.fact),
z.object({ text: z.string() }).transform((o) => o.text),
]);
// Define Zod schema for fact retrieval output
export const FactRetrievalSchema = z.object({
facts: z
.array(factItem)
.transform((arr) => arr.filter((s) => s.length > 0))
.array(z.string())
.describe("An array of distinct facts extracted from the conversation."),
});
@@ -278,9 +269,5 @@ export function parseMessages(messages: string[]): string {
}
export function removeCodeBlocks(text: string): string {
// Extract content inside code fences instead of deleting it.
// The old regex /```[^`]*```/g replaced the entire block (including
// its content) with an empty string, so when an LLM returned JSON
// wrapped in ```json ... ``` the actual payload was discarded.
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
return text.replace(/```[^`]*```/g, "");
}
+43 -29
View File
@@ -1,20 +1,16 @@
import Database from "better-sqlite3";
import sqlite3 from "sqlite3";
import { HistoryManager } from "./base";
import { ensureSQLiteDirectory } from "../utils/sqlite";
export class SQLiteManager implements HistoryManager {
private db: Database.Database;
private stmtInsert!: Database.Statement;
private stmtSelect!: Database.Statement;
private db: sqlite3.Database;
constructor(dbPath: string) {
ensureSQLiteDirectory(dbPath);
this.db = new Database(dbPath);
this.init();
this.db = new sqlite3.Database(dbPath);
this.init().catch(console.error);
}
private init(): void {
this.db.exec(`
private async init() {
await this.run(`
CREATE TABLE IF NOT EXISTS memory_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_id TEXT NOT NULL,
@@ -26,14 +22,24 @@ export class SQLiteManager implements HistoryManager {
is_deleted INTEGER DEFAULT 0
)
`);
this.stmtInsert = this.db.prepare(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
this.stmtSelect = this.db.prepare(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async addHistory(
@@ -45,24 +51,32 @@ export class SQLiteManager implements HistoryManager {
updatedAt?: string,
isDeleted: number = 0,
): Promise<void> {
this.stmtInsert.run(
memoryId,
previousValue,
newValue,
action,
createdAt ?? null,
updatedAt ?? null,
isDeleted,
await this.run(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
memoryId,
previousValue,
newValue,
action,
createdAt,
updatedAt,
isDeleted,
],
);
}
async getHistory(memoryId: string): Promise<any[]> {
return this.stmtSelect.all(memoryId) as any[];
return this.all(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
[memoryId],
);
}
async reset(): Promise<void> {
this.db.exec("DROP TABLE IF EXISTS memory_history");
this.init();
await this.run("DROP TABLE IF EXISTS memory_history");
await this.init();
}
close(): void {
@@ -1,377 +0,0 @@
/**
* Tests for the sqlite3 → better-sqlite3 migration.
*
* Covers:
* - SQLiteManager: all HistoryManager interface methods
* - MemoryVectorStore: insert, search, get, update, delete, list, userId mgmt
* - File-based persistence and in-memory mode
* - Backward compatibility: same schema, same data shapes
*/
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryVectorStore } from "../vector_stores/memory";
import fs from "fs";
import path from "path";
import os from "os";
// ---------------------------------------------------------------------------
// SQLiteManager tests
// ---------------------------------------------------------------------------
describe("SQLiteManager (better-sqlite3)", () => {
let mgr: SQLiteManager;
beforeEach(() => {
mgr = new SQLiteManager(":memory:");
});
afterEach(() => {
mgr.close();
});
it("creates an in-memory database without errors", () => {
expect(mgr).toBeDefined();
});
it("addHistory inserts a row that getHistory returns", async () => {
await mgr.addHistory(
"mem-001",
null,
"User likes TypeScript",
"ADD",
"2026-01-01T00:00:00Z",
);
const history = await mgr.getHistory("mem-001");
expect(history).toHaveLength(1);
expect(history[0].memory_id).toBe("mem-001");
expect(history[0].new_value).toBe("User likes TypeScript");
expect(history[0].action).toBe("ADD");
expect(history[0].previous_value).toBeNull();
expect(history[0].is_deleted).toBe(0);
});
it("returns history in DESC order (most recent first)", async () => {
await mgr.addHistory("mem-002", null, "First", "ADD", "2026-01-01");
await mgr.addHistory(
"mem-002",
"First",
"Updated",
"UPDATE",
"2026-01-01",
"2026-01-02",
);
await mgr.addHistory(
"mem-002",
"Updated",
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-002");
expect(history).toHaveLength(3);
expect(history[0].action).toBe("DELETE");
expect(history[1].action).toBe("UPDATE");
expect(history[2].action).toBe("ADD");
expect(history[0].is_deleted).toBe(1);
});
it("isolates history by memory_id", async () => {
await mgr.addHistory("mem-A", null, "Fact A", "ADD", "2026-01-01");
await mgr.addHistory("mem-B", null, "Fact B", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-A")).toHaveLength(1);
expect(await mgr.getHistory("mem-B")).toHaveLength(1);
expect((await mgr.getHistory("mem-A"))[0].new_value).toBe("Fact A");
expect((await mgr.getHistory("mem-B"))[0].new_value).toBe("Fact B");
});
it("handles NULL/undefined optional fields correctly", async () => {
await mgr.addHistory(
"mem-null",
null,
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-null");
expect(history).toHaveLength(1);
expect(history[0].previous_value).toBeNull();
expect(history[0].new_value).toBeNull();
expect(history[0].created_at).toBeNull();
expect(history[0].updated_at).toBeNull();
});
it("reset() clears all history and allows re-insertion", async () => {
await mgr.addHistory("mem-003", null, "Fact", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-003")).toHaveLength(1);
await mgr.reset();
expect(await mgr.getHistory("mem-003")).toHaveLength(0);
await mgr.addHistory("mem-004", null, "New fact", "ADD", "2026-02-01");
expect(await mgr.getHistory("mem-004")).toHaveLength(1);
});
it("works with file-based database and persists data", async () => {
const dbPath = path.join(os.tmpdir(), `mem0-test-history-${Date.now()}.db`);
try {
const fmgr = new SQLiteManager(dbPath);
await fmgr.addHistory(
"mem-file",
null,
"Persistent",
"ADD",
"2026-01-01",
);
fmgr.close();
expect(fs.existsSync(dbPath)).toBe(true);
expect(fs.statSync(dbPath).size).toBeGreaterThan(0);
// Reopen and verify
const fmgr2 = new SQLiteManager(dbPath);
const history = await fmgr2.getHistory("mem-file");
expect(history).toHaveLength(1);
expect(history[0].new_value).toBe("Persistent");
fmgr2.close();
} finally {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
}
});
it("handles many rapid insertions", async () => {
for (let i = 0; i < 100; i++) {
await mgr.addHistory(
`mem-rapid-${i}`,
null,
`Fact ${i}`,
"ADD",
new Date().toISOString(),
);
}
for (let i = 0; i < 100; i++) {
const h = await mgr.getHistory(`mem-rapid-${i}`);
expect(h).toHaveLength(1);
expect(h[0].new_value).toBe(`Fact ${i}`);
}
});
});
// ---------------------------------------------------------------------------
// MemoryVectorStore tests
// ---------------------------------------------------------------------------
describe("MemoryVectorStore (better-sqlite3)", () => {
const DIM = 4;
let store: MemoryVectorStore;
let dbPath: string;
function normalize(v: number[]): number[] {
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return v.map((x) => x / norm);
}
beforeEach(() => {
dbPath = path.join(os.tmpdir(), `mem0-test-vectors-${Date.now()}.db`);
store = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
});
afterEach(() => {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
});
it("insert + get returns the stored payload", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "hello", userId: "u1" }]);
const result = await store.get("id-1");
expect(result).not.toBeNull();
expect(result!.id).toBe("id-1");
expect(result!.payload.data).toBe("hello");
expect(result!.payload.userId).toBe("u1");
});
it("get returns null for non-existent id", async () => {
const result = await store.get("nope");
expect(result).toBeNull();
});
it("search returns results sorted by cosine similarity", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
const v3 = normalize([1, 1, 0, 0]);
await store.insert(
[v1, v2, v3],
["id-1", "id-2", "id-3"],
[{ data: "exact" }, { data: "orthogonal" }, { data: "close" }],
);
const results = await store.search(v1, 3);
expect(results).toHaveLength(3);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
expect(results[1].id).toBe("id-3");
expect(results[2].id).toBe("id-2");
expect(results[2].score).toBeCloseTo(0, 5);
});
it("search respects limit", async () => {
const vectors = [];
const ids = [];
const payloads = [];
for (let i = 0; i < 10; i++) {
const v = [0, 0, 0, 0];
v[i % DIM] = 1;
vectors.push(normalize(v));
ids.push(`id-${i}`);
payloads.push({ data: `item-${i}` });
}
await store.insert(vectors, ids, payloads);
const results = await store.search(normalize([1, 0, 0, 0]), 3);
expect(results).toHaveLength(3);
});
it("search respects filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v],
["id-1", "id-2"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
],
);
const results = await store.search(v, 10, { userId: "alice" });
expect(results).toHaveLength(1);
expect(results[0].id).toBe("id-1");
});
it("search throws on dimension mismatch", async () => {
await expect(store.search([1, 0, 0], 10)).rejects.toThrow(
"dimension mismatch",
);
});
it("insert throws on dimension mismatch", async () => {
await expect(
store.insert([[1, 0, 0]], ["id-1"], [{ data: "x" }]),
).rejects.toThrow("dimension mismatch");
});
it("update modifies the stored vector and payload", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.update("id-1", v2, { data: "updated" });
const result = await store.get("id-1");
expect(result!.payload.data).toBe("updated");
const results = await store.search(v2, 1);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
});
it("delete removes the vector", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "doomed" }]);
await store.delete("id-1");
expect(await store.get("id-1")).toBeNull();
});
it("deleteCol drops and recreates table", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "will be gone" }]);
await store.deleteCol();
expect(await store.get("id-1")).toBeNull();
await store.insert([v], ["id-2"], [{ data: "fresh" }]);
expect(await store.get("id-2")).not.toBeNull();
});
it("list returns all vectors with optional filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
{ data: "c", userId: "alice" },
],
);
const [all, totalAll] = await store.list();
expect(all).toHaveLength(3);
expect(totalAll).toBe(3);
const [filtered, totalFiltered] = await store.list({ userId: "alice" });
expect(filtered).toHaveLength(2);
expect(totalFiltered).toBe(2);
});
it("list respects limit", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[{ data: "a" }, { data: "b" }, { data: "c" }],
);
const [results] = await store.list(undefined, 2);
expect(results).toHaveLength(2);
});
it("getUserId generates and persists a userId", async () => {
const id1 = await store.getUserId();
expect(typeof id1).toBe("string");
expect(id1.length).toBeGreaterThan(0);
const id2 = await store.getUserId();
expect(id2).toBe(id1);
});
it("setUserId overrides the stored userId", async () => {
await store.setUserId("custom-user-123");
const id = await store.getUserId();
expect(id).toBe("custom-user-123");
});
it("INSERT OR REPLACE upserts on id conflict", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.insert([v2], ["id-1"], [{ data: "replaced" }]);
const result = await store.get("id-1");
expect(result!.payload.data).toBe("replaced");
const [all] = await store.list();
expect(all).toHaveLength(1);
});
it("file-based database persists across reopens", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-persist"], [{ data: "persistent" }]);
const store2 = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
const result = await store2.get("id-persist");
expect(result).not.toBeNull();
expect(result!.payload.data).toBe("persistent");
});
});
@@ -1,396 +0,0 @@
/**
* Backward-compatibility tests for SQLite path handling changes.
*
* These tests verify that every documented and common usage pattern
* from before the fix continues to work identically after the change.
*/
import fs from "fs";
import os from "os";
import path from "path";
import { ConfigManager } from "../config/manager";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryVectorStore } from "../vector_stores/memory";
import {
ensureSQLiteDirectory,
getDefaultVectorStoreDbPath,
} from "../utils/sqlite";
function normalize(vector: number[]): number[] {
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
return vector.map((value) => value / norm);
}
// ---------------------------------------------------------------------------
// 1. Config merging – existing patterns must keep working
// ---------------------------------------------------------------------------
describe("backward compat: ConfigManager.mergeConfig", () => {
it("empty config returns all expected defaults", () => {
const cfg = ConfigManager.mergeConfig({});
expect(cfg.version).toBe("v1.1");
expect(cfg.embedder.provider).toBe("openai");
expect(cfg.vectorStore.provider).toBe("memory");
expect(cfg.vectorStore.config.collectionName).toBe("memories");
expect(cfg.vectorStore.config.dimension).toBeUndefined();
expect(cfg.llm.provider).toBe("openai");
expect(cfg.historyStore).toBeDefined();
expect(cfg.historyStore!.provider).toBe("sqlite");
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
expect(cfg.disableHistory).toBe(false);
expect(cfg.enableGraph).toBe(false);
});
it("workaround: explicit historyStore still works (existing user pattern)", () => {
// This is the documented workaround from all three issues
const cfg = ConfigManager.mergeConfig({
historyStore: {
provider: "sqlite",
config: { historyDbPath: "/tmp/workaround.db" },
},
});
expect(cfg.historyStore!.provider).toBe("sqlite");
expect(cfg.historyStore!.config.historyDbPath).toBe("/tmp/workaround.db");
});
it("disableHistory: true still works", () => {
const cfg = ConfigManager.mergeConfig({ disableHistory: true });
expect(cfg.disableHistory).toBe(true);
});
it("supabase historyStore config is preserved", () => {
const cfg = ConfigManager.mergeConfig({
historyStore: {
provider: "supabase",
config: {
supabaseUrl: "https://abc.supabase.co",
supabaseKey: "secret-key",
tableName: "custom_history",
},
},
});
expect(cfg.historyStore!.provider).toBe("supabase");
expect(cfg.historyStore!.config.supabaseUrl).toBe(
"https://abc.supabase.co",
);
expect(cfg.historyStore!.config.supabaseKey).toBe("secret-key");
expect(cfg.historyStore!.config.tableName).toBe("custom_history");
});
it("custom embedder, llm, vectorStore configs pass through unchanged", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", url: "http://localhost:11434" },
},
llm: {
provider: "ollama",
config: { model: "llama3.1:8b" },
},
vectorStore: {
provider: "qdrant",
config: {
collectionName: "test",
dimension: 768,
},
},
});
expect(cfg.embedder.provider).toBe("ollama");
expect(cfg.embedder.config.model).toBe("nomic-embed-text");
expect(cfg.llm.provider).toBe("ollama");
expect(cfg.llm.config.model).toBe("llama3.1:8b");
expect(cfg.vectorStore.provider).toBe("qdrant");
expect(cfg.vectorStore.config.collectionName).toBe("test");
expect(cfg.vectorStore.config.dimension).toBe(768);
});
it("graphStore config passes through unchanged", () => {
const cfg = ConfigManager.mergeConfig({
enableGraph: true,
graphStore: {
provider: "neo4j",
config: {
url: "neo4j://custom:7687",
username: "admin",
password: "pass",
},
},
});
expect(cfg.enableGraph).toBe(true);
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
});
it("customPrompt passes through unchanged", () => {
const cfg = ConfigManager.mergeConfig({
customPrompt: "You are a helpful assistant",
});
expect(cfg.customPrompt).toBe("You are a helpful assistant");
});
it("version override passes through unchanged", () => {
const cfg = ConfigManager.mergeConfig({ version: "v1.0" });
expect(cfg.version).toBe("v1.0");
});
});
// ---------------------------------------------------------------------------
// 2. SQLiteManager – existing behavior preserved
// ---------------------------------------------------------------------------
describe("backward compat: SQLiteManager", () => {
it("relative path still works (resolves from CWD)", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
const manager = new SQLiteManager("memory.db");
await manager.addHistory("m1", null, "value", "ADD");
const history = await manager.getHistory("m1");
expect(history).toHaveLength(1);
expect(fs.existsSync(path.join(tempDir, "memory.db"))).toBe(true);
manager.close();
} finally {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("absolute path still works", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
const dbPath = path.join(tempDir, "history.db");
try {
const manager = new SQLiteManager(dbPath);
await manager.addHistory("m1", null, "value", "ADD");
expect(fs.existsSync(dbPath)).toBe(true);
manager.close();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it(":memory: still works", async () => {
const manager = new SQLiteManager(":memory:");
await manager.addHistory("m1", null, "value", "ADD");
const history = await manager.getHistory("m1");
expect(history).toHaveLength(1);
manager.close();
});
it("reset clears history and allows re-use", async () => {
const manager = new SQLiteManager(":memory:");
await manager.addHistory("m1", null, "val", "ADD");
await manager.reset();
const history = await manager.getHistory("m1");
expect(history).toHaveLength(0);
await manager.addHistory("m2", null, "new-val", "ADD");
const history2 = await manager.getHistory("m2");
expect(history2).toHaveLength(1);
manager.close();
});
});
// ---------------------------------------------------------------------------
// 3. MemoryVectorStore – existing API preserved
// ---------------------------------------------------------------------------
describe("backward compat: MemoryVectorStore", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
jest.restoreAllMocks();
});
it("explicit dbPath still works (the existing config.dbPath feature)", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
const dbPath = path.join(tempDir, "my_vectors.db");
try {
const store = new MemoryVectorStore({ dimension: 3, dbPath });
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]);
expect(fs.existsSync(dbPath)).toBe(true);
const result = await store.get("id1");
expect(result).not.toBeNull();
expect(result!.payload.text).toBe("hello");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("insert, search, get, update, delete, list all work", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
const dbPath = path.join(tempDir, "test.db");
try {
const store = new MemoryVectorStore({ dimension: 3, dbPath });
const v1 = normalize([1, 0, 0]);
const v2 = normalize([0, 1, 0]);
// insert
await store.insert([v1, v2], ["a", "b"], [{ t: "a" }, { t: "b" }]);
// get
const a = await store.get("a");
expect(a!.payload.t).toBe("a");
// search
const results = await store.search(v1, 2);
expect(results).toHaveLength(2);
expect(results[0].id).toBe("a"); // closest to v1
// update
await store.update("a", v2, { t: "updated" });
const updated = await store.get("a");
expect(updated!.payload.t).toBe("updated");
// list
const [listed, count] = await store.list();
expect(count).toBe(2);
expect(listed).toHaveLength(2);
// delete
await store.delete("a");
const deleted = await store.get("a");
expect(deleted).toBeNull();
// deleteCol
await store.deleteCol();
const [afterDrop] = await store.list();
expect(afterDrop).toHaveLength(0);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("dimension mismatch on insert still throws", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
const dbPath = path.join(tempDir, "test.db");
try {
const store = new MemoryVectorStore({ dimension: 3, dbPath });
await expect(
store.insert([[1, 0]], ["id1"], [{ t: "x" }]),
).rejects.toThrow("Vector dimension mismatch");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("dimension mismatch on search still throws", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
const dbPath = path.join(tempDir, "test.db");
try {
const store = new MemoryVectorStore({ dimension: 3, dbPath });
await expect(store.search([1, 0], 1)).rejects.toThrow(
"Query dimension mismatch",
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("default dimension is 1536 when not specified", () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
try {
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
const store = new MemoryVectorStore({});
// Verify by trying to insert a 1536-dim vector
const vec = new Array(1536).fill(0);
vec[0] = 1;
expect(store.insert([vec], ["id1"], [{ t: "x" }])).resolves.not.toThrow();
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it("search with filters still works", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
const dbPath = path.join(tempDir, "test.db");
try {
const store = new MemoryVectorStore({ dimension: 3, dbPath });
await store.insert(
[normalize([1, 0, 0]), normalize([0, 1, 0])],
["a", "b"],
[
{ text: "hello", userId: "user1" },
{ text: "world", userId: "user2" },
],
);
const results = await store.search(normalize([1, 0, 0]), 10, {
userId: "user2",
});
expect(results).toHaveLength(1);
expect(results[0].id).toBe("b");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// 4. VectorStoreConfig type – dbPath is optional, existing configs work
// ---------------------------------------------------------------------------
describe("backward compat: VectorStoreConfig type", () => {
it("config without dbPath still works (no required field breakage)", () => {
const cfg = ConfigManager.mergeConfig({
vectorStore: {
provider: "memory",
config: { collectionName: "test", dimension: 512 },
},
});
expect(cfg.vectorStore.config.dbPath).toBeUndefined();
expect(cfg.vectorStore.config.collectionName).toBe("test");
expect(cfg.vectorStore.config.dimension).toBe(512);
});
it("config with client instance passes through unchanged", () => {
const fakeClient = { connect: () => {} };
const cfg = ConfigManager.mergeConfig({
vectorStore: {
provider: "qdrant",
config: { client: fakeClient, dimension: 768 },
},
});
expect(cfg.vectorStore.config.client).toBe(fakeClient);
expect(cfg.vectorStore.config.dimension).toBe(768);
});
});
// ---------------------------------------------------------------------------
// 5. ensureSQLiteDirectory – does not break existing paths
// ---------------------------------------------------------------------------
describe("backward compat: ensureSQLiteDirectory", () => {
it("no-ops for already existing directory", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-existing-"));
try {
// Should not throw even though directory already exists
expect(() =>
ensureSQLiteDirectory(path.join(tempDir, "test.db")),
).not.toThrow();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("handles path with trailing slash gracefully", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-trailing-"));
try {
// path.dirname of "dir/sub/" is "dir/sub", mkdirSync should handle it
expect(() =>
ensureSQLiteDirectory(path.join(tempDir, "sub", "test.db")),
).not.toThrow();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
@@ -1,290 +0,0 @@
import fs from "fs";
import os from "os";
import path from "path";
import { ConfigManager } from "../config/manager";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryVectorStore } from "../vector_stores/memory";
import {
ensureSQLiteDirectory,
getDefaultVectorStoreDbPath,
} from "../utils/sqlite";
function normalize(vector: number[]): number[] {
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
return vector.map((value) => value / norm);
}
// ---------------------------------------------------------------------------
// Config merging – historyDbPath
// ---------------------------------------------------------------------------
describe("ConfigManager.mergeConfig – historyDbPath handling", () => {
it("propagates top-level historyDbPath into historyStore.config", () => {
const cfg = ConfigManager.mergeConfig({
historyDbPath: "/tmp/custom/history.db",
});
expect(cfg.historyDbPath).toBe("/tmp/custom/history.db");
expect(cfg.historyStore?.provider).toBe("sqlite");
expect(cfg.historyStore?.config.historyDbPath).toBe(
"/tmp/custom/history.db",
);
});
it("explicit historyStore.config.historyDbPath takes precedence over top-level", () => {
const cfg = ConfigManager.mergeConfig({
historyDbPath: "/tmp/shorthand.db",
historyStore: {
provider: "sqlite",
config: { historyDbPath: "/tmp/explicit.db" },
},
});
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/explicit.db");
});
it("preserves default memory.db when nothing is provided", () => {
const cfg = ConfigManager.mergeConfig({});
expect(cfg.historyStore?.provider).toBe("sqlite");
expect(cfg.historyStore?.config.historyDbPath).toBe("memory.db");
});
it("respects only historyStore.config when top-level is absent", () => {
const cfg = ConfigManager.mergeConfig({
historyStore: {
provider: "sqlite",
config: { historyDbPath: "/tmp/nested-only.db" },
},
});
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/nested-only.db");
});
it("does not leak historyDbPath into non-sqlite providers", () => {
const cfg = ConfigManager.mergeConfig({
historyDbPath: "/tmp/should-not-apply.db",
historyStore: {
provider: "supabase",
config: {
supabaseUrl: "https://x.supabase.co",
supabaseKey: "key",
},
},
});
expect(cfg.historyStore?.provider).toBe("supabase");
expect(cfg.historyStore?.config.historyDbPath).toBeUndefined();
});
it("disableHistory does not prevent historyStore config from merging", () => {
const cfg = ConfigManager.mergeConfig({
disableHistory: true,
historyDbPath: "/tmp/disabled.db",
});
expect(cfg.disableHistory).toBe(true);
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/disabled.db");
});
});
// ---------------------------------------------------------------------------
// SQLiteManager – directory creation & DB operations
// ---------------------------------------------------------------------------
describe("SQLiteManager – directory auto-creation", () => {
it("creates nested parent directories and writes to the DB", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-sqlite-"));
const dbPath = path.join(tempDir, "a", "b", "c", "history.db");
let manager: SQLiteManager | undefined;
try {
manager = new SQLiteManager(dbPath);
await manager.addHistory("mem-1", null, "test value", "ADD");
const history = await manager.getHistory("mem-1");
expect(fs.existsSync(dbPath)).toBe(true);
expect(history).toHaveLength(1);
expect(history[0].new_value).toBe("test value");
} finally {
manager?.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("end-to-end: mergeConfig + SQLiteManager at configured path", async () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "mem0-history-path-"),
);
const historyDbPath = path.join(tempDir, "nested", "history.db");
let manager: SQLiteManager | undefined;
try {
const mergedConfig = ConfigManager.mergeConfig({ historyDbPath });
manager = new SQLiteManager(
mergedConfig.historyStore!.config.historyDbPath!,
);
await manager.addHistory("memory-1", null, "remember me", "ADD");
expect(fs.existsSync(historyDbPath)).toBe(true);
} finally {
manager?.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("works with :memory: without attempting directory creation", () => {
const manager = new SQLiteManager(":memory:");
expect(manager).toBeDefined();
manager.close();
});
});
// ---------------------------------------------------------------------------
// MemoryVectorStore – path handling
// ---------------------------------------------------------------------------
describe("MemoryVectorStore – path handling", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
jest.restoreAllMocks();
});
it("uses ~/.mem0/vector_store.db by default", () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
try {
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
new MemoryVectorStore({ dimension: 4 });
expect(
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
).toBe(true);
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it("respects explicit dbPath config", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
const dbPath = path.join(tempDir, "custom", "vectors.db");
try {
const store = new MemoryVectorStore({ dimension: 4, dbPath });
await store.insert(
[normalize([1, 0, 0, 0])],
["v1"],
[{ text: "hello" }],
);
expect(fs.existsSync(dbPath)).toBe(true);
const results = await store.search(normalize([1, 0, 0, 0]), 1);
expect(results).toHaveLength(1);
expect(results[0].payload.text).toBe("hello");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("works when CWD is read-only", async () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
const readOnlyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ro-"));
try {
fs.chmodSync(readOnlyCwd, 0o555);
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
process.chdir(readOnlyCwd);
const store = new MemoryVectorStore({ dimension: 4 });
await store.insert(
[normalize([0, 1, 0, 0])],
["v2"],
[{ text: "works" }],
);
expect(
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
).toBe(true);
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe(
false,
);
} finally {
fs.chmodSync(readOnlyCwd, 0o755);
fs.rmSync(fakeHome, { recursive: true, force: true });
fs.rmSync(readOnlyCwd, { recursive: true, force: true });
}
});
it("emits migration warning when old CWD-based vector_store.db exists", () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
try {
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
process.chdir(tempCwd);
new MemoryVectorStore({ dimension: 4 });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Default vector_store.db location changed"),
);
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
fs.rmSync(tempCwd, { recursive: true, force: true });
}
});
it("does NOT emit migration warning when dbPath is explicitly set", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
try {
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
process.chdir(tempCwd);
new MemoryVectorStore({
dimension: 4,
dbPath: path.join(tempDir, "explicit.db"),
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(tempCwd, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// Utils
// ---------------------------------------------------------------------------
describe("ensureSQLiteDirectory", () => {
it("creates nested directories", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ensure-"));
const target = path.join(tempDir, "x", "y", "z", "test.db");
try {
ensureSQLiteDirectory(target);
expect(fs.existsSync(path.join(tempDir, "x", "y", "z"))).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("skips :memory:", () => {
expect(() => ensureSQLiteDirectory(":memory:")).not.toThrow();
});
it("skips file: URIs", () => {
expect(() => ensureSQLiteDirectory("file::memory:")).not.toThrow();
});
it("skips empty string", () => {
expect(() => ensureSQLiteDirectory("")).not.toThrow();
});
});
describe("getDefaultVectorStoreDbPath", () => {
it("returns path under homedir/.mem0", () => {
const result = getDefaultVectorStoreDbPath();
expect(result).toBe(path.join(os.homedir(), ".mem0", "vector_store.db"));
});
});
-5
View File
@@ -15,7 +15,6 @@ export interface Message {
export interface EmbeddingConfig {
apiKey?: string;
model?: string | any;
baseURL?: string;
url?: string;
embeddingDims?: number;
modelProperties?: Record<string, any>;
@@ -24,7 +23,6 @@ export interface EmbeddingConfig {
export interface VectorStoreConfig {
collectionName?: string;
dimension?: number;
dbPath?: string;
client?: any;
instance?: any;
[key: string]: any;
@@ -43,7 +41,6 @@ export interface HistoryStoreConfig {
export interface LLMConfig {
provider?: string;
baseURL?: string;
url?: string;
config?: Record<string, any>;
apiKey?: string;
model?: string | any;
@@ -132,7 +129,6 @@ export const MemoryConfigSchema = z.object({
.object({
collectionName: z.string().optional(),
dimension: z.number().optional(),
dbPath: z.string().optional(),
client: z.any().optional(),
})
.passthrough(),
@@ -144,7 +140,6 @@ export const MemoryConfigSchema = z.object({
model: z.union([z.string(), z.any()]).optional(),
modelProperties: z.record(z.string(), z.any()).optional(),
baseURL: z.string().optional(),
url: z.string().optional(),
}),
}),
historyDbPath: z.string().optional(),
-6
View File
@@ -1,6 +1,5 @@
import { OpenAIEmbedder } from "../embeddings/openai";
import { OllamaEmbedder } from "../embeddings/ollama";
import { LMStudioEmbedder } from "../embeddings/lmstudio";
import { OpenAILLM } from "../llms/openai";
import { OpenAIStructuredLLM } from "../llms/openai_structured";
import { AnthropicLLM } from "../llms/anthropic";
@@ -20,7 +19,6 @@ import { Qdrant } from "../vector_stores/qdrant";
import { VectorizeDB } from "../vector_stores/vectorize";
import { RedisDB } from "../vector_stores/redis";
import { OllamaLLM } from "../llms/ollama";
import { LMStudioLLM } from "../llms/lmstudio";
import { SupabaseDB } from "../vector_stores/supabase";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
@@ -42,8 +40,6 @@ export class EmbedderFactory {
return new OpenAIEmbedder(config);
case "ollama":
return new OllamaEmbedder(config);
case "lmstudio":
return new LMStudioEmbedder(config);
case "google":
case "gemini":
return new GoogleEmbedder(config);
@@ -70,8 +66,6 @@ export class LLMFactory {
return new GroqLLM(config);
case "ollama":
return new OllamaLLM(config);
case "lmstudio":
return new LMStudioLLM(config);
case "google":
case "gemini":
return new GoogleLLM(config);
-15
View File
@@ -1,15 +0,0 @@
import fs from "fs";
import os from "os";
import path from "path";
export function getDefaultVectorStoreDbPath(): string {
return path.join(os.homedir(), ".mem0", "vector_store.db");
}
export function ensureSQLiteDirectory(dbPath: string): void {
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file:")) {
return;
}
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
@@ -81,7 +81,6 @@ export class AzureAISearch implements VectorStore {
private readonly hybridSearch: boolean;
private readonly vectorFilterMode: string;
private readonly apiKey: string | undefined;
private _initPromise?: Promise<void>;
constructor(config: AzureAISearchConfig) {
this.serviceName = config.serviceName;
@@ -118,13 +117,6 @@ export class AzureAISearch implements VectorStore {
* Initialize the Azure AI Search index if it doesn't exist
*/
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
try {
const collections = await this.listCols();
if (!collections.includes(this.indexName)) {
+75 -75
View File
@@ -1,12 +1,7 @@
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import Database from "better-sqlite3";
import fs from "fs";
import sqlite3 from "sqlite3";
import path from "path";
import {
ensureSQLiteDirectory,
getDefaultVectorStoreDbPath,
} from "../utils/sqlite";
interface MemoryVector {
id: string;
@@ -15,31 +10,22 @@ interface MemoryVector {
}
export class MemoryVectorStore implements VectorStore {
private db: Database.Database;
private db: sqlite3.Database;
private dimension: number;
private dbPath: string;
constructor(config: VectorStoreConfig) {
this.dimension = config.dimension || 1536; // Default OpenAI dimension
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
if (!config.dbPath) {
const oldDefault = path.join(process.cwd(), "vector_store.db");
if (fs.existsSync(oldDefault) && oldDefault !== this.dbPath) {
console.warn(
`[mem0] Default vector_store.db location changed from ${oldDefault} to ${this.dbPath}. ` +
`Move your existing file or set vectorStore.config.dbPath explicitly.`,
);
}
this.dbPath = path.join(process.cwd(), "vector_store.db");
if (config.dbPath) {
this.dbPath = config.dbPath;
}
ensureSQLiteDirectory(this.dbPath);
this.db = new Database(this.dbPath);
this.init();
this.db = new sqlite3.Database(this.dbPath);
this.init().catch(console.error);
}
private init(): void {
this.db.exec(`
private async init() {
await this.run(`
CREATE TABLE IF NOT EXISTS vectors (
id TEXT PRIMARY KEY,
vector BLOB NOT NULL,
@@ -47,7 +33,7 @@ export class MemoryVectorStore implements VectorStore {
)
`);
this.db.exec(`
await this.run(`
CREATE TABLE IF NOT EXISTS memory_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE
@@ -55,6 +41,33 @@ export class MemoryVectorStore implements VectorStore {
`);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
private async getOne(sql: string, params: any[] = []): Promise<any> {
return new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
private cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
@@ -79,23 +92,18 @@ export class MemoryVectorStore implements VectorStore {
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
const stmt = this.db.prepare(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
);
const insertMany = this.db.transaction(
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
for (let i = 0; i < vecs.length; i++) {
if (vecs[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vecs[i]).buffer);
stmt.run(vIds[i], vectorBuffer, JSON.stringify(vPayloads[i]));
}
},
);
insertMany(vectors, ids, payloads);
for (let i = 0; i < vectors.length; i++) {
if (vectors[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
await this.run(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
[ids[i], vectorBuffer, JSON.stringify(payloads[i])],
);
}
}
async search(
@@ -109,15 +117,11 @@ export class MemoryVectorStore implements VectorStore {
);
}
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const rows = await this.all(`SELECT * FROM vectors`);
const results: VectorStoreResult[] = [];
for (const row of rows) {
const vector = new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
);
const vector = new Float32Array(row.vector.buffer);
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
@@ -140,9 +144,9 @@ export class MemoryVectorStore implements VectorStore {
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
const row = this.db
.prepare(`SELECT * FROM vectors WHERE id = ?`)
.get(vectorId) as any;
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
vectorId,
]);
if (!row) return null;
const payload = JSON.parse(row.payload);
@@ -163,38 +167,34 @@ export class MemoryVectorStore implements VectorStore {
);
}
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
this.db
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
.run(vectorBuffer, JSON.stringify(payload), vectorId);
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
vectorBuffer,
JSON.stringify(payload),
vectorId,
]);
}
async delete(vectorId: string): Promise<void> {
this.db.prepare(`DELETE FROM vectors WHERE id = ?`).run(vectorId);
await this.run(`DELETE FROM vectors WHERE id = ?`, [vectorId]);
}
async deleteCol(): Promise<void> {
this.db.exec(`DROP TABLE IF EXISTS vectors`);
this.init();
await this.run(`DROP TABLE IF EXISTS vectors`);
await this.init();
}
async list(
filters?: SearchFilters,
limit: number = 100,
): Promise<[VectorStoreResult[], number]> {
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const rows = await this.all(`SELECT * FROM vectors`);
const results: VectorStoreResult[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(
new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
),
),
vector: Array.from(new Float32Array(row.vector.buffer)),
payload,
};
@@ -210,9 +210,9 @@ export class MemoryVectorStore implements VectorStore {
}
async getUserId(): Promise<string> {
const row = this.db
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
.get() as any;
const row = await this.getOne(
`SELECT user_id FROM memory_migrations LIMIT 1`,
);
if (row) {
return row.user_id;
}
@@ -221,20 +221,20 @@ export class MemoryVectorStore implements VectorStore {
const randomUserId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(randomUserId);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
randomUserId,
]);
return randomUserId;
}
async setUserId(userId: string): Promise<void> {
this.db.prepare(`DELETE FROM memory_migrations`).run();
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(userId);
await this.run(`DELETE FROM memory_migrations`);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
userId,
]);
}
async initialize(): Promise<void> {
this.init();
await this.init();
}
}
+65 -48
View File
@@ -32,7 +32,6 @@ export class Qdrant implements VectorStore {
private client: QdrantClient;
private readonly collectionName: string;
private dimension: number;
private _initPromise?: Promise<void>;
constructor(config: QdrantConfig) {
if (config.client) {
@@ -212,8 +211,22 @@ export class Qdrant implements VectorStore {
async getUserId(): Promise<string> {
try {
// Ensure collection exists (idempotent — handles race conditions)
await this.ensureCollection("memory_migrations", 1);
// First check if the collection exists
const collections = await this.client.getCollections();
const userCollectionExists = collections.collections.some(
(col: { name: string }) => col.name === "memory_migrations",
);
if (!userCollectionExists) {
// Create the collection if it doesn't exist
await this.client.createCollection("memory_migrations", {
vectors: {
size: 1,
distance: "Cosine",
on_disk: false,
},
});
}
// Now try to get the user ID
const result = await this.client.scroll("memory_migrations", {
@@ -273,62 +286,66 @@ export class Qdrant implements VectorStore {
}
}
private async ensureCollection(name: string, size: number): Promise<void> {
async initialize(): Promise<void> {
try {
await this.client.createCollection(name, {
vectors: {
size,
distance: "Cosine",
},
});
} catch (error: any) {
if (
error?.status === 409 ||
error?.status === 401 ||
error?.status === 403
) {
// Collection already exists — verify configuration for the main collection
if (name === this.collectionName) {
try {
const collectionInfo = await this.client.getCollection(name);
// Create collection if it doesn't exist
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(c) => c.name === this.collectionName,
);
if (!exists) {
try {
await this.client.createCollection(this.collectionName, {
vectors: {
size: this.dimension,
distance: "Cosine",
},
});
} catch (error: any) {
// Handle case where collection was created between our check and create
if (error?.status === 409) {
// Collection already exists - verify it has the correct configuration
const collectionInfo = await this.client.getCollection(
this.collectionName,
);
const vectorConfig = collectionInfo.config?.params?.vectors;
if (vectorConfig && vectorConfig.size !== size) {
if (!vectorConfig || vectorConfig.size !== this.dimension) {
throw new Error(
`Collection ${name} exists but has wrong vector size. ` +
`Expected: ${size}, got: ${vectorConfig.size}`,
`Collection ${this.collectionName} exists but has wrong configuration. ` +
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
);
}
} catch (verifyError: any) {
// Re-throw dimension mismatch errors
if (verifyError?.message?.includes("wrong vector size")) {
throw verifyError;
}
// Transient errors (e.g. 500 while collection is being committed)
// are non-fatal — the collection exists per the 409.
console.warn(
`Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`,
);
// Collection exists with correct configuration - we can proceed
} else {
throw error;
}
}
// Otherwise collection exists and is fine — proceed
} else {
throw error;
}
}
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
// Create memory_migrations collection if it doesn't exist
const userExists = collections.collections.some(
(c) => c.name === "memory_migrations",
);
private async _doInitialize(): Promise<void> {
try {
await this.ensureCollection(this.collectionName, this.dimension);
await this.ensureCollection("memory_migrations", 1);
if (!userExists) {
try {
await this.client.createCollection("memory_migrations", {
vectors: {
size: 1, // Minimal size since we only store user_id
distance: "Cosine",
},
});
} catch (error: any) {
// Handle case where collection was created between our check and create
if (error?.status === 409) {
// Collection already exists - we can proceed
} else {
throw error;
}
}
}
} catch (error) {
console.error("Error initializing Qdrant:", error);
throw error;
@@ -139,7 +139,6 @@ export class RedisDB implements VectorStore {
private readonly indexName: string;
private readonly indexPrefix: string;
private readonly schema: RedisSchema;
private _initPromise?: Promise<void>;
constructor(config: RedisConfig) {
this.indexName = config.collectionName;
@@ -241,13 +240,6 @@ export class RedisDB implements VectorStore {
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
try {
await this.client.connect();
console.log("Connected to Redis");
@@ -86,7 +86,6 @@ export class SupabaseDB implements VectorStore {
private readonly tableName: string;
private readonly embeddingColumnName: string;
private readonly metadataColumnName: string;
private _initPromise?: Promise<void>;
constructor(config: SupabaseConfig) {
this.client = createClient(config.supabaseUrl, config.supabaseKey);
@@ -101,13 +100,6 @@ export class SupabaseDB implements VectorStore {
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
try {
// Verify table exists and vector operations work by attempting a test insert
const testVector = Array(1536).fill(0);
@@ -20,7 +20,6 @@ export class VectorizeDB implements VectorStore {
private dimensions: number;
private indexName: string;
private accountId: string;
private _initPromise?: Promise<void>;
constructor(config: VectorizeConfig) {
this.client = new Cloudflare({ apiToken: config.apiKey });
@@ -344,13 +343,6 @@ export class VectorizeDB implements VectorStore {
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
try {
// Check if the index already exists
let indexFound = false;
@@ -1,597 +0,0 @@
/// <reference types="jest" />
import { ConfigManager } from "../src/config/manager";
describe("ConfigManager", () => {
describe("mergeConfig - dimension handling", () => {
const baseLlm = {
provider: "openai",
config: { apiKey: "test-key" },
};
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
const config = ConfigManager.mergeConfig({
embedder: { provider: "openai", config: { apiKey: "test-key" } },
vectorStore: { provider: "memory", config: { collectionName: "test" } },
llm: baseLlm,
});
// Dimension should be undefined so Memory._autoInitialize() will
// auto-detect it via a probe embedding at runtime.
expect(config.vectorStore.config.dimension).toBeUndefined();
});
it("should use embeddingDims from embedder config when provided", () => {
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: baseLlm,
});
expect(config.vectorStore.config.dimension).toBe(768);
});
it("should prefer explicit vector store dimension over embedder dims", () => {
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: { collectionName: "test", dimension: 1024 },
},
llm: baseLlm,
});
expect(config.vectorStore.config.dimension).toBe(1024);
});
it("should leave dimension undefined when using a custom client without explicit dims", () => {
const mockClient = { someMethod: () => {} };
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text" },
},
vectorStore: {
provider: "qdrant",
config: { collectionName: "test", client: mockClient },
},
llm: baseLlm,
});
// No embeddingDims and no explicit dimension → should be undefined
// for auto-detection at runtime.
expect(config.vectorStore.config.dimension).toBeUndefined();
});
it("should use embeddingDims when using a custom client", () => {
const mockClient = { someMethod: () => {} };
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: { collectionName: "test", client: mockClient },
},
llm: baseLlm,
});
expect(config.vectorStore.config.dimension).toBe(768);
});
});
describe("mergeConfig - LLM url passthrough for Ollama", () => {
const baseEmbedder = {
provider: "openai",
config: { apiKey: "test-key" },
};
const baseVectorStore = {
provider: "memory",
config: { collectionName: "test" },
};
it("should preserve url in LLM config when provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
it("should prefer baseURL over url when both are provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: {
model: "llama3.2:3b",
baseURL: "http://custom:11434",
url: "http://fallback:11434",
},
},
});
expect(config.llm.config.baseURL).toBe("http://custom:11434");
expect(config.llm.config.url).toBe("http://fallback:11434");
});
it("should use default baseURL when no url or baseURL provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b" },
},
});
expect(config.llm.config.url).toBeUndefined();
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
});
it("should preserve url in embedder config (existing behavior)", () => {
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: {
model: "nomic-embed-text",
url: "http://10.0.0.100:11434",
},
},
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
});
// ─────────────────────────────────────────────────────────────────────
// LM Studio snake_case normalization
// ─────────────────────────────────────────────────────────────────────
describe("mergeConfig - LM Studio embedder config", () => {
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
it("normalizes lmstudio_base_url to baseURL for embedder", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
lmstudio_base_url: "http://192.168.1.1:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.provider).toBe("lmstudio");
expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1");
expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5");
});
it("normalizes embedding_dims to embeddingDims for embedder", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
embedding_dims: 768,
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.embeddingDims).toBe(768);
});
it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "test",
baseURL: "http://camel:1234/v1",
lmstudio_base_url: "http://snake:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1");
});
it("prefers camelCase embeddingDims over snake_case embedding_dims", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "test",
embeddingDims: 1536,
embedding_dims: 768,
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.embeddingDims).toBe(1536);
});
it("passes through camelCase config without issues", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
embeddingDims: 768,
},
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
expect(cfg.embedder.config.embeddingDims).toBe(768);
});
});
describe("mergeConfig - LM Studio LLM config", () => {
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
it("normalizes lmstudio_base_url to baseURL for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: {
provider: "lmstudio",
config: {
model: "meta-llama-3.1",
lmstudio_base_url: "http://192.168.1.1:1234/v1",
} as any,
},
});
expect(cfg.llm.provider).toBe("lmstudio");
expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1");
expect(cfg.llm.config.model).toBe("meta-llama-3.1");
});
it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: {
provider: "lmstudio",
config: {
baseURL: "http://camel:1234/v1",
lmstudio_base_url: "http://snake:1234/v1",
} as any,
},
});
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
});
it("falls back to default baseURL when neither is provided for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider: "lmstudio", config: { model: "test-model" } },
});
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
});
});
describe("mergeConfig - full OpenClaw-style LM Studio config", () => {
it("handles the exact config from issue #4235", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "text-embedding-gte-qwen2-1.5b-instruct",
embedding_dims: 1536,
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
vectorStore: {
provider: "qdrant",
config: {
host: "192.168.200.12",
port: 6333,
checkCompatibility: false,
},
},
llm: {
provider: "lmstudio",
config: {
model: "openai/gpt-oss-20b",
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
});
expect(cfg.embedder.provider).toBe("lmstudio");
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
expect(cfg.embedder.config.model).toBe(
"text-embedding-gte-qwen2-1.5b-instruct",
);
expect(cfg.embedder.config.embeddingDims).toBe(1536);
expect(cfg.llm.provider).toBe("lmstudio");
expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1");
expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b");
expect(cfg.vectorStore.provider).toBe("qdrant");
expect(cfg.vectorStore.config.host).toBe("192.168.200.12");
expect(cfg.vectorStore.config.port).toBe(6333);
});
});
});
// ─────────────────────────────────────────────────────────────────────────
// Memory class – LM Studio end-to-end flow (mocked factories)
// ─────────────────────────────────────────────────────────────────────────
describe("Memory – LM Studio end-to-end flow", () => {
let MemoryClass: any;
let mockEmbedderFactory: any;
let mockVectorStoreFactory: any;
let mockLlmFactory: any;
let mockHistoryFactory: any;
let mockEmbedder: any;
let mockVStore: any;
let mockLlm: any;
beforeEach(() => {
jest.resetModules();
mockEmbedder = {
embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)),
embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]),
};
mockVStore = {
insert: jest.fn().mockResolvedValue(undefined),
search: jest.fn().mockResolvedValue([]),
get: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
deleteCol: jest.fn().mockResolvedValue(undefined),
list: jest.fn().mockResolvedValue([[], 0]),
getUserId: jest.fn().mockResolvedValue("test-user-id"),
setUserId: jest.fn().mockResolvedValue(undefined),
initialize: jest.fn().mockResolvedValue(undefined),
};
mockLlm = {
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
};
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) };
mockHistoryFactory = {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
};
jest.doMock("../src/utils/factory", () => ({
EmbedderFactory: mockEmbedderFactory,
VectorStoreFactory: mockVectorStoreFactory,
LLMFactory: mockLlmFactory,
HistoryManagerFactory: mockHistoryFactory,
}));
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
MemoryClass = require("../src/memory").Memory;
});
afterEach(() => {
jest.restoreAllMocks();
jest.resetModules();
});
it("creates Memory with lmstudio embedder and llm providers", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
},
},
vectorStore: { provider: "memory", config: { collectionName: "test" } },
llm: {
provider: "lmstudio",
config: {
model: "meta-llama-3.1-70b",
baseURL: "http://localhost:1234/v1",
},
},
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
}),
);
expect(mockLlmFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({
model: "meta-llama-3.1-70b",
baseURL: "http://localhost:1234/v1",
}),
);
});
it("auto-detects embedding dimension via probe with lmstudio", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
},
},
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: {
provider: "lmstudio",
config: { baseURL: "http://localhost:1234/v1" },
},
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
expect(vsCall[1].dimension).toBe(768);
});
it("handles snake_case OpenClaw config through full Memory stack", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "text-embedding-gte-qwen2-1.5b-instruct",
embedding_dims: 1536,
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: { collectionName: "test" } },
llm: {
provider: "lmstudio",
config: {
model: "openai/gpt-oss-20b",
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({
model: "text-embedding-gte-qwen2-1.5b-instruct",
baseURL: "http://192.168.200.83:1234/v1",
}),
);
expect(mockLlmFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({
model: "openai/gpt-oss-20b",
baseURL: "http://192.168.200.83:1234/v1",
}),
);
});
it("search flow works with lmstudio embedder", async () => {
mockVStore.search.mockResolvedValueOnce([
{
id: "mem-1",
payload: {
data: "User likes hiking",
user_id: "u1",
hash: "abc123",
created_at: "2026-01-01",
},
score: 0.95,
},
]);
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
embeddingDims: 768,
},
},
vectorStore: {
provider: "memory",
config: { collectionName: "test", dimension: 768 },
},
llm: {
provider: "lmstudio",
config: { baseURL: "http://localhost:1234/v1" },
},
disableHistory: true,
});
const result = await mem.search("What does the user like?", {
userId: "u1",
});
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
expect(mockVStore.search).toHaveBeenCalled();
expect(result.results).toHaveLength(1);
expect(result.results[0].memory).toBe("User likes hiking");
});
it("add flow works with lmstudio LLM for fact extraction", async () => {
mockLlm.generateResponse.mockResolvedValueOnce(
'{"facts":["User loves sushi"]}',
);
mockVStore.search.mockResolvedValue([]);
mockVStore.list.mockResolvedValue([[], 0]);
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
embeddingDims: 768,
},
},
vectorStore: {
provider: "memory",
config: { collectionName: "test", dimension: 768 },
},
llm: {
provider: "lmstudio",
config: {
model: "meta-llama-3.1-70b",
baseURL: "http://localhost:1234/v1",
},
},
disableHistory: true,
});
await mem.add("I love sushi", { userId: "u1" });
expect(mockLlm.generateResponse).toHaveBeenCalled();
expect(mockEmbedder.embed).toHaveBeenCalled();
});
});
@@ -1,519 +0,0 @@
/// <reference types="jest" />
/**
* Tests for embedding dimension auto-detection.
*
* Covers:
* - ConfigManager: dimension resolution logic
* - Memory class: probe-based auto-detection, lazy init gate, backward compat
* - MemoryVectorStore: backward compat with explicit dimensions
* - Explicit error messages on probe failure
*/
import { ConfigManager } from "../src/config/manager";
import { MemoryVectorStore } from "../src/vector_stores/memory";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
jest.setTimeout(15000);
// ───────────────────────────────────────────────────────────────────────────
// 1. ConfigManager – dimension resolution
// ───────────────────────────────────────────────────────────────────────────
describe("ConfigManager – dimension resolution", () => {
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
it("leaves dimension undefined when nothing explicit is set", () => {
const cfg = ConfigManager.mergeConfig({
embedder: { provider: "openai", config: { apiKey: "k" } },
vectorStore: { provider: "memory", config: { collectionName: "t" } },
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBeUndefined();
});
it("uses embeddingDims from embedder config", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBe(768);
});
it("prefers explicit vectorStore.dimension over embeddingDims", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: { collectionName: "t", dimension: 1024 },
},
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBe(1024);
});
it("leaves dimension undefined for custom client without explicit dims", () => {
const cfg = ConfigManager.mergeConfig({
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
vectorStore: {
provider: "qdrant",
config: { collectionName: "t", client: {} },
},
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBeUndefined();
});
it("uses embeddingDims with a custom client", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: { collectionName: "t", client: {} },
},
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBe(768);
});
it("preserves all other vectorStore config fields", () => {
const cfg = ConfigManager.mergeConfig({
embedder: { provider: "openai", config: { apiKey: "k" } },
vectorStore: {
provider: "qdrant",
config: {
collectionName: "my-coll",
host: "my-host",
port: 6333,
apiKey: "qdrant-key",
},
},
llm: baseLlm,
});
expect(cfg.vectorStore.config.collectionName).toBe("my-coll");
expect(cfg.vectorStore.config.host).toBe("my-host");
expect(cfg.vectorStore.config.port).toBe(6333);
expect(cfg.vectorStore.config.apiKey).toBe("qdrant-key");
});
it("leaves dimension undefined with empty config", () => {
const cfg = ConfigManager.mergeConfig({
embedder: { provider: "openai", config: {} },
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.vectorStore.config.dimension).toBeUndefined();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 2. MemoryVectorStore – backward compat with explicit dimensions
// ───────────────────────────────────────────────────────────────────────────
describe("MemoryVectorStore – backward compat", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("defaults to dimension 1536 when not specified", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dbPath: path.join(tmpDir, "vs.db"),
});
const vector = new Array(1536).fill(0.1);
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
const result = await store.get("id-1");
expect(result).not.toBeNull();
});
it("explicit dimension=1536 still works", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dimension: 1536,
dbPath: path.join(tmpDir, "vs.db"),
});
const vector = new Array(1536).fill(0.1);
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
const result = await store.get("id-1");
expect(result).not.toBeNull();
});
it("explicit dimension rejects mismatched vectors", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dimension: 1536,
dbPath: path.join(tmpDir, "vs.db"),
});
const wrongVector = new Array(768).fill(0.1);
await expect(
store.insert([wrongVector], ["id-1"], [{ data: "hello" }]),
).rejects.toThrow("Vector dimension mismatch");
});
it("search validates dimension", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dimension: 4,
dbPath: path.join(tmpDir, "vs.db"),
});
await expect(store.search([1, 2, 3], 1)).rejects.toThrow(
"Query dimension mismatch",
);
});
it("custom dimension=768 works end-to-end", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dimension: 768,
dbPath: path.join(tmpDir, "vs.db"),
});
await store.insert(
[
[1, ...new Array(767).fill(0)],
[0, 1, ...new Array(766).fill(0)],
],
["a", "b"],
[{ data: "alpha" }, { data: "beta" }],
);
const results = await store.search([1, ...new Array(767).fill(0)], 2);
expect(results.length).toBe(2);
expect(results[0].id).toBe("a");
});
it("getUserId and setUserId still work", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dbPath: path.join(tmpDir, "vs.db"),
});
const userId = await store.getUserId();
expect(typeof userId).toBe("string");
expect(userId.length).toBeGreaterThan(0);
await store.setUserId("custom-user");
const newUserId = await store.getUserId();
expect(newUserId).toBe("custom-user");
});
it("initialize() is idempotent", async () => {
const store = new MemoryVectorStore({
collectionName: "test",
dbPath: path.join(tmpDir, "vs.db"),
});
await store.initialize();
await store.initialize();
await store.initialize();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 3. Memory class – auto-init with probe, lazy gate, backward compat
// ───────────────────────────────────────────────────────────────────────────
describe("Memory – auto-initialization", () => {
let mockEmbedderFactory: any;
let mockVectorStoreFactory: any;
let mockLlmFactory: any;
let mockHistoryFactory: any;
let MemoryClass: any;
function createMockEmbedder(dims: number) {
return {
embed: jest.fn().mockResolvedValue(new Array(dims).fill(0)),
embedBatch: jest.fn().mockResolvedValue([new Array(dims).fill(0)]),
};
}
function createMockVectorStore() {
return {
insert: jest.fn().mockResolvedValue(undefined),
search: jest.fn().mockResolvedValue([]),
get: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
deleteCol: jest.fn().mockResolvedValue(undefined),
list: jest.fn().mockResolvedValue([[], 0]),
getUserId: jest.fn().mockResolvedValue("test-user-id"),
setUserId: jest.fn().mockResolvedValue(undefined),
initialize: jest.fn().mockResolvedValue(undefined),
};
}
beforeEach(() => {
jest.resetModules();
const mockEmbedder = createMockEmbedder(768);
const mockVStore = createMockVectorStore();
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
mockLlmFactory = {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
};
mockHistoryFactory = {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
};
jest.doMock("../src/utils/factory", () => ({
EmbedderFactory: mockEmbedderFactory,
VectorStoreFactory: mockVectorStoreFactory,
LLMFactory: mockLlmFactory,
HistoryManagerFactory: mockHistoryFactory,
}));
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
MemoryClass = require("../src/memory").Memory;
});
afterEach(() => {
jest.restoreAllMocks();
jest.resetModules();
});
it("probes embedder to detect dimension when none set", async () => {
const mockEmbedder = createMockEmbedder(768);
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
// Should have called embed("dimension probe") to detect dimension
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
// VectorStoreFactory should have been called with detected dimension
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
expect(vsCreateCall[1].dimension).toBe(768);
});
it("skips probe when explicit dimension provided", async () => {
const mockEmbedder = createMockEmbedder(1536);
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
embedder: { provider: "openai", config: { apiKey: "k" } },
vectorStore: {
provider: "memory",
config: { collectionName: "test", dimension: 1536 },
},
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
// embed should NOT have been called for probing
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
// VectorStoreFactory gets the explicit dimension
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
expect(vsCreateCall[1].dimension).toBe(1536);
});
it("skips probe when embeddingDims provided", async () => {
const mockEmbedder = createMockEmbedder(768);
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
// ConfigManager resolves dimension from embeddingDims → no probe needed
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
});
it("all public methods wait for initialization", async () => {
let resolveProbe: () => void;
let probeCallCount = 0;
const mockEmbedder = {
embed: jest.fn().mockImplementation(() => {
probeCallCount++;
if (probeCallCount === 1) {
// First call is the dimension probe — hang until manually resolved
return new Promise<number[]>((resolve) => {
resolveProbe = () => resolve(new Array(768).fill(0));
});
}
// Subsequent calls (from search, etc.) resolve immediately
return Promise.resolve(new Array(768).fill(0));
}),
embedBatch: jest.fn(),
};
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
embedder: { provider: "ollama", config: { model: "test" } },
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
let getAllDone = false;
let searchDone = false;
let getDone = false;
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
const searchP = mem
.search("q", { userId: "u" })
.then(() => (searchDone = true));
const getP = mem.get("id").then(() => (getDone = true));
await new Promise((r) => setTimeout(r, 50));
expect(getAllDone).toBe(false);
expect(searchDone).toBe(false);
expect(getDone).toBe(false);
// Resolve the probe — init completes — methods unblock
resolveProbe!();
await Promise.all([getAllP, searchP, getP]);
expect(getAllDone).toBe(true);
expect(searchDone).toBe(true);
expect(getDone).toBe(true);
});
it("reset re-creates vector store with correct dimension", async () => {
const mockEmbedder = createMockEmbedder(768);
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
// Reset should re-create vector store
const mockVStore2 = createMockVectorStore();
mockVectorStoreFactory.create.mockReturnValue(mockVStore2);
await mem.reset();
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(2);
// Second creation should still have dimension=768 (cached from first probe)
const secondCall = mockVectorStoreFactory.create.mock.calls[1];
expect(secondCall[1].dimension).toBe(768);
});
it("backward compat: full explicit config works without probe", async () => {
const mockEmbedder = createMockEmbedder(1536);
const mockVStore = createMockVectorStore();
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "sk-fake", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-memories", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
});
it("throws explicit error when probe fails", async () => {
const mockEmbedder = {
embed: jest.fn().mockRejectedValue(new Error("Connection refused")),
embedBatch: jest.fn(),
};
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
// Suppress console.error for this test
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const mem = new MemoryClass({
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: { provider: "openai", config: { apiKey: "k" } },
disableHistory: true,
});
// getAll should reject with the init error
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
"auto-detect embedding dimension",
);
// Verify the error was logged and contains helpful information
const errorCall = consoleSpy.mock.calls.find(
(call) =>
call[0] instanceof Error &&
call[0].message.includes("auto-detect embedding dimension"),
);
expect(errorCall).toBeDefined();
const errorMsg = (errorCall![0] as Error).message;
expect(errorMsg).toContain("ollama");
expect(errorMsg).toContain("Connection refused");
expect(errorMsg).toContain("dimension");
expect(errorMsg).toContain("embeddingDims");
consoleSpy.mockRestore();
});
});
+44
View File
@@ -0,0 +1,44 @@
/// <reference types="jest" />
import { VectorStoreFactory } from "../src/utils/factory";
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
describe("VectorStoreFactory", () => {
describe("create", () => {
it("should create Azure AI Search vector store", () => {
const config = {
collectionName: "test-memories",
serviceName: "test-service",
apiKey: "test-api-key",
embeddingModelDims: 1536,
compressionType: "none" as const,
useFloat16: false,
hybridSearch: false,
vectorFilterMode: "preFilter" as const,
};
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
expect(vectorStore).toBeInstanceOf(AzureAISearch);
});
it("should create memory vector store", () => {
const config = {
collectionName: "test-memories",
dimension: 1536,
};
const vectorStore = VectorStoreFactory.create("memory", config);
expect(vectorStore).toBeDefined();
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
});
it("should throw error for unsupported provider", () => {
const config = {};
expect(() => {
VectorStoreFactory.create("unsupported-provider", config);
}).toThrow("Unsupported vector store provider: unsupported-provider");
});
});
});
-287
View File
@@ -1,287 +0,0 @@
/**
* 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",
);
});
});
-187
View File
@@ -1,187 +0,0 @@
/// <reference types="jest" />
/**
* Google LLM — unit tests (mocked @google/genai).
*
* Regression tests for #4380: tools parameter was ignored, causing graph
* memory operations to silently fail with Gemini models.
*/
const mockGenerateContent = jest.fn();
jest.mock("@google/genai", () => ({
GoogleGenAI: jest.fn().mockImplementation(() => ({
models: { generateContent: mockGenerateContent },
})),
}));
import { GoogleLLM } from "../src/llms/google";
describe("GoogleLLM (unit)", () => {
beforeEach(() => mockGenerateContent.mockClear());
it("returns text response when no tools are provided", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: '{"facts": ["fact1"]}',
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Hello" },
]);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(result).toBe('{"facts": ["fact1"]}');
// Verify tools are not in config
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.tools).toBeUndefined();
});
it("forwards tools as functionDeclarations to Gemini API", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "",
functionCalls: [
{
name: "extract_entities",
args: { entities: [{ entity: "Alice", entity_type: "person" }] },
},
],
});
const tools = [
{
type: "function",
function: {
name: "extract_entities",
description: "Extract entities from text",
parameters: {
type: "object",
properties: {
entities: {
type: "array",
items: {
type: "object",
properties: {
entity: { type: "string" },
entity_type: { type: "string" },
},
},
},
},
required: ["entities"],
},
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice is a person" }],
undefined,
tools,
);
// Verify functionDeclarations were passed in config
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.tools).toBeDefined();
expect(callArgs.config.tools[0].functionDeclarations).toHaveLength(1);
expect(callArgs.config.tools[0].functionDeclarations[0].name).toBe(
"extract_entities",
);
// Verify toolCalls in response
expect(result).toHaveProperty("toolCalls");
const response = result as { toolCalls: any[] };
expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls[0].name).toBe("extract_entities");
expect(JSON.parse(response.toolCalls[0].arguments)).toEqual({
entities: [{ entity: "Alice", entity_type: "person" }],
});
});
it("returns text when tools are provided but model returns text", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "Just a text response",
functionCalls: null,
});
const tools = [
{
type: "function",
function: {
name: "noop",
description: "No operation",
parameters: { type: "object", properties: {} },
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Hello" }],
undefined,
tools,
);
// Should return text, not toolCalls
expect(result).toBe("Just a text response");
});
it("strips markdown code fences from text responses", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: '```json\n{"facts": ["fact1"]}\n```',
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Extract facts" },
]);
expect(result).toBe('{"facts": ["fact1"]}');
});
it("handles multiple function calls in response", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "",
functionCalls: [
{
name: "add_graph_memory",
args: { source: "Alice", destination: "Bob", relationship: "knows" },
},
{
name: "add_graph_memory",
args: {
source: "Bob",
destination: "Charlie",
relationship: "works_with",
},
},
],
});
const tools = [
{
type: "function",
function: {
name: "add_graph_memory",
description: "Add a graph memory",
parameters: { type: "object", properties: {} },
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice knows Bob, Bob works with Charlie" }],
undefined,
tools,
);
const response = result as { toolCalls: any[] };
expect(response.toolCalls).toHaveLength(2);
expect(response.toolCalls[0].name).toBe("add_graph_memory");
expect(response.toolCalls[1].name).toBe("add_graph_memory");
});
});
@@ -1,548 +0,0 @@
/**
* Regression tests for graph_memory.ts response parsing (issue #4248).
*
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
* 1. _retrieveNodesFromData → entity extraction
* 2. _establishNodesRelationsFromData → relation extraction
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
*
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
* string-only responses, empty tool calls, and prompt construction.
*
* See: https://github.com/mem0ai/mem0/issues/4248
*/
import { MemoryGraph } from "../src/memory/graph_memory";
import {
EXTRACT_RELATIONS_PROMPT,
getDeleteMessages,
} from "../src/graphs/utils";
// ---------------------------------------------------------------------------
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
// ---------------------------------------------------------------------------
// Mock neo4j-driver: provides a fake Driver with a no-op session
jest.mock("neo4j-driver", () => ({
__esModule: true,
default: {
driver: jest.fn(() => ({
session: () => ({
run: jest.fn().mockResolvedValue({ records: [] }),
close: jest.fn(),
}),
})),
auth: { basic: jest.fn() },
},
}));
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
const mockGenerateResponse = jest.fn();
const mockGenerateChat = jest.fn();
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
jest.mock("../src/utils/factory", () => ({
LLMFactory: {
create: jest.fn(() => ({
generateResponse: mockGenerateResponse,
generateChat: mockGenerateChat,
})),
},
EmbedderFactory: {
create: jest.fn(() => ({
embed: mockEmbed,
})),
},
}));
// Minimal config that satisfies the MemoryGraph constructor
function makeConfig(overrides: Record<string, any> = {}) {
return {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
...overrides,
},
embedder: { provider: "openai", config: {} },
llm: { provider: "openai", config: {} },
} as any;
}
// Helper to access private methods via `any` cast
function graph(overrides: Record<string, any> = {}): any {
return new MemoryGraph(makeConfig(overrides));
}
const FILTERS = { userId: "test-user" };
beforeEach(() => {
jest.clearAllMocks();
});
// ═══════════════════════════════════════════════════════════════════════════
// 1. _retrieveNodesFromData – entity extraction
// ═══════════════════════════════════════════════════════════════════════════
describe("_retrieveNodesFromData", () => {
it("parses a well-formed extract_entities tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({
entities: [
{ entity: "Alice", entity_type: "person" },
{ entity: "Pizza", entity_type: "food" },
],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData(
"Alice likes pizza",
FILTERS,
);
expect(result).toEqual({ alice: "person", pizza: "food" });
});
it("returns empty map when LLM returns a plain string", async () => {
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("returns empty map when toolCalls is undefined", async () => {
mockGenerateResponse.mockResolvedValueOnce({});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("returns empty map when toolCalls is an empty array", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("handles malformed JSON in tool call arguments gracefully", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
],
});
const mg = graph();
// Should not throw — the catch block in the source logs the error
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("handles missing entities array in arguments", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({ wrong_key: [] }),
},
],
});
const mg = graph();
// args.entities is undefined → for..of on undefined throws → caught
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("skips tool calls with unrelated names", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "some_other_tool",
arguments: JSON.stringify({
entities: [{ entity: "X", entity_type: "Y" }],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("normalises entity names to lowercase with underscores", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({
entities: [{ entity: "New York City", entity_type: "City Name" }],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({ new_york_city: "city_name" });
});
it("passes json_object response format and the correct system prompt", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._retrieveNodesFromData("test data", FILTERS);
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemMsg = messages[0].content as string;
expect(systemMsg.toLowerCase()).toContain("json");
expect(systemMsg).toContain("test-user");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 2. _establishNodesRelationsFromData – relation extraction
// ═══════════════════════════════════════════════════════════════════════════
describe("_establishNodesRelationsFromData", () => {
it("parses a well-formed establish_relationships tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({
entities: [
{ source: "Alice", relationship: "likes", destination: "Pizza" },
],
}),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData(
"Alice likes pizza",
FILTERS,
{ alice: "person", pizza: "food" },
);
expect(result).toEqual([
{ source: "alice", relationship: "likes", destination: "pizza" },
]);
});
it("returns empty array when LLM returns a string", async () => {
mockGenerateResponse.mockResolvedValueOnce("just a string");
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
expect(result).toEqual([]);
});
it("returns empty array when toolCalls is empty", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
expect(result).toEqual([]);
});
it("returns empty array when entities key is missing from arguments", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({ not_entities: [] }),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
// args.entities is undefined → falls back to []
expect(result).toEqual([]);
});
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
});
const mg = graph();
// _establishNodesRelationsFromData does JSON.parse without try/catch
await expect(
mg._establishNodesRelationsFromData("x", FILTERS, {}),
).rejects.toThrow();
});
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("test-user");
expect(systemContent).not.toContain("USER_ID");
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
// (only replaced when config.graphStore.customPrompt is set)
});
it("appends JSON format suffix and custom prompt when configured", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph({ customPrompt: "Focus on food relationships only." });
await mg._establishNodesRelationsFromData("data", FILTERS, {});
const [messages] = mockGenerateResponse.mock.calls[0];
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("Focus on food relationships only.");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
// ═══════════════════════════════════════════════════════════════════════════
describe("_getDeleteEntitiesFromSearchOutput", () => {
const SEARCH_OUTPUT = [
{
source: "alice",
source_id: "1",
relationship: "likes",
relation_id: "r1",
destination: "pizza",
destination_id: "2",
similarity: 0.95,
},
];
it("parses a well-formed delete_graph_memory tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "Alice",
relationship: "likes",
destination: "Pizza",
}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"Alice hates pizza",
FILTERS,
);
expect(result).toEqual([
{ source: "alice", relationship: "likes", destination: "pizza" },
]);
});
it("returns empty array when LLM returns a string", async () => {
mockGenerateResponse.mockResolvedValueOnce("string response");
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("returns empty array when no tool calls are present", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("skips non-delete_graph_memory tool calls", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "noop",
arguments: JSON.stringify({}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("collects multiple delete tool calls", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "A",
relationship: "r1",
destination: "B",
}),
},
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "C",
relationship: "r2",
destination: "D",
}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toHaveLength(2);
expect(result[0].source).toBe("a");
expect(result[1].source).toBe("c");
});
it("passes json_object format and includes 'json' in system prompt", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("test-user");
expect(systemContent).not.toContain("USER_ID");
});
it("handles empty searchOutput array", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
[],
"data",
FILTERS,
);
expect(result).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. Prompt construction — JSON keyword present in every json_object site
// ═══════════════════════════════════════════════════════════════════════════
describe("Prompt construction — all json_object sites include 'json'", () => {
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._retrieveNodesFromData("test", { userId });
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._establishNodesRelationsFromData("test", { userId }, {});
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
// ═══════════════════════════════════════════════════════════════════════════
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
it("normalises spaces and case in entity source/relationship/destination", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({
entities: [
{
source: "New York",
relationship: "Capital Of",
destination: "United States",
},
],
}),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData(
"test",
FILTERS,
{},
);
expect(result).toEqual([
{
source: "new_york",
relationship: "capital_of",
destination: "united_states",
},
]);
});
});
-177
View File
@@ -1,177 +0,0 @@
import {
DELETE_RELATIONS_SYSTEM_PROMPT,
EXTRACT_RELATIONS_PROMPT,
UPDATE_GRAPH_PROMPT,
getDeleteMessages,
formatEntities,
} from "../src/graphs/utils";
/**
* Regression tests for graph prompts (issue #4248).
*
* When response_format: { type: "json_object" } is used, OpenAI requires
* the word "json" (case-insensitive) to appear in at least one message.
* Missing it produces a 400 error.
*
* Three call sites use json_object today:
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
*
* See: https://github.com/mem0ai/mem0/issues/4248
*/
// ─── JSON keyword presence ────────────────────────────────────────────────────
describe("Graph prompts — JSON keyword requirement", () => {
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
});
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
// graph_memory.ts appends "\nPlease provide your response in JSON format."
const withSuffix =
EXTRACT_RELATIONS_PROMPT +
"\nPlease provide your response in JSON format.";
expect(withSuffix.toLowerCase()).toContain("json");
});
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
const [systemContent] = getDeleteMessages(
"alice -- loves -- pizza",
"Alice now hates pizza",
"user-42",
);
expect(systemContent.toLowerCase()).toContain("json");
});
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
// Mirrors the template in _retrieveNodesFromData()
const userId = "user-1";
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
expect(prompt.toLowerCase()).toContain("json");
});
});
// ─── getDeleteMessages ────────────────────────────────────────────────────────
describe("getDeleteMessages", () => {
it("replaces USER_ID with the provided userId in the system prompt", () => {
const [system] = getDeleteMessages("mem", "data", "alice-123");
expect(system).toContain("alice-123");
expect(system).not.toContain("USER_ID");
});
it("includes existing memories and new data in the user prompt", () => {
const existing = "bob -- knows -- carol";
const newData = "Bob no longer knows Carol";
const [, user] = getDeleteMessages(existing, newData, "u1");
expect(user).toContain(existing);
expect(user).toContain(newData);
});
it("returns a 2-tuple [system, user]", () => {
const result = getDeleteMessages("a", "b", "c");
expect(result).toHaveLength(2);
expect(typeof result[0]).toBe("string");
expect(typeof result[1]).toBe("string");
});
// — Malformed / edge-case inputs —
it("handles empty strings without throwing", () => {
expect(() => getDeleteMessages("", "", "")).not.toThrow();
const [system, user] = getDeleteMessages("", "", "");
expect(system.toLowerCase()).toContain("json");
expect(typeof user).toBe("string");
});
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
const [system] = getDeleteMessages(
"mem",
"data",
'<script>alert("xss")</script>',
);
expect(system).toContain('<script>alert("xss")</script>');
expect(system).not.toContain("USER_ID");
});
it("handles unicode input", () => {
const [system, user] = getDeleteMessages(
"日本語メモリ",
"新しい情報",
"ユーザー1",
);
expect(system).toContain("ユーザー1");
expect(user).toContain("日本語メモリ");
expect(user).toContain("新しい情報");
});
it("handles very long input strings", () => {
const longStr = "x".repeat(100_000);
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
const [system] = getDeleteMessages(longStr, longStr, "u");
expect(system.toLowerCase()).toContain("json");
});
});
// ─── formatEntities ───────────────────────────────────────────────────────────
describe("formatEntities", () => {
it("formats a single entity triplet", () => {
const result = formatEntities([
{ source: "Alice", relationship: "knows", destination: "Bob" },
]);
expect(result).toBe("Alice -- knows -- Bob");
});
it("joins multiple entities with newlines", () => {
const result = formatEntities([
{ source: "A", relationship: "r1", destination: "B" },
{ source: "C", relationship: "r2", destination: "D" },
]);
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
});
it("returns empty string for empty array", () => {
expect(formatEntities([])).toBe("");
});
it("preserves special characters in entity fields", () => {
const result = formatEntities([
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
]);
expect(result).toContain("O'Brien");
expect(result).toContain('said "hello"');
expect(result).toContain("café");
});
});
// ─── Prompt structural invariants ─────────────────────────────────────────────
describe("Prompt structural invariants", () => {
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
});
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
});
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
});
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
});
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
});
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
});
});
@@ -1,83 +0,0 @@
/// <reference types="jest" />
/**
* LM Studio Embedder — unit tests (mocked OpenAI).
*/
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
const mockCreate = jest.fn().mockResolvedValue({
data: [{ embedding: mockEmbedding }],
});
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
embeddings: { create: mockCreate },
}));
});
describe("LMStudioEmbedder (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
const embedder = new LMStudioEmbedder({
model: "nomic-embed-text-v1.5-GGUF",
baseURL: "http://localhost:1234/v1",
});
const result = await embedder.embed("Sample text to embed.");
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate.mock.calls[0][0]).toEqual({
model: "nomic-embed-text-v1.5-GGUF",
input: "Sample text to embed.",
encoding_format: "float",
});
expect(result).toEqual(mockEmbedding);
});
it("embed() normalizes newlines", async () => {
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
await embedder.embed("Line one\nLine two");
expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two");
});
it("embed() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
await expect(embedder.embed("text")).rejects.toThrow(
"LM Studio embedder failed: Connection refused",
);
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const mockBatch = [
[0.1, 0.2],
[0.3, 0.4],
];
mockCreate.mockResolvedValueOnce({
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
});
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]);
expect(result).toEqual(mockBatch);
});
});
-114
View File
@@ -1,114 +0,0 @@
/// <reference types="jest" />
/**
* LM Studio LLM — unit tests (mocked OpenAI).
*/
import { LMStudioLLM } from "../src/llms/lmstudio";
const mockCreate = jest.fn();
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } },
}));
});
describe("LMStudioLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("generateResponse() returns a text response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: "Hello, world!",
role: "assistant",
tool_calls: null,
},
},
],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateResponse([
{ role: "user", content: "Hi" },
]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(result).toBe("Hello, world!");
});
it("generateResponse() handles tool calls", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: "",
role: "assistant",
tool_calls: [
{
function: {
name: "get_weather",
arguments: '{"city": "London"}',
},
},
],
},
},
],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateResponse(
[{ role: "user", content: "What is the weather?" }],
undefined,
[{ type: "function", function: { name: "get_weather" } }],
);
expect(result).toEqual({
content: "",
role: "assistant",
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
});
});
it("generateResponse() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
await expect(
llm.generateResponse([{ role: "user", content: "Hi" }]),
).rejects.toThrow("LM Studio LLM failed: Connection refused");
});
it("generateChat() returns LLMResponse shape", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: { content: "I can help with that.", role: "assistant" },
},
],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateChat([
{ role: "user", content: "Help me" },
]);
expect(result).toEqual({
content: "I can help with that.",
role: "assistant",
});
});
it("generateChat() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
await expect(
llm.generateChat([{ role: "user", content: "Hi" }]),
).rejects.toThrow("LM Studio LLM failed: Timeout");
});
});
-168
View File
@@ -1,168 +0,0 @@
/**
* 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,
dbPath: ":memory:",
},
},
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" }),
);
});
});
-359
View File
@@ -1,359 +0,0 @@
/**
* 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,
dbPath: ":memory:",
},
},
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);
});
});
-125
View File
@@ -1,125 +0,0 @@
/**
* OSS Memory unit tests — constructor, initialization, config validation, reset.
* Mocks LLM/Embedder at module level. No API keys needed.
*/
/// <reference types="jest" />
import { Memory } from "../src/memory";
import type { MemoryConfig, SearchResult } from "../src/types";
jest.setTimeout(15000);
// Mock Google modules to prevent @google/genai crash in CI
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
// ─── Content-based LLM mock (reviewer #9) ────────────────
// Returns facts for system-prompt calls, memory actions for user-only calls.
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({ facts: ["test fact"] });
}
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "test fact",
old_memory: "",
new_memory: "test fact",
},
],
});
},
),
})),
}));
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embeddingDims: 1536,
})),
}));
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-init", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
...overrides,
});
}
describe("Memory - Initialization", () => {
test("constructs without throwing with valid config", () => {
expect(() => createMemory()).not.toThrow();
});
test("fromConfig creates instance from config dict", () => {
const config = {
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4" },
},
};
const mem = Memory.fromConfig(config);
expect(mem).toBeInstanceOf(Memory);
});
test("fromConfig throws on invalid config", () => {
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
});
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
const mem = createMemory({ disableHistory: true });
// If DummyHistoryManager is used, history returns [] without error
const result = await mem.history("nonexistent-id");
expect(Array.isArray(result)).toBe(true);
});
});
describe("Memory - reset()", () => {
test("reset clears all stored memories", async () => {
const mem = createMemory();
const userId = `reset_test_${Date.now()}`;
await mem.add("Remember this fact", { userId });
const before: SearchResult = await mem.getAll({ userId });
expect(before.results.length).toBeGreaterThan(0);
await mem.reset();
const after: SearchResult = await mem.getAll({ userId });
expect(after.results).toHaveLength(0);
});
});
+256
View File
@@ -0,0 +1,256 @@
/// <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);
});
});
});
@@ -1,121 +0,0 @@
/// <reference types="jest" />
/**
* Ollama Embedder — unit tests (mocked Ollama client).
*/
import { OllamaEmbedder } from "../src/embeddings/ollama";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
const mockEmbed = jest.fn().mockResolvedValue({
model: "nomic-embed-text:latest",
embeddings: [mockEmbedding],
});
const mockList = jest.fn().mockResolvedValue({
models: [{ name: "nomic-embed-text:latest" }],
});
const mockPull = jest.fn().mockResolvedValue({});
jest.mock("ollama", () => ({
Ollama: jest.fn().mockImplementation(() => ({
embed: mockEmbed,
list: mockList,
pull: mockPull,
})),
}));
describe("OllamaEmbedder (unit)", () => {
beforeEach(() => {
mockEmbed.mockClear();
mockList.mockClear();
mockPull.mockClear();
});
it("embed() calls ollama.embed with model and input, returns first embedding", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
const result = await embedder.embed("Sample text to embed.");
expect(mockEmbed).toHaveBeenCalledTimes(1);
expect(mockEmbed.mock.calls[0][0]).toEqual({
model: "nomic-embed-text:latest",
input: "Sample text to embed.",
});
expect(result).toEqual(mockEmbedding);
});
it("embed() coerces non-string input to JSON string", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
// Force a non-string through the type boundary
await embedder.embed(42 as any);
expect(mockEmbed.mock.calls[0][0].input).toBe("42");
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(mockEmbed).toHaveBeenCalledTimes(2);
expect(result).toEqual([mockEmbedding, mockEmbedding]);
});
it("ensureModelExists() does not pull when model is already present", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await embedder.embed("trigger ensureModelExists");
expect(mockList).toHaveBeenCalled();
expect(mockPull).not.toHaveBeenCalled();
});
it("ensureModelExists() pulls model when not found locally", async () => {
mockList.mockResolvedValueOnce({ models: [] });
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await embedder.embed("trigger ensureModelExists");
expect(mockPull).toHaveBeenCalledWith({ model: "nomic-embed-text:latest" });
});
it("ensureModelExists() normalizes model name with :latest tag", async () => {
mockList.mockResolvedValue({
models: [{ name: "nomic-embed-text:latest" }],
});
const embedder = new OllamaEmbedder({
model: "nomic-embed-text",
});
await embedder.embed("trigger ensureModelExists");
expect(mockPull).not.toHaveBeenCalled();
});
it("embed() throws when embeddings array is empty", async () => {
mockEmbed.mockResolvedValueOnce({
model: "nomic-embed-text:latest",
embeddings: [],
});
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await expect(embedder.embed("text")).rejects.toThrow(
"Ollama embed() returned no embeddings",
);
});
});
@@ -1,30 +0,0 @@
import { removeCodeBlocks } from "../src/prompts";
describe("removeCodeBlocks", () => {
it("extracts JSON from ```json code fence", () => {
const input = '```json\n{"facts": ["hello"]}\n```';
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
});
it("extracts content from bare ``` code fence", () => {
const input = '```\n{"key": "value"}\n```';
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
});
it("returns plain text unchanged", () => {
const input = '{"facts": ["hello"]}';
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
});
it("handles multiple code blocks", () => {
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
});
it("handles Claude-style response with surrounding text", () => {
const input =
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
expect(removeCodeBlocks(input)).toContain('"facts"');
expect(removeCodeBlocks(input)).not.toContain("```");
});
});
-228
View File
@@ -1,228 +0,0 @@
/**
* 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);
});
});
@@ -1,51 +0,0 @@
import * as fs from "fs";
import * as path from "path";
/**
* Drift-prevention test: ensures every peerDependency in package.json
* is listed in tsup.config.ts's external array so tsup never bundles
* optional provider SDKs into the dist output.
*/
describe("tsup.config.ts externals", () => {
let peerDeps: string[];
let directDeps: string[];
let externalDeps: string[];
beforeAll(() => {
const pkgPath = path.resolve(__dirname, "../../../package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
// Filter out @types/* packages — they are type-only and not bundled at runtime
peerDeps = Object.keys(pkg.peerDependencies || {}).filter(
(dep) => !dep.startsWith("@types/"),
);
directDeps = Object.keys(pkg.dependencies || {});
const tsupConfigPath = path.resolve(__dirname, "../../../tsup.config.ts");
const tsupContent = fs.readFileSync(tsupConfigPath, "utf-8");
// Extract strings from the external array (supports double, single, and backtick quotes)
const externalMatch = tsupContent.match(
/const external\s*=\s*\[([\s\S]*?)\];/,
);
if (!externalMatch) {
throw new Error("Could not find external array in tsup.config.ts");
}
const matches = externalMatch[1].match(/["'`]([^"'`]+)["'`]/g);
externalDeps = (matches || []).map((m) => m.replace(/["'`]/g, ""));
});
it("should have every peerDependency in the external array", () => {
const missing = peerDeps.filter((dep) => !externalDeps.includes(dep));
expect(missing).toEqual([]);
});
it("should not have stale entries that are not in package.json", () => {
const allDeps = [...peerDeps, ...directDeps];
const stale = externalDeps.filter((dep) => !allDeps.includes(dep));
expect(stale).toEqual([]);
});
it("should have peerDependencies defined in package.json", () => {
expect(peerDeps.length).toBeGreaterThan(0);
});
});
@@ -1,198 +0,0 @@
/**
* MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity.
* Uses real SQLite in-memory DB, no external dependencies.
*/
/// <reference types="jest" />
import { MemoryVectorStore } from "../src/vector_stores/memory";
import type { VectorStoreResult } from "../src/types";
const DIM = 4; // Small dimension for fast tests
function createStore(): MemoryVectorStore {
return new MemoryVectorStore({
collectionName: "test",
dimension: DIM,
dbPath: ":memory:",
});
}
function vec(values: number[]): number[] {
return values;
}
describe("MemoryVectorStore - insert + get", () => {
let store: MemoryVectorStore;
beforeAll(() => {
store = createStore();
});
test("inserts and retrieves a vector by ID", async () => {
await store.insert(
[vec([1, 0, 0, 0])],
["id1"],
[{ data: "hello", userId: "u1" }],
);
const result: VectorStoreResult | null = await store.get("id1");
expect(result).not.toBeNull();
expect(result!.id).toBe("id1");
expect(result!.payload.data).toBe("hello");
});
test("returns null for non-existent ID", async () => {
const result = await store.get("nonexistent");
expect(result).toBeNull();
});
test("throws on dimension mismatch during insert", async () => {
await expect(
store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]),
).rejects.toThrow("Vector dimension mismatch");
});
});
describe("MemoryVectorStore - search", () => {
let store: MemoryVectorStore;
beforeAll(async () => {
store = createStore();
await store.insert(
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])],
["a", "b", "c"],
[
{ data: "north", userId: "u1" },
{ data: "east", userId: "u1" },
{ data: "north-ish", userId: "u2" },
],
);
});
test("returns results sorted by cosine similarity descending", async () => {
const results: VectorStoreResult[] = await store.search(
vec([1, 0, 0, 0]),
10,
);
expect(results.length).toBeGreaterThan(0);
expect(results[0].id).toBe("a"); // exact match
// scores should be descending
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!);
}
});
test("respects limit parameter", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 1);
expect(results).toHaveLength(1);
});
test("filters by userId", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
});
test("returns empty when filter matches nothing", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 10, {
userId: "nobody",
});
expect(results).toHaveLength(0);
});
test("throws on query dimension mismatch", async () => {
await expect(store.search(vec([1, 0]), 10)).rejects.toThrow(
"Query dimension mismatch",
);
});
});
describe("MemoryVectorStore - update", () => {
let store: MemoryVectorStore;
beforeAll(async () => {
store = createStore();
await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]);
});
test("updates payload and vector", async () => {
await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" });
const result = await store.get("upd1");
expect(result!.payload.data).toBe("updated");
});
test("throws on dimension mismatch during update", async () => {
await expect(
store.update("upd1", vec([1, 0]), { data: "bad" }),
).rejects.toThrow("Vector dimension mismatch");
});
});
describe("MemoryVectorStore - delete + deleteCol", () => {
test("delete removes a vector", async () => {
const store = createStore();
await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]);
await store.delete("del1");
expect(await store.get("del1")).toBeNull();
});
test("deleteCol clears all vectors", async () => {
const store = createStore();
await store.insert(
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0])],
["x", "y"],
[{ data: "a" }, { data: "b" }],
);
await store.deleteCol();
const [results] = await store.list();
expect(results).toHaveLength(0);
});
});
describe("MemoryVectorStore - list", () => {
let store: MemoryVectorStore;
beforeAll(async () => {
store = createStore();
await store.insert(
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])],
["l1", "l2", "l3"],
[
{ data: "a", userId: "u1" },
{ data: "b", userId: "u1" },
{ data: "c", userId: "u2" },
],
);
});
test("returns all vectors without filter", async () => {
const [results, count] = await store.list();
expect(count).toBe(3);
expect(results).toHaveLength(3);
});
test("filters by userId", async () => {
const [results, count] = await store.list({ userId: "u1" });
expect(count).toBe(2);
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
});
test("respects limit", async () => {
const [results] = await store.list(undefined, 1);
expect(results).toHaveLength(1);
});
});
describe("MemoryVectorStore - userId tracking", () => {
test("getUserId generates and persists a random ID", async () => {
const store = createStore();
const id = await store.getUserId();
expect(typeof id).toBe("string");
expect(id.length).toBeGreaterThan(0);
// Calling again returns same ID
expect(await store.getUserId()).toBe(id);
});
test("setUserId overrides the stored ID", async () => {
const store = createStore();
await store.setUserId("custom-id");
expect(await store.getUserId()).toBe("custom-id");
});
});
File diff suppressed because it is too large Load Diff
+1 -11
View File
@@ -7,19 +7,9 @@ const external = [
"uuid",
"pg",
"zod",
"better-sqlite3",
"sqlite3",
"@qdrant/js-client-rest",
"redis",
"ollama",
"@google/genai",
"@mistralai/mistralai",
"neo4j-driver",
"@supabase/supabase-js",
"@azure/search-documents",
"@azure/identity",
"cloudflare",
"@cloudflare/workers-types",
"@langchain/core",
];
export default defineConfig([
+1 -7
View File
@@ -1,5 +1,4 @@
from typing import Any, Dict, Optional
from typing import Optional
from pydantic import Field
from mem0.configs.rerankers.base import BaseRerankerConfig
@@ -47,8 +46,3 @@ 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.",
)
+3 -15
View File
@@ -31,21 +31,12 @@ class OllamaEmbedding(EmbeddingBase):
self.client = Client(host=self.config.ollama_base_url)
self._ensure_model_exists()
@staticmethod
def _normalize_model_name(name: str) -> str:
return name if ":" in name else f"{name}:latest"
def _ensure_model_exists(self):
"""
Ensure the specified model exists locally. If not, pull it from Ollama.
"""
local_models = self.client.list()["models"]
target = self._normalize_model_name(self.config.model)
if not any(
self._normalize_model_name(model.get("name", "")) == target
or self._normalize_model_name(model.get("model", "")) == target
for model in local_models
):
if not any(model.get("name") == self.config.model or model.get("model") == self.config.model for model in local_models):
self.client.pull(self.config.model)
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
@@ -58,8 +49,5 @@ class OllamaEmbedding(EmbeddingBase):
Returns:
list: The embedding vector.
"""
response = self.client.embed(model=self.config.model, input=text)
embeddings = response.get("embeddings") or []
if not embeddings:
raise ValueError(f"Ollama embed() returned no embeddings for model '{self.config.model}'")
return embeddings[0]
response = self.client.embeddings(model=self.config.model, prompt=text)
return response["embedding"]
+1 -6
View File
@@ -43,12 +43,7 @@ class OpenAIEmbedding(EmbeddingBase):
"""
text = text.replace("\n", " ")
return (
self.client.embeddings.create(
input=[text],
model=self.config.model,
dimensions=self.config.embedding_dims,
encoding_format="float",
)
self.client.embeddings.create(input=[text], model=self.config.model, dimensions=self.config.embedding_dims)
.data[0]
.embedding
)
+2 -2
View File
@@ -97,7 +97,7 @@ class NeptuneBase(ABC):
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call.get("arguments", {}).get("entities", []):
for item in tool_call["arguments"]["entities"]:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
@@ -144,7 +144,7 @@ class NeptuneBase(ABC):
entities = []
if extracted_entities["tool_calls"]:
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
entities = self._remove_spaces_from_entities(entities)
logger.debug(f"Extracted entities: {entities}")
+1 -27
View File
@@ -1,4 +1,3 @@
import json
from typing import Dict, List, Optional, Union
try:
@@ -9,7 +8,6 @@ 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):
@@ -63,28 +61,7 @@ class OllamaLLM(LLMBase):
"tool_calls": [],
}
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}
)
# Ollama doesn't support tool calls in the same way, so we return the content
return processed_response
else:
return content
@@ -136,8 +113,5 @@ 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)
+5 -5
View File
@@ -30,10 +30,10 @@ class MemoryGraph:
def __init__(self, config):
self.config = config
self.graph = Neo4jGraph(
url=self.config.graph_store.config.url,
username=self.config.graph_store.config.username,
password=self.config.graph_store.config.password,
database=self.config.graph_store.config.database,
self.config.graph_store.config.url,
self.config.graph_store.config.username,
self.config.graph_store.config.password,
self.config.graph_store.config.database,
refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"},
)
@@ -215,7 +215,7 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call.get("arguments", {}).get("entities", []):
for item in tool_call["arguments"]["entities"]:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
+1 -1
View File
@@ -241,7 +241,7 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call.get("arguments", {}).get("entities", []):
for item in tool_call["arguments"]["entities"]:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
+35 -45
View File
@@ -24,12 +24,10 @@ from mem0.exceptions import ValidationError as Mem0ValidationError
from mem0.memory.base import MemoryBase
from mem0.memory.setup import mem0_dir, setup_config
from mem0.memory.storage import SQLiteManager
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
from mem0.memory.telemetry import capture_event
from mem0.memory.utils import (
ensure_json_instruction,
extract_json,
get_fact_retrieval_messages,
normalize_facts,
parse_messages,
parse_vision_messages,
process_telemetry_filters,
@@ -39,8 +37,8 @@ from mem0.utils.factory import (
EmbedderFactory,
GraphStoreFactory,
LlmFactory,
RerankerFactory,
VectorStoreFactory,
RerankerFactory,
)
# Suppress SWIG deprecation warnings globally
@@ -206,33 +204,32 @@ class Memory(MemoryBase):
self.enable_graph = True
else:
self.graph = None
if MEM0_TELEMETRY:
# Create telemetry config manually to avoid deepcopy issues with thread locks
telemetry_config_dict = {}
if hasattr(self.config.vector_store.config, 'model_dump'):
# For pydantic models
telemetry_config_dict = self.config.vector_store.config.model_dump()
else:
# For other objects, manually copy common attributes
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
if hasattr(self.config.vector_store.config, attr):
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
# Create telemetry config manually to avoid deepcopy issues with thread locks
telemetry_config_dict = {}
if hasattr(self.config.vector_store.config, 'model_dump'):
# For pydantic models
telemetry_config_dict = self.config.vector_store.config.model_dump()
else:
# For other objects, manually copy common attributes
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
if hasattr(self.config.vector_store.config, attr):
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
# Override collection name for telemetry
telemetry_config_dict['collection_name'] = "mem0migrations"
# Override collection name for telemetry
telemetry_config_dict['collection_name'] = "mem0migrations"
# Set path for file-based vector stores
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
# Set path for file-based vector stores
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
# Create the config object using the same class as the original
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
self._telemetry_vector_store = VectorStoreFactory.create(
self.config.vector_store.provider, telemetry_config
)
# Create the config object using the same class as the original
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
self._telemetry_vector_store = VectorStoreFactory.create(
self.config.vector_store.provider, telemetry_config
)
capture_event("mem0.init", self, {"sync_type": "sync"})
@classmethod
@@ -434,9 +431,6 @@ class Memory(MemoryBase):
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
# Ensure 'json' appears in prompts for json_object response format compatibility
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
response = self.llm.generate_response(
messages=[
{"role": "system", "content": system_prompt},
@@ -457,7 +451,6 @@ 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 = []
@@ -1053,10 +1046,11 @@ class Memory(MemoryBase):
keys, encoded_ids = process_telemetry_filters(filters)
capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"})
# delete matching vector memories individually (do NOT reset the collection)
# delete all vector memories and reset the collections
memories = self.vector_store.list(filters=filters)[0]
for memory in memories:
self._delete_memory(memory.id)
self.vector_store.reset()
logger.info(f"Deleted {len(memories)} memories")
@@ -1278,14 +1272,14 @@ class AsyncMemory(MemoryBase):
else:
self.graph = None
if MEM0_TELEMETRY:
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
telemetry_config.collection_name = "mem0migrations"
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config.path = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config.path, exist_ok=True)
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
telemetry_config.collection_name = "mem0migrations"
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config.path = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config.path, exist_ok=True)
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
capture_event("mem0.init", self, {"sync_type": "async"})
@classmethod
@@ -1466,9 +1460,6 @@ class AsyncMemory(MemoryBase):
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
# Ensure 'json' appears in prompts for json_object response format compatibility
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
response = await asyncio.to_thread(
self.llm.generate_response,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
@@ -1486,7 +1477,6 @@ 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 = []
+4 -5
View File
@@ -218,9 +218,8 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call.get("arguments", {}).get("entities", []):
if "entity" in item and "entity_type" in item:
entity_type_map[item["entity"]] = item["entity_type"]
for item in tool_call["arguments"]["entities"]:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
@@ -264,8 +263,8 @@ class MemoryGraph:
)
entities = []
if extracted_entities and extracted_entities.get("tool_calls"):
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
if extracted_entities["tool_calls"]:
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
entities = self._remove_spaces_from_entities(entities)
logger.debug(f"Extracted entities: {entities}")
+5 -16
View File
@@ -24,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if properties is None:
properties = {}
properties = {
@@ -53,17 +49,13 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -89,9 +81,6 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+1 -53
View File
@@ -1,15 +1,12 @@
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.
@@ -32,31 +29,6 @@ def get_fact_retrieval_messages_legacy(message):
return FACT_RETRIEVAL_PROMPT, f"Input:\n{message}"
def ensure_json_instruction(system_prompt, user_prompt):
"""Ensure the word 'json' appears in the prompts when using json_object response format.
OpenAI's API requires the word 'json' to appear in the messages when
response_format is set to {"type": "json_object"}. When users provide a
custom_fact_extraction_prompt that doesn't include 'json', this causes a
400 error. This function appends a JSON format instruction to the system
prompt if 'json' is not already present in either prompt.
Args:
system_prompt: The system prompt string
user_prompt: The user prompt string
Returns:
tuple: (system_prompt, user_prompt) with JSON instruction added if needed
"""
combined = (system_prompt + user_prompt).lower()
if "json" not in combined:
system_prompt += (
"\n\nYou must return your response in valid JSON format "
"with a 'facts' key containing an array of strings."
)
return system_prompt, user_prompt
def parse_messages(messages):
response = ""
for msg in messages:
@@ -80,30 +52,6 @@ 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:
"""
+14 -25
View File
@@ -1,10 +1,10 @@
import re
from typing import Any, Dict, List, Union
from typing import List, Dict, Any, Union
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
from mem0.configs.rerankers.base import BaseRerankerConfig
from mem0.configs.rerankers.llm import LLMRerankerConfig
class LLMReranker(BaseReranker):
@@ -33,30 +33,19 @@ class LLMReranker(BaseReranker):
self.config = config
# 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
# 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
# Initialize LLM using the factory
self.llm = LlmFactory.create(llm_provider, llm_config)
self.llm = LlmFactory.create(self.config.provider, llm_config)
# Default scoring prompt
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
+1 -1
View File
@@ -159,7 +159,7 @@ class RedisDB(VectorStoreBase):
return [
MemoryResult(
id=result["memory_id"],
score=float(result["vector_distance"]),
score=result["vector_distance"],
payload={
"hash": result["hash"],
"data": result["memory"],
-2
View File
@@ -1,2 +0,0 @@
package-manager-strict-version=false
approve-builds=esbuild
-81
View File
@@ -1,81 +0,0 @@
# Changelog
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [0.4.0] - 2026-03-16
### Added
- **Non-interactive trigger filtering**: Skips recall and capture for `cron`, `heartbeat`, `automation`, and `schedule` triggers — prevents system-generated noise from polluting memory
- **Subagent hallucination prevention**: `isSubagentSession()` detects ephemeral subagent sessions and routes recall to the parent (main user) namespace instead of empty ephemeral namespaces; skips capture to prevent orphaned memories
- **Subagent-specific preamble**: Subagents receive "You are a subagent — use these memories for context but do not assume you are this user" to prevent identity assumption
- **User identity in recall preamble**: Recalled memories now include `userId` attribution for better context
- **User identity in extraction preamble**: Extraction context includes user identity and current date for accurate attribution and temporal anchoring
- **User-content guard**: Skips extraction when no meaningful user messages remain after filtering
- **Dynamic recall thresholding**: Memories scoring less than 50% of the top result are dropped to filter out the long tail of weak matches
- **SQLite resilience for OSS mode**: Init error recovery with automatic retry (history disabled) when native SQLite bindings fail under jiti
- **`disableHistory` config option**: New `oss.disableHistory` flag to explicitly skip history DB initialization
- **Updated minimum package version of mem0ai package**: Updated minimum package version of mem0ai package to ^2.3.0 to force old users to migrate to better-sqlite3
- 78 unit tests covering filtering, isolation, trigger filtering, subagent detection, and SQLite resilience
### Changed
- Auto-recall threshold raised from 0.5 to 0.6 for stricter precision during automatic injection (explicit tool searches remain at 0.5)
- Recall candidate pool increased to `topK * 2` for better filtering headroom
- Provider init promises now reset on failure, allowing retry on subsequent calls
- Relaxed extraction instructions: related facts are kept together to preserve context (removed atomic memory requirement)
### Fixed
- **Concurrent session race condition**: Lifecycle hooks (`before_agent_start`, `agent_end`) now use `ctx.sessionKey` directly from the event context instead of a shared mutable `currentSessionId` variable, preventing cross-session data leaks when multiple sessions run simultaneously
## [0.3.1] - 2026-03-12
### Added
- **Message filtering pipeline**: Multi-stage noise removal before extraction — drops heartbeats, timestamps, single-word acks, system routing metadata, compaction audit logs, and generic assistant acknowledgments
- **Broad recall for new sessions**: Short or new-session prompts trigger a secondary broad search to avoid cold-start blindness
- **Client-side threshold filtering**: Safety net that drops low-relevance results even if the API doesn't honor the threshold parameter
- **Temporal anchoring**: Extraction instructions now include current date so memories are prefixed with "As of YYYY-MM-DD, ..."
- **Summary message inclusion**: Earlier assistant messages containing work summaries are included in extraction context even if outside the recent-message window
- 55 unit tests covering filtering and isolation helpers
### Changed
- Default `searchThreshold` remains at 0.5, with client-side filtering as a safety net
- Extraction window expanded from last 10 → last 20 messages for richer context
- Rewritten custom extraction instructions: conciseness, outcome-over-intent, deduplication guidance, language preservation
- **Refactored** monolithic `index.ts` (1772 lines) into 6 focused modules: `types.ts`, `providers.ts`, `config.ts`, `filtering.ts`, `isolation.ts`, `index.ts`
### Fixed
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
## [0.3.0] - 2026-03-10
### Fixed
- Updated `mem0ai` dependency which includes the sqlite3 to better-sqlite3 migration for native binding resolution (#4270)
## [0.2.0] - 2026-03-09
### Added
- "Understanding userId" section in docs clarifying that `userId` is user-defined
- Per-agent memory isolation for multi-agent setups via `agentId`
- Regression tests for per-agent isolation helpers
### Changed
- Updated config examples to use concrete `userId` values instead of placeholders
### Fixed
- Migrated platform search to Mem0 v2 API
## [0.1.2] - 2026-02-19
### Added
- Source field for openclaw memory entries
### Fixed
- Auto-recall injection and auto-capture message drop
## [0.1.0] - 2026-02-02
### Added
- Initial release of the OpenClaw Mem0 plugin
- Platform mode (Mem0 Cloud) and open-source mode support
- Auto-recall: inject relevant memories before each turn
- Auto-capture: store facts after each turn
- Configurable `topK`, `threshold`, and `apiVersion` options

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