Compare commits

..
Author SHA1 Message Date
kartik-mem0 68e2bca647 refactor: use extract_json for OllamaLLM argument parsing 2026-03-18 19:00:36 +05:30
Small c0069f0a59 fix(ollama): pass tools to client.chat and parse tool_calls from response
OllamaLLM.generate_response() never forwarded the tools parameter to
ollama.Client.chat(), and _parse_response() hard-coded tool_calls to an
empty list. This caused graph memory entity extraction to silently return
zero results when using Ollama as the LLM provider.

- Forward tools to client.chat() when provided
- Parse tool_calls from response (supports both dict and object formats)
- Handle string arguments via json.loads

Fixes #4175

Signed-off-by: sxu75374 <imshuaixu@gmail.com>
Signed-off-by: Small <imshuaixu@gmail.com>
2026-03-01 06:55:20 -08:00
116 changed files with 1913 additions and 20416 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/
-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>
+1 -2
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"
]
}
]
+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"
}
}
```
+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">
-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,501 +0,0 @@
/**
* MemoryClient E2E integration tests.
*
* These tests exercise realistic usage patterns with mock HTTP responses.
* Skipped by default — run with MEM0_RUN_E2E=1 to enable.
*
* Run: MEM0_RUN_E2E=1 npx jest memoryClient.e2e.test.ts
*/
import { MemoryClient } from "../mem0";
import type {
Memory,
AllUsers,
MemoryHistory,
User,
Messages,
} from "../mem0.types";
import {
createMockFetch,
createMockMemory,
createMockMemoryHistory,
createMockUser,
createMockAllUsers,
TEST_API_KEY,
MOCK_PING_RESPONSE,
} from "./helpers";
const originalFetch = global.fetch;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
if (
String(args[0] ?? "").match(
/Telemetry|Failed to initialize|Failed to capture/,
)
)
return;
originalConsoleError(...args);
});
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return;
originalConsoleWarn(...args);
});
});
afterAll(() => jest.restoreAllMocks());
afterEach(() => {
global.fetch = originalFetch;
});
// Shared test data matching realistic API responses
const userId = "test_user_abc123";
const memoryId = "mem_550e8400";
const mockMemory = createMockMemory({
id: memoryId,
memory: "Alex is a vegetarian",
user_id: userId,
event: "ADD",
data: { memory: "Alex is a vegetarian" },
categories: ["personal"],
metadata: null,
created_at: "2026-03-17T10:00:00Z",
updated_at: "2026-03-17T10:00:00Z",
score: 0.95,
});
function mockFetchForTest(
extraPatterns?: Record<string, { status: number; body: unknown }>,
) {
const responses = new Map<string, { status: number; body: unknown }>();
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
responses.set("/v1/memories/search/", { status: 200, body: [mockMemory] });
responses.set("/v2/memories/search/", { status: 200, body: [mockMemory] });
responses.set("/history/", {
status: 200,
body: [
createMockMemoryHistory({
memory_id: memoryId,
user_id: userId,
event: "ADD",
old_memory: null,
new_memory: "Alex is a vegetarian",
}),
],
});
responses.set("/v1/entities/", {
status: 200,
body: createMockAllUsers([
createMockUser({ id: "entity_1", name: userId, type: "user" }),
]),
});
// This must come last — it's a broad pattern that matches /v1/memories/:id/ and /v1/memories/
responses.set("/v1/memories/", { status: 200, body: [mockMemory] });
if (extraPatterns) {
for (const [k, v] of Object.entries(extraPatterns)) {
responses.set(k, v);
}
}
global.fetch = createMockFetch(responses);
}
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
describeOrSkip("MemoryClient API (E2E)", () => {
beforeEach(() => mockFetchForTest());
const messages1 = [
{ role: "user" as const, content: "Hey, I am Alex. I'm now a vegetarian." },
{ role: "assistant" as const, content: "Hello Alex! Glad to hear!" },
];
describe("add messages", () => {
let res: Memory[];
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
res = await client.add(messages1, { user_id: userId });
});
test("returns an array", () => {
expect(Array.isArray(res)).toBe(true);
});
test("first message has a string id", () => {
expect(typeof res[0].id).toBe("string");
});
test("first message has a string data.memory", () => {
expect(typeof res[0].data?.memory).toBe("string");
});
test("first message has a string event", () => {
expect(typeof res[0].event).toBe("string");
});
});
describe("retrieve specific memory by ID", () => {
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const memories = await client.getAll({ user_id: userId });
memory = Array.isArray(memories) ? memories[0] : memories;
});
test("returns string id", () => {
expect(typeof memory.id).toBe("string");
});
test("returns string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("returns string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("each category is a string", () => {
if (Array.isArray(memory.categories)) {
expect(
memory.categories.every((c: string) => typeof c === "string"),
).toBe(true);
}
});
test("created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("updated_at is a valid date", () => {
expect(new Date(memory.updated_at || "").toString()).not.toBe(
"Invalid Date",
);
});
});
describe("retrieve all users", () => {
let allUsers: AllUsers;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
allUsers = await client.users();
});
test("count is a number", () => {
expect(typeof allUsers.count).toBe("number");
});
test("first user has a string id", () => {
expect(typeof allUsers.results[0].id).toBe("string");
});
test("first user has a string name", () => {
expect(typeof allUsers.results[0].name).toBe("string");
});
test("first user has a string created_at", () => {
expect(typeof allUsers.results[0].created_at).toBe("string");
});
test("first user has a string updated_at", () => {
expect(typeof allUsers.results[0].updated_at).toBe("string");
});
test("first user has a number total_memories", () => {
expect(typeof allUsers.results[0].total_memories).toBe("number");
});
test("first user has a string type", () => {
expect(typeof allUsers.results[0].type).toBe("string");
});
test("results contain an entity matching userId", () => {
const entity = allUsers.results.find(
(user: User) => user.name === userId,
);
expect(entity).not.toBeUndefined();
});
test("matched entity has a string id", () => {
const entity = allUsers.results.find(
(user: User) => user.name === userId,
);
expect(typeof entity?.id).toBe("string");
});
});
describe("retrieve all memories for the user", () => {
let memories: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
memories = await client.getAll({ user_id: userId });
memory = memories[0];
});
test("returns an array", () => {
expect(Array.isArray(memories)).toBe(true);
});
test("first memory has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first memory has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first memory has a string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("first memory user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("first memory metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("first memory categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("first memory created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("first memory updated_at is a valid date", () => {
expect(new Date(memory.updated_at || "").toString()).not.toBe(
"Invalid Date",
);
});
});
describe("search with API version 2", () => {
let results: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
results = await client.search("What do you know about me?", {
filters: {
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
},
threshold: 0.1,
api_version: "v2",
});
memory = results[0];
});
test("returns an array", () => {
expect(Array.isArray(results)).toBe(true);
});
test("first result has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first result has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first result metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("first result categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("first result created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("first result has a number score", () => {
expect(typeof memory.score).toBe("number");
});
});
describe("search with API version 1", () => {
let results: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
results = await client.search("What is my name?", {
user_id: userId,
});
memory = results[0];
});
test("returns an array", () => {
expect(Array.isArray(results)).toBe(true);
});
test("first result has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first result has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first result has a string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("first result user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("first result has a number score", () => {
expect(typeof memory.score).toBe("number");
});
});
describe("retrieve history of a specific memory", () => {
let history: MemoryHistory[];
let entry: MemoryHistory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
history = await client.history(memoryId);
entry = history[0];
});
test("returns an array", () => {
expect(Array.isArray(history)).toBe(true);
});
test("first entry has a string id", () => {
expect(typeof entry.id).toBe("string");
});
test("first entry has a string memory_id", () => {
expect(typeof entry.memory_id).toBe("string");
});
test("first entry has a string user_id", () => {
expect(typeof entry.user_id).toBe("string");
});
test("first entry user_id matches the requested userId", () => {
expect(entry.user_id).toBe(userId);
});
test("old_memory is null or a string", () => {
expect(
entry.old_memory === null || typeof entry.old_memory === "string",
).toBe(true);
});
test("new_memory is null or a string", () => {
expect(
entry.new_memory === null || typeof entry.new_memory === "string",
).toBe(true);
});
test("created_at is a valid date", () => {
expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date");
});
test("updated_at is a valid date", () => {
expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date");
});
test("event is one of ADD, UPDATE, DELETE, NOOP", () => {
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event);
});
test("ADD event has null old_memory", () => {
expect(entry.old_memory).toBeNull();
});
test("ADD event has non-null new_memory", () => {
expect(entry.new_memory).not.toBeNull();
});
test("input is an array or null", () => {
expect(Array.isArray(entry.input) || entry.input === null).toBe(true);
});
test("each input item is an object", () => {
if (Array.isArray(entry.input)) {
expect(entry.input.every((i: Messages) => typeof i === "object")).toBe(
true,
);
}
});
test("each input item has a string content", () => {
if (Array.isArray(entry.input)) {
expect(
entry.input.every((i: Messages) => typeof i.content === "string"),
).toBe(true);
}
});
test("each input item has a valid role", () => {
if (Array.isArray(entry.input)) {
expect(
entry.input.every((i: Messages) =>
["user", "assistant"].includes(i.role),
),
).toBe(true);
}
});
});
describe("delete user", () => {
test("returns success message", async () => {
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: "org_test",
projectId: "proj_test",
});
client.client.delete = jest.fn().mockResolvedValue({
data: { message: "Entity deleted successfully!" },
});
const result = await client.deleteUsers({ user_id: userId });
expect(result.message).toBe("Entity deleted successfully.");
});
});
});
@@ -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}`);
}
}
}
+1 -1
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;
+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";
-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 },
],
+31 -91
View File
@@ -41,7 +41,7 @@ export class Memory {
private config: MemoryConfig;
private customPrompt: string | undefined;
private embedder: Embedder;
private vectorStore!: VectorStore;
private vectorStore: VectorStore;
private llm: LLM;
private db: HistoryManager;
private collectionName: string | undefined;
@@ -49,8 +49,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 +59,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 +70,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 +96,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 +157,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,
@@ -433,7 +382,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 +423,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 +489,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 +496,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 +504,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 +531,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 +559,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,
+1 -5
View File
@@ -278,9 +278,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",
);
});
});
@@ -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);
});
});
@@ -1,187 +0,0 @@
/// <reference types="jest" />
/**
* LM Studio integration tests against a real local server.
* Skipped by default. Enable with: LMSTUDIO_INTEGRATION=1
*
* Prerequisites:
* 1. LM Studio installed with `lms` CLI
* 2. Server running: lms server start
* 3. Embedding model loaded: lms load text-embedding-nomic-embed-text-v1.5
* 4. (Optional) Chat model loaded for LLM tests
*/
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
import { LMStudioLLM } from "../src/llms/lmstudio";
const LMSTUDIO_BASE_URL =
process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1";
const RUN_INTEGRATION = process.env.LMSTUDIO_INTEGRATION === "1";
const describeIf = RUN_INTEGRATION ? describe : describe.skip;
jest.setTimeout(120_000);
async function listModels(): Promise<{
embedding: string | null;
chat: string | null;
}> {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
const body = await res.json();
const models: any[] = body.data || [];
const embedding = models.find(
(m) => m.id.includes("embed") || m.id.includes("nomic"),
);
const chat = models.find(
(m) => !m.id.includes("embed") && !m.id.includes("nomic"),
);
return { embedding: embedding?.id ?? null, chat: chat?.id ?? null };
}
function cosineSim(a: number[], b: number[]): number {
let dot = 0,
normA = 0,
normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
describeIf("LM Studio Integration", () => {
it("server is reachable and lists models", async () => {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
expect(res.ok).toBe(true);
const body = await res.json();
expect(body.data).toBeDefined();
console.log(
"Loaded models:",
body.data.map((m: any) => m.id),
);
});
// ─── Embedder ────────────────────────────────────────────────────────
describe("LMStudioEmbedder (real server)", () => {
let embedder: LMStudioEmbedder;
let modelId: string;
beforeAll(async () => {
const models = await listModels();
if (!models.embedding) throw new Error("No embedding model loaded");
modelId = models.embedding;
embedder = new LMStudioEmbedder({
baseURL: LMSTUDIO_BASE_URL,
model: modelId,
});
});
it("embed() returns a numeric vector", async () => {
const vector = await embedder.embed("Hello world");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
vector.forEach((v) => expect(typeof v).toBe("number"));
console.log(` Model: ${modelId}, dimension: ${vector.length}`);
});
it("embed() produces identical output for newline-normalized text", async () => {
const v1 = await embedder.embed("hello world");
const v2 = await embedder.embed("hello\nworld");
expect(v1.length).toBe(v2.length);
const totalDiff = v1.reduce((s, val, i) => s + Math.abs(val - v2[i]), 0);
expect(totalDiff).toBeLessThan(0.001);
});
it("embedBatch() returns correct number of vectors", async () => {
const vectors = await embedder.embedBatch(["first", "second", "third"]);
expect(vectors).toHaveLength(3);
vectors.forEach((v) => {
expect(v.length).toBe(vectors[0].length);
v.forEach((val) => expect(typeof val).toBe("number"));
});
});
it("semantically similar texts have higher cosine similarity", async () => {
const [v1, v2, v3] = await Promise.all([
embedder.embed("I love hiking in the mountains"),
embedder.embed("I enjoy trekking through mountain trails"),
embedder.embed("The stock market crashed yesterday"),
]);
const simSimilar = cosineSim(v1, v2);
const simDifferent = cosineSim(v1, v3);
console.log(
` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`,
);
expect(Number.isFinite(simSimilar)).toBe(true);
expect(Number.isFinite(simDifferent)).toBe(true);
expect(simSimilar).toBeGreaterThan(simDifferent);
});
it("embed() handles empty string", async () => {
const vector = await embedder.embed("");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
it("embed() handles long text", async () => {
const longText = "This is a test sentence. ".repeat(200);
const vector = await embedder.embed(longText);
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
});
// ─── LLM ─────────────────────────────────────────────────────────────
describe("LMStudioLLM (real server)", () => {
let llm: LMStudioLLM;
let chatModelId: string | null;
beforeAll(async () => {
const models = await listModels();
chatModelId = models.chat;
if (!chatModelId) {
console.warn("No chat model loaded — LLM tests will be skipped");
return;
}
llm = new LMStudioLLM({ baseURL: LMSTUDIO_BASE_URL, model: chatModelId });
});
it("generateResponse() returns a response", async () => {
if (!chatModelId) return;
const result = await llm.generateResponse([
{ role: "user", content: "Say hello in exactly 3 words." },
]);
if (typeof result === "string") {
expect(result.length).toBeGreaterThan(0);
console.log(` Response (string): ${result.slice(0, 100)}`);
} else {
expect(result).toHaveProperty("content");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Response (object): ${result.content.slice(0, 100)}`);
}
});
it("generateChat() returns LLMResponse with content and role", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "What is 2+2?" },
]);
expect(result).toHaveProperty("content");
expect(result).toHaveProperty("role");
expect(result.role).toBe("assistant");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Chat: ${result.content.slice(0, 100)}`);
});
it("generateChat() handles multi-turn conversation", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "My name is Alice." },
{ role: "assistant", content: "Hello Alice!" },
{ role: "user", content: "What is my name?" },
]);
expect(result.content.length).toBeGreaterThan(0);
console.log(` Multi-turn: ${result.content.slice(0, 100)}`);
});
});
});
-114
View File
@@ -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");
});
});
-164
View File
@@ -1,164 +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 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
...overrides,
});
}
describe("Memory - add()", () => {
let memory: Memory;
const userId = `add_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("returns SearchResult with results array for string input", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
userId,
});
expect(Array.isArray(result.results)).toBe(true);
});
test("returns at least one result with an id", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
userId,
});
expect(result.results.length).toBeGreaterThan(0);
expect(result.results[0].id).toBeDefined();
});
test("result item has a memory string field", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
userId,
});
expect(typeof result.results[0].memory).toBe("string");
});
test("accepts Message[] input", async () => {
const messages = [
{ role: "user", content: "What is your favorite city?" },
{ role: "assistant", content: "I love Paris." },
];
const result: SearchResult = await memory.add(messages, { userId });
expect(result.results.length).toBeGreaterThan(0);
});
test("works with agentId filter instead of userId", async () => {
const result: SearchResult = await memory.add("test", {
agentId: "agent_1",
});
expect(result.results.length).toBeGreaterThan(0);
});
test("works with runId filter instead of userId", async () => {
const result: SearchResult = await memory.add("test", { runId: "run_1" });
expect(result.results.length).toBeGreaterThan(0);
});
test("throws when no userId/agentId/runId provided", async () => {
await expect(memory.add("test", {} as any)).rejects.toThrow(
"One of the filters: userId, agentId or runId is required!",
);
});
test("passes metadata through to stored memory", async () => {
const result: SearchResult = await memory.add("I love TypeScript", {
userId,
metadata: { source: "chat", tag: "programming" },
});
const stored: MemoryItem | null = await memory.get(result.results[0].id);
expect(stored).not.toBeNull();
expect(stored!.metadata).toEqual(
expect.objectContaining({ source: "chat", tag: "programming" }),
);
});
test("with infer=false skips LLM and stores messages directly", async () => {
const result: SearchResult = await memory.add("Direct storage content", {
userId,
infer: false,
});
expect(result.results.length).toBeGreaterThan(0);
// When infer=false, the literal message text is stored
expect(result.results[0].memory).toBe("Direct storage content");
});
test("with infer=false marks event as ADD in metadata", async () => {
const result: SearchResult = await memory.add("Direct fact", {
userId,
infer: false,
});
expect(result.results[0].metadata).toEqual(
expect.objectContaining({ event: "ADD" }),
);
});
});
-358
View File
@@ -1,358 +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,
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
});
}
// ─── get() ───────────────────────────────────────────────
describe("Memory - get()", () => {
let memory: Memory;
const userId = `get_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("returns the memory matching the ID from add()", async () => {
const addResult: SearchResult = await memory.add("I love AI", { userId });
const id = addResult.results[0].id;
const item: MemoryItem | null = await memory.get(id);
expect(item).not.toBeNull();
expect(item!.id).toBe(id);
});
test("returns a string for the memory field", async () => {
const addResult: SearchResult = await memory.add("Testing get", {
userId,
});
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
expect(typeof item!.memory).toBe("string");
});
test("returns null for non-existent ID", async () => {
const item = await memory.get("nonexistent-uuid-12345");
expect(item).toBeNull();
});
test("returns hash and createdAt on stored memory", async () => {
const addResult: SearchResult = await memory.add("Hash test", { userId });
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
expect(typeof item!.hash).toBe("string");
expect(item!.createdAt).toBeDefined();
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
});
});
// ─── update() ────────────────────────────────────────────
describe("Memory - update()", () => {
let memory: Memory;
const userId = `update_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
test("returns success message", async () => {
const addResult: SearchResult = await memory.add("Original", {
userId,
infer: false,
});
const id = addResult.results[0].id;
const result = await memory.update(id, "Updated");
expect(result.message).toBe("Memory updated successfully!");
});
test("persists the updated text", async () => {
const addResult: SearchResult = await memory.add("Before update", {
userId,
infer: false,
});
const id = addResult.results[0].id;
await memory.update(id, "After update");
const item: MemoryItem | null = await memory.get(id);
expect(item!.memory).toBe("After update");
});
test("preserves createdAt and sets updatedAt", async () => {
const addResult: SearchResult = await memory.add("Timestamp test", {
userId,
infer: false,
});
const id = addResult.results[0].id;
const before: MemoryItem | null = await memory.get(id);
const originalCreatedAt = before!.createdAt;
await memory.update(id, "New text");
const after: MemoryItem | null = await memory.get(id);
expect(after!.createdAt).toBe(originalCreatedAt);
expect(after!.updatedAt).toBeDefined();
});
test("updates the hash", async () => {
const addResult: SearchResult = await memory.add("Hash change", {
userId,
infer: false,
});
const id = addResult.results[0].id;
const before: MemoryItem | null = await memory.get(id);
await memory.update(id, "Completely different text");
const after: MemoryItem | null = await memory.get(id);
expect(after!.hash).not.toBe(before!.hash);
});
});
// ─── delete() ────────────────────────────────────────────
describe("Memory - delete()", () => {
let memory: Memory;
const userId = `delete_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("returns success message", async () => {
const addResult: SearchResult = await memory.add("Delete me", {
userId,
infer: false,
});
const result = await memory.delete(addResult.results[0].id);
expect(result.message).toBe("Memory deleted successfully!");
});
test("get() returns null after deletion", async () => {
const addResult: SearchResult = await memory.add("Temporary", {
userId,
infer: false,
});
const id = addResult.results[0].id;
await memory.delete(id);
expect(await memory.get(id)).toBeNull();
});
});
// ─── deleteAll() ─────────────────────────────────────────
describe("Memory - deleteAll()", () => {
let memory: Memory;
const userId = `deleteall_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("removes all memories for the user and returns success", async () => {
await memory.add("Fact A", { userId });
await memory.add("Fact B", { userId });
const result = await memory.deleteAll({ userId });
expect(result.message).toBe("Memories deleted successfully!");
const remaining: SearchResult = await memory.getAll({ userId });
expect(remaining.results).toHaveLength(0);
});
test("throws when no filter is provided", async () => {
await expect(memory.deleteAll({} as any)).rejects.toThrow(
"At least one filter is required",
);
});
});
// ─── getAll() ────────────────────────────────────────────
describe("Memory - getAll()", () => {
let memory: Memory;
const userId = `getall_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("returns all stored memories for the user", async () => {
await memory.add("First", { userId });
await memory.add("Second", { userId });
const result: SearchResult = await memory.getAll({ userId });
expect(Array.isArray(result.results)).toBe(true);
expect(result.results.length).toBeGreaterThanOrEqual(2);
});
test("each result has id and memory fields", async () => {
const result: SearchResult = await memory.getAll({ userId });
for (const item of result.results) {
expect(item.id).toBeDefined();
expect(typeof item.memory).toBe("string");
}
});
test("returns empty array when no memories exist", async () => {
const result: SearchResult = await memory.getAll({
userId: "no_such_user",
});
expect(result.results).toHaveLength(0);
});
});
// ─── search() ────────────────────────────────────────────
describe("Memory - search()", () => {
let memory: Memory;
const userId = `search_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
await memory.add("I love TypeScript", { userId });
});
afterAll(async () => {
await memory.reset();
});
test("returns SearchResult with results array", async () => {
const result: SearchResult = await memory.search("TypeScript", { userId });
expect(Array.isArray(result.results)).toBe(true);
});
test("returns results with score field", async () => {
const result: SearchResult = await memory.search("content", { userId });
if (result.results.length > 0) {
expect(typeof result.results[0].score).toBe("number");
}
});
test("throws when no userId/agentId/runId provided", async () => {
await expect(memory.search("query", {} as any)).rejects.toThrow(
"One of the filters: userId, agentId or runId is required!",
);
});
test("returns empty results for user with no memories", async () => {
const result: SearchResult = await memory.search("query", {
userId: "empty_user",
});
expect(result.results).toHaveLength(0);
});
});
// ─── history() ───────────────────────────────────────────
describe("Memory - history()", () => {
let memory: Memory;
const userId = `history_test_${Date.now()}`;
beforeAll(async () => {
memory = createMemory();
});
afterAll(async () => {
await memory.reset();
});
test("records ADD event after add()", async () => {
const addResult: SearchResult = await memory.add("New fact", { userId });
const history = await memory.history(addResult.results[0].id);
expect(Array.isArray(history)).toBe(true);
expect(history.length).toBeGreaterThan(0);
});
test("records additional entry after update()", async () => {
const addResult: SearchResult = await memory.add("Before", { userId });
const id = addResult.results[0].id;
await memory.update(id, "After");
const history = await memory.history(id);
expect(history.length).toBeGreaterThanOrEqual(2);
});
test("returns empty array for non-existent memory ID", async () => {
const history = await memory.history("nonexistent-id");
expect(history).toHaveLength(0);
});
});
-292
View File
@@ -1,292 +0,0 @@
/**
* OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder.
* Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts
*/
/// <reference types="jest" />
import { Memory } from "../src/memory";
import { MemoryItem, SearchResult } from "../src/types";
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
jest.setTimeout(30000);
// Mock LLM and embedder so tests run without API keys.
// Content-based mock: system-prompt calls → facts, user-only calls → memory actions.
jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() }));
jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() }));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({
facts: ["John is a software engineer"],
});
}
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "John is a software engineer",
old_memory: "",
new_memory: "John is a software engineer",
},
],
});
},
),
})),
}));
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embeddingDims: 1536,
})),
}));
describeOrSkip("Memory Class (E2E)", () => {
let memory: Memory;
const userId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
beforeEach(async () => {
memory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-memories", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
});
await memory.reset();
});
afterEach(async () => {
await memory.reset();
});
describe("add() single memory", () => {
let result: SearchResult;
beforeEach(async () => {
result = (await memory.add(
"Hi, my name is John and I am a software engineer.",
{ userId },
)) as SearchResult;
});
it("returns a defined result", () => {
expect(result).toBeDefined();
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
it("returns result with an id", () => {
expect(result.results[0]?.id).toBeDefined();
});
});
describe("add() multiple messages", () => {
let result: SearchResult;
beforeEach(async () => {
const messages = [
{ role: "user", content: "What is your favorite city?" },
{ role: "assistant", content: "I love Paris, it is my favorite city." },
];
result = (await memory.add(messages, { userId })) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
});
describe("get() single memory", () => {
let memoryItem: MemoryItem;
let memoryId: string;
beforeEach(async () => {
const addResult = (await memory.add(
"I am a big advocate of using AI to make the world a better place",
{ userId },
)) as SearchResult;
memoryId = addResult.results[0].id;
memoryItem = (await memory.get(memoryId)) as MemoryItem;
});
it("returns the correct id", () => {
expect(memoryItem.id).toBe(memoryId);
});
it("returns a string memory", () => {
expect(typeof memoryItem.memory).toBe("string");
});
});
describe("update() memory", () => {
let memoryId: string;
beforeEach(async () => {
const addResult = (await memory.add(
"I love speaking foreign languages especially Spanish",
{ userId },
)) as SearchResult;
memoryId = addResult.results[0].id;
});
it("returns success message", async () => {
const result = await memory.update(memoryId, "Updated content");
expect(result.message).toBe("Memory updated successfully!");
});
it("persists the updated content", async () => {
await memory.update(memoryId, "Updated content");
const updated = (await memory.get(memoryId)) as MemoryItem;
expect(updated.memory).toBe("Updated content");
});
});
describe("getAll() memories for user", () => {
let result: SearchResult;
beforeEach(async () => {
await memory.add("I love visiting new places in the winters", { userId });
await memory.add("I like to rule the world", { userId });
result = (await memory.getAll({ userId })) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least two results", () => {
expect(result.results.length).toBeGreaterThanOrEqual(2);
});
});
describe("search() memories", () => {
let result: SearchResult;
beforeEach(async () => {
await memory.add("I love programming in Python", { userId });
await memory.add("JavaScript is my favorite language", { userId });
result = (await memory.search("What programming languages do I know?", {
userId,
})) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
});
describe("history() of a memory", () => {
let history: unknown[];
beforeEach(async () => {
const addResult = (await memory.add("I like swimming in warm water", {
userId,
})) as SearchResult;
const memoryId = addResult.results[0].id;
await memory.update(memoryId, "Updated content");
history = await memory.history(memoryId);
});
it("returns an array", () => {
expect(Array.isArray(history)).toBe(true);
});
it("returns at least one entry", () => {
expect(history.length).toBeGreaterThan(0);
});
});
describe("delete() a memory", () => {
it("returns null after deletion", async () => {
const addResult = (await memory.add("I love to drink vodka in summers", {
userId,
})) as SearchResult;
const memoryId = addResult.results[0].id;
await memory.delete(memoryId);
const result = await memory.get(memoryId);
expect(result).toBeNull();
});
});
describe("Memory with Custom Configuration", () => {
let customMemory: Memory;
beforeEach(() => {
customMemory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-memories", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
});
});
afterEach(async () => {
await customMemory.reset();
});
it("add() returns results with custom config", async () => {
const result = (await customMemory.add("I love programming in Python", {
userId,
})) as SearchResult;
expect(result.results.length).toBeGreaterThan(0);
});
it("search() returns results with custom config", async () => {
await customMemory.add("The weather in London is rainy today", {
userId,
});
await customMemory.add("The temperature in Paris is 25 degrees", {
userId,
});
const result = (await customMemory.search("What is the weather like?", {
userId,
})) as SearchResult;
expect(result.results.length).toBeGreaterThan(0);
});
});
});
-125
View File
@@ -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);
});
});
});
-531
View File
@@ -1,531 +0,0 @@
/// <reference types="jest" />
/**
* End-to-end tests for Qdrant dimension mismatch fix.
*
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
*
* Skipped automatically when Qdrant is not available.
*
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { Qdrant } from "../src/vector_stores/qdrant";
import { v4 as uuidv4 } from "uuid";
jest.setTimeout(30000);
const QDRANT_HOST = "localhost";
const QDRANT_PORT = 6333;
// Check if Qdrant is reachable synchronously at load time using
// a sync check via child_process so describe.skip works correctly.
function isQdrantAvailable(): boolean {
try {
const { execSync } = require("child_process");
execSync(
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
{ timeout: 3000, stdio: "ignore" },
);
return true;
} catch {
return false;
}
}
const qdrantAvailable = isQdrantAvailable();
if (!qdrantAvailable) {
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
}
let qdrantClient: QdrantClient;
beforeAll(async () => {
if (!qdrantAvailable) return;
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
const collections = await qdrantClient.getCollections();
expect(collections).toBeDefined();
});
// Helper: delete a collection if it exists
async function deleteCollectionIfExists(name: string) {
try {
await qdrantClient.deleteCollection(name);
} catch {
// Collection doesn't exist — fine
}
}
// Helper: create a fake embedder that produces vectors of a given dimension
function createFakeEmbedder(dims: number) {
return {
embed: jest.fn().mockImplementation(async (_text: string) => {
const vec = new Array(dims).fill(0);
for (let i = 0; i < _text.length && i < dims; i++) {
vec[i] = _text.charCodeAt(i) / 255;
}
return vec;
}),
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
return Promise.all(
texts.map(async (t) => {
const vec = new Array(dims).fill(0);
for (let i = 0; i < t.length && i < dims; i++) {
vec[i] = t.charCodeAt(i) / 255;
}
return vec;
}),
);
}),
};
}
// Conditionally skip tests when Qdrant is unavailable
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
afterAll(async () => {
await deleteCollectionIfExists("e2e_test_768");
await deleteCollectionIfExists("e2e_test_1536");
await deleteCollectionIfExists("e2e_test_race");
await deleteCollectionIfExists("e2e_test_race2");
await deleteCollectionIfExists("e2e_test_noexplicit");
await deleteCollectionIfExists("e2e_test_explicit");
await deleteCollectionIfExists("e2e_test_embdims");
await deleteCollectionIfExists("e2e_test_autodetect");
await deleteCollectionIfExists("memory_migrations");
});
// ───────────────────────────────────────────────────────────────────────────
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
const collectionName = "e2e_test_1536";
await deleteCollectionIfExists(collectionName);
await qdrantClient.createCollection(collectionName, {
vectors: { size: 1536, distance: "Cosine" },
});
// Insert a 768-dim vector — this is what nomic-embed-text produces
const vector768 = new Array(768).fill(0.1);
try {
await qdrantClient.upsert(collectionName, {
points: [
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
],
});
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
} catch (error: any) {
// This is the exact "Bad Request" error users were hitting
expect(error.status).toBe(400);
}
await deleteCollectionIfExists(collectionName);
});
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
const collectionName = "e2e_test_768";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
// Create Qdrant store with correct dimension (what our auto-detect provides)
const store = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await store.initialize();
// Verify collection was created with 768 dims
const info = await qdrantClient.getCollection(collectionName);
expect(info.config?.params?.vectors?.size).toBe(768);
// Insert 768-dim vectors (what nomic-embed-text produces)
const vec1 = new Array(768).fill(0);
vec1[0] = 1.0;
const vec2 = new Array(768).fill(0);
vec2[1] = 1.0;
const id1 = uuidv4();
const id2 = uuidv4();
await store.insert(
[vec1, vec2],
[id1, id2],
[
{ data: "hello", userId: "u1" },
{ data: "world", userId: "u1" },
],
);
// Search with 768-dim query — this USED TO fail with Bad Request
const results = await store.search(vec1, 2, { userId: "u1" });
expect(results.length).toBe(2);
expect(results[0].id).toBe(id1); // Most similar to itself
expect(results[0].score).toBeGreaterThan(0.9);
// Get by ID
const item = await store.get(id1);
expect(item).not.toBeNull();
expect(item!.payload.data).toBe("hello");
// Update with 768-dim vector
const vec3 = new Array(768).fill(0);
vec3[2] = 1.0;
await store.update(id1, vec3, { data: "updated", userId: "u1" });
const updated = await store.get(id1);
expect(updated!.payload.data).toBe("updated");
// Delete
await store.delete(id2);
const deleted = await store.get(id2);
expect(deleted).toBeNull();
// List
const [listed, count] = await store.list({ userId: "u1" });
expect(count).toBe(1);
expect(listed[0].payload.data).toBe("updated");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
const collectionName = "e2e_test_autodetect";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
// Mock only the non-Qdrant factories to avoid Google SDK import crash
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
// Import Qdrant directly (avoids loading Google embedder via factory)
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
// This is the EXACT config from issue #4212 — NO dimension specified
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text" },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
// This triggers init — probe should detect 768 dims
await mem.getAll({ userId: "test-user" });
// Verify the probe was called
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
// Verify Qdrant collection was created with auto-detected 768 dims
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
// Search should work (this used to throw Bad Request)
const searchResult = await mem.search("hello world", {
userId: "test-user",
});
expect(searchResult).toBeDefined();
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
const collectionName = "e2e_test_explicit";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
// Workaround config from #4212 — explicit dimension
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text" },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
dimension: 768,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
await mem.getAll({ userId: "test-user" });
// Probe should NOT have been called
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
const collectionName = "e2e_test_embdims";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
await mem.getAll({ userId: "test-user" });
// Probe should NOT have been called — dimension inferred from embeddingDims
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 2. Reproduce #4056 issue 1: Collection creation race condition
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
const collectionName = "e2e_test_race";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
// Create 5 Qdrant instances concurrently — this simulates the race
// that caused "Collection memory_migrations already exists!" in #4056
const instances = Array.from(
{ length: 5 },
() =>
new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
}),
);
// All should initialize without throwing 409 Conflict
await Promise.all(instances.map((inst) => inst.initialize()));
// Verify collection exists with correct dimension
const info = await qdrantClient.getCollection(collectionName);
expect(info.config?.params?.vectors?.size).toBe(768);
// memory_migrations should also exist (created by initialize)
const migrInfo = await qdrantClient.getCollection("memory_migrations");
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
it("getUserId works after concurrent initialization", async () => {
const collectionName = "e2e_test_race2";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const instance = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await instance.initialize();
// getUserId should work without 409 crash
const userId = await instance.getUserId();
expect(typeof userId).toBe("string");
expect(userId.length).toBeGreaterThan(0);
// setUserId + getUserId roundtrip
await instance.setUserId("custom-e2e-user");
const updated = await instance.getUserId();
expect(updated).toBe("custom-e2e-user");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
});
// ───────────────────────────────────────────────────────────────────────────
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
const collectionName = "e2e_test_noexplicit";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const instance = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await instance.initialize();
// Allow Qdrant a moment to fully commit collections
await new Promise((r) => setTimeout(r, 500));
// Main collection should be 768
const mainInfo = await qdrantClient.getCollection(collectionName);
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
// memory_migrations should be 1 (NOT 768!)
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
const migrationsInfo =
await qdrantClient.getCollection("memory_migrations");
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
// getUserId should work — vector dim=1 in memory_migrations
const userId = await instance.getUserId();
expect(typeof userId).toBe("string");
// setUserId should also work
await instance.setUserId("custom-test-user");
const newUserId = await instance.getUserId();
expect(newUserId).toBe("custom-test-user");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
});
-427
View File
@@ -1,427 +0,0 @@
/// <reference types="jest" />
/**
* End-to-end tests for Redis vector store with init guard fix.
*
* Requires a running Redis Stack instance at localhost:6379.
* Skipped automatically when Redis is not available.
*
* Run: npx jest --config jest.config.js src/oss/tests/redis-e2e.test.ts --forceExit
*/
import { createClient } from "redis";
import { RedisDB } from "../src/vector_stores/redis";
import { v4 as uuidv4 } from "uuid";
jest.setTimeout(30000);
const REDIS_HOST = "localhost";
const REDIS_PORT = 6379;
const REDIS_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
const COLLECTION_NAME = "e2e_redis_test";
// Check if Redis is reachable synchronously at load time
function isRedisAvailable(): boolean {
try {
const { execSync } = require("child_process");
execSync(
`node -e "const s=require('net').createConnection({host:'${REDIS_HOST}',port:${REDIS_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
{ timeout: 3000, stdio: "ignore" },
);
return true;
} catch {
return false;
}
}
const redisAvailable = isRedisAvailable();
if (!redisAvailable) {
console.warn("Redis not available at localhost:6379 — skipping e2e tests");
}
// Standalone client for cleanup
let cleanupClient: ReturnType<typeof createClient>;
async function cleanupRedis() {
if (!redisAvailable) return;
try {
// Drop the index if it exists
await cleanupClient.ft.dropIndex(COLLECTION_NAME);
} catch {
// Index doesn't exist — fine
}
// Delete all keys with our prefix
const keys = await cleanupClient.keys(`mem0:${COLLECTION_NAME}:*`);
if (keys.length > 0) {
await cleanupClient.del(keys);
}
// Clean up memory_migrations key
await cleanupClient.del("memory_migrations:1");
}
beforeAll(async () => {
if (!redisAvailable) return;
cleanupClient = createClient({ url: REDIS_URL });
await cleanupClient.connect();
// Verify Redis Stack is running with search module
const modules = (await cleanupClient.moduleList()) as unknown as any[];
const hasSearch = modules.some((mod: any[]) => {
const moduleMap = new Map();
for (let i = 0; i < mod.length; i += 2) {
moduleMap.set(mod[i], mod[i + 1]);
}
return moduleMap.get("name")?.toLowerCase() === "search";
});
expect(hasSearch).toBe(true);
});
afterAll(async () => {
if (!redisAvailable) return;
await cleanupRedis();
await cleanupClient.quit();
});
// Conditionally skip tests when Redis is unavailable
const describeIfRedis = redisAvailable ? describe : describe.skip;
// ───────────────────────────────────────────────────────────────────────────
// 1. Basic initialization and idempotent init guard
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: initialization", () => {
afterEach(async () => {
await cleanupRedis();
});
it("initializes successfully and creates index", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 128,
});
await store.initialize();
// Verify the index was created by querying index info
const info = await cleanupClient.ft.info(COLLECTION_NAME);
expect(info).toBeDefined();
expect(info.indexName).toBe(COLLECTION_NAME);
await store.close();
});
it("idempotent initialize() — multiple calls don't crash", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 128,
});
// Call initialize multiple times concurrently
await Promise.all([
store.initialize(),
store.initialize(),
store.initialize(),
]);
// Should still work fine
const info = await cleanupClient.ft.info(COLLECTION_NAME);
expect(info).toBeDefined();
await store.close();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 2. Full CRUD operations
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: CRUD operations", () => {
let store: RedisDB;
beforeEach(async () => {
await cleanupRedis();
store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 4, // Small dims for testing
});
await store.initialize();
});
afterEach(async () => {
await store.close();
await cleanupRedis();
});
it("insert and search vectors", async () => {
const id1 = uuidv4();
const id2 = uuidv4();
const vec1 = [1.0, 0.0, 0.0, 0.0];
const vec2 = [0.0, 1.0, 0.0, 0.0];
await store.insert(
[vec1, vec2],
[id1, id2],
[
{
data: "hello world",
hash: "h1",
userId: "user1",
createdAt: new Date().toISOString(),
},
{
data: "goodbye world",
hash: "h2",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Search — vec1 should be most similar to itself
const results = await store.search(vec1, 2, { userId: "user1" });
expect(results.length).toBe(2);
// The first result should be closest to the query
expect(results[0].id).toBe(id1);
expect(results[0].score).toBeDefined();
expect(results[0].payload).toBeDefined();
});
it("get vector by ID", async () => {
const id = uuidv4();
const vec = [0.5, 0.5, 0.0, 0.0];
await store.insert(
[vec],
[id],
[
{
data: "test memory",
hash: "h-test",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.id).toBe(id);
expect(result!.payload.data).toBe("test memory");
expect(result!.payload.hash).toBe("h-test");
});
it("get non-existent vector returns null", async () => {
const result = await store.get("non-existent-id");
expect(result).toBeNull();
});
it("update vector and payload", async () => {
const id = uuidv4();
const vec = [1.0, 0.0, 0.0, 0.0];
await store.insert(
[vec],
[id],
[
{
data: "original",
hash: "h-orig",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Update with new vector and payload
const newVec = [0.0, 0.0, 1.0, 0.0];
await store.update(id, newVec, {
data: "updated memory",
hash: "h-updated",
userId: "user1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.payload.data).toBe("updated memory");
expect(result!.payload.hash).toBe("h-updated");
});
it("delete vector", async () => {
const id = uuidv4();
const vec = [0.0, 0.0, 0.0, 1.0];
await store.insert(
[vec],
[id],
[
{
data: "to be deleted",
hash: "h-del",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Verify it exists
const before = await store.get(id);
expect(before).not.toBeNull();
// Delete
await store.delete(id);
// Verify it's gone
const after = await store.get(id);
expect(after).toBeNull();
});
it("list vectors with filters", async () => {
const id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
await store.insert(
[
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
],
[id1, id2, id3],
[
{
data: "mem1",
hash: "h1",
userId: "usera",
createdAt: new Date().toISOString(),
},
{
data: "mem2",
hash: "h2",
userId: "usera",
createdAt: new Date().toISOString(),
},
{
data: "mem3",
hash: "h3",
userId: "userb",
createdAt: new Date().toISOString(),
},
],
);
// List all
const [all, allCount] = await store.list();
expect(allCount).toBe(3);
expect(all.length).toBe(3);
// List with filter
const [filtered, filteredCount] = await store.list({
userId: "usera",
});
expect(filteredCount).toBe(2);
expect(filtered.length).toBe(2);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 3. getUserId / setUserId
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: getUserId / setUserId", () => {
let store: RedisDB;
beforeEach(async () => {
await cleanupRedis();
store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 4,
});
await store.initialize();
});
afterEach(async () => {
await store.close();
await cleanupRedis();
});
it("getUserId generates random ID if none exists", async () => {
const userId = await store.getUserId();
expect(typeof userId).toBe("string");
expect(userId.length).toBeGreaterThan(0);
});
it("setUserId + getUserId roundtrip", async () => {
await store.setUserId("custom-redis-user");
const retrieved = await store.getUserId();
expect(retrieved).toBe("custom-redis-user");
});
it("getUserId returns same value on subsequent calls", async () => {
const first = await store.getUserId();
const second = await store.getUserId();
expect(first).toBe(second);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 4. Dimension handling (our fix ensures correct dims from Memory)
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: dimension handling", () => {
afterEach(async () => {
await cleanupRedis();
});
it("creates index with correct dimensions from config", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 768,
});
await store.initialize();
// Verify the index has the right dimension in its schema
const info = await cleanupClient.ft.info(COLLECTION_NAME);
// Check that the vector field has DIM=768
const attributes = info.attributes as any[];
const vectorAttr = attributes.find(
(a: any) => a.identifier === "embedding" || a.attribute === "embedding",
);
expect(vectorAttr).toBeDefined();
await store.close();
});
it("insert with matching dimension succeeds", async () => {
const dims = 128;
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: dims,
});
await store.initialize();
const id = uuidv4();
const vec = new Array(dims).fill(0.1);
await store.insert(
[vec],
[id],
[
{
data: "test",
hash: "h1",
createdAt: new Date().toISOString(),
},
],
);
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.id).toBe(id);
await store.close();
});
});
@@ -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,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 -1
View File
@@ -7,7 +7,7 @@ const external = [
"uuid",
"pg",
"zod",
"better-sqlite3",
"sqlite3",
"@qdrant/js-client-rest",
"redis",
];
+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.",
)
+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}")
+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
-43
View File
@@ -1,43 +0,0 @@
# Changelog
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [0.3.1] - 2026-03-12
### Fixed
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
## [0.3.0] - 2026-03-10
### Fixed
- 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
+10 -50
View File
@@ -7,7 +7,7 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
## How it works
<p align="center">
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
<img src="../docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
</p>
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
@@ -30,46 +30,12 @@ The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"s
All new parameters are optional and backward-compatible — existing configurations work without changes.
### Per-agent memory isolation
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
**How it works:**
- The agent's session key is inspected on every recall/capture cycle
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
- Different agents never see each other's memories unless explicitly queried
**Explicit cross-agent queries:**
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
```
memory_search({ query: "user's tech stack", agentId: "researcher" })
```
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
## Setup
```bash
openclaw plugins install @mem0/openclaw-mem0
```
### 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.
### Platform (Mem0 Cloud)
Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`:
@@ -80,7 +46,7 @@ Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `opencl
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
@@ -94,7 +60,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"
}
}
```
@@ -121,11 +87,11 @@ The agent gets five tools it can call during conversations:
| Tool | Description |
|------|-------------|
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
| `memory_search` | Search memories by natural language |
| `memory_list` | List all stored memories for a user |
| `memory_store` | Explicitly save a fact |
| `memory_get` | Retrieve a memory by ID |
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
| `memory_forget` | Delete by ID or by query |
## CLI
@@ -141,12 +107,6 @@ openclaw mem0 search "what languages does the user know" --scope session
# Stats
openclaw mem0 stats
# Search a specific agent's memories
openclaw mem0 search "user preferences" --agent researcher
# Stats for a specific agent
openclaw mem0 stats --agent researcher
```
## Options
@@ -156,7 +116,7 @@ openclaw mem0 stats --agent researcher
| Key | Type | Default | |
|-----|------|---------|---|
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
| `userId` | `string` | `"default"` | Any unique identifier you choose for the user (e.g. `"alice"`, `"user_123"`). All memories are scoped to this value. Not found in any dashboard — you define it yourself. |
| `userId` | `string` | `"default"` | Scope memories per user |
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
| `topK` | `number` | `5` | Max memories per recall |
@@ -180,11 +140,11 @@ Works with zero extra config. The `oss` block lets you swap out any component:
| Key | Type | Default | |
|-----|------|---------|---|
| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing |
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) |
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) |
| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` |
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) |
| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` |
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) |
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
-165
View File
@@ -1,165 +0,0 @@
/**
* Regression tests for per-agent memory isolation helpers.
*
* Addresses review feedback: targeted coverage for auth/session state,
* malformed input, and the resolveUserId priority chain.
*/
import { describe, it, expect } from "vitest";
import {
extractAgentId,
effectiveUserId,
agentUserId,
resolveUserId,
} from "./index.ts";
// ---------------------------------------------------------------------------
// extractAgentId
// ---------------------------------------------------------------------------
describe("extractAgentId", () => {
it("returns agentId from a well-formed session key", () => {
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
});
it("returns undefined for the 'main' sentinel", () => {
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
});
it("returns undefined for undefined/null/empty input", () => {
expect(extractAgentId(undefined)).toBeUndefined();
expect(extractAgentId("")).toBeUndefined();
});
it("returns undefined for non-agent session keys", () => {
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
expect(extractAgentId("some-random-uuid")).toBeUndefined();
});
it("handles keys with extra colons after the UUID portion", () => {
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
});
it("returns undefined when agentId segment is empty", () => {
// pattern: agent::<uuid> — empty agentId
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
});
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
expect(extractAgentId("agent:")).toBeUndefined();
});
it("is case-sensitive (Agent != agent)", () => {
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
});
it("handles whitespace-only agentId as truthy string", () => {
// " " is a non-empty match — returned as-is (validation is caller's job)
expect(extractAgentId("agent: :uuid")).toBe(" ");
});
});
// ---------------------------------------------------------------------------
// effectiveUserId
// ---------------------------------------------------------------------------
describe("effectiveUserId", () => {
const base = "alice";
it("returns base userId when sessionKey is undefined", () => {
expect(effectiveUserId(base)).toBe("alice");
expect(effectiveUserId(base, undefined)).toBe("alice");
});
it("returns namespaced userId for agent session keys", () => {
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
"alice:agent:researcher",
);
});
it("falls back to base for 'main' agent sessions", () => {
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
});
it("falls back to base for non-agent session keys", () => {
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// agentUserId
// ---------------------------------------------------------------------------
describe("agentUserId", () => {
it("produces the correct namespaced format", () => {
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
});
it("handles empty agentId (caller is responsible for validation)", () => {
expect(agentUserId("alice", "")).toBe("alice:agent:");
});
});
// ---------------------------------------------------------------------------
// resolveUserId — priority chain
// ---------------------------------------------------------------------------
describe("resolveUserId", () => {
const base = "alice";
it("prefers explicit agentId over everything else", () => {
expect(
resolveUserId(
base,
{ agentId: "researcher", userId: "bob" },
"agent:beta:uuid",
),
).toBe("alice:agent:researcher");
});
it("uses explicit userId when agentId is absent", () => {
expect(
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
).toBe("bob");
});
it("derives from session key when both agentId and userId are absent", () => {
expect(
resolveUserId(base, {}, "agent:gamma:uuid"),
).toBe("alice:agent:gamma");
});
it("falls back to base userId when nothing else is provided", () => {
expect(resolveUserId(base, {})).toBe("alice");
expect(resolveUserId(base, {}, undefined)).toBe("alice");
});
it("ignores empty-string agentId (falsy)", () => {
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
});
it("ignores empty-string userId (falsy)", () => {
expect(resolveUserId(base, { userId: "" })).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// Cross-agent isolation sanity checks
// ---------------------------------------------------------------------------
describe("multi-agent isolation", () => {
const base = "user-42";
it("different agents get different namespaces", () => {
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
expect(alphaId).not.toBe(betaId);
expect(alphaId).toBe("user-42:agent:alpha");
expect(betaId).toBe("user-42:agent:beta");
});
it("same agent across sessions yields the same namespace", () => {
const s1 = effectiveUserId(base, "agent:alpha:session-1");
const s2 = effectiveUserId(base, "agent:alpha:session-2");
expect(s1).toBe(s2);
});
it("main session shares the base namespace (no isolation)", () => {
const mainId = effectiveUserId(base, "agent:main:uuid-m");
expect(mainId).toBe(base);
});
});
+36 -158
View File
@@ -10,8 +10,6 @@
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
* - CLI: openclaw mem0 search, openclaw mem0 stats
* - Dual mode: platform or open-source (self-hosted)
*/
@@ -41,7 +39,6 @@ type Mem0Config = {
vectorStore?: { provider: string; config: Record<string, unknown> };
llm?: { provider: string; config: Record<string, unknown> };
historyDbPath?: string;
disableHistory?: boolean;
};
// Shared
userId: string;
@@ -133,16 +130,13 @@ class PlatformProvider implements Mem0Provider {
private async ensureClient(): Promise<void> {
if (this.client) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
this.initPromise = this._init();
return this.initPromise;
}
private async _init(): Promise<void> {
const { default: MemoryClient } = await import("mem0ai");
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
const opts: Record<string, string> = { apiKey: this.apiKey };
if (this.orgId) opts.org_id = this.orgId;
if (this.projectId) opts.project_id = this.projectId;
this.client = new MemoryClient(opts);
@@ -169,17 +163,13 @@ class PlatformProvider implements Mem0Provider {
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
await this.ensureClient();
const filters: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) filters.run_id = options.run_id;
const opts: Record<string, unknown> = {
api_version: "v2",
filters,
};
const opts: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) opts.run_id = options.run_id;
if (options.top_k != null) opts.top_k = options.top_k;
if (options.threshold != null) opts.threshold = options.threshold;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.rerank = options.reranking;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
const results = await this.client.search(query, opts);
return normalizeSearchResults(results);
@@ -229,10 +219,7 @@ class OSSProvider implements Mem0Provider {
private async ensureMemory(): Promise<void> {
if (this.memory) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
this.initPromise = this._init();
return this.initPromise;
}
@@ -253,30 +240,9 @@ class OSSProvider implements Mem0Provider {
config.historyDbPath = dbPath;
}
if (this.ossConfig?.disableHistory) {
config.disableHistory = true;
}
if (this.customPrompt) config.customPrompt = this.customPrompt;
try {
this.memory = new Memory(config);
} catch (err) {
// If initialization fails (e.g. native SQLite binding resolution under
// jiti), retry with history disabled — the history DB is the most common
// source of native-binding failures and is not required for core
// memory operations.
if (!config.disableHistory) {
console.warn(
"[mem0] Memory initialization failed, retrying with history disabled:",
err instanceof Error ? err.message : err,
);
config.disableHistory = true;
this.memory = new Memory(config);
} else {
throw err;
}
}
this.memory = new Memory(config);
}
async add(
@@ -549,7 +515,7 @@ function assertAllowedKeys(
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export const mem0ConfigSchema = {
const mem0ConfigSchema = {
parse(value: unknown): Mem0Config {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("openclaw-mem0 config required");
@@ -615,7 +581,7 @@ export const mem0ConfigSchema = {
// Provider Factory
// ============================================================================
export function createProvider(
function createProvider(
cfg: Mem0Config,
api: OpenClawPluginApi,
): Mem0Provider {
@@ -639,50 +605,6 @@ function categoriesToArray(
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
}
// ============================================================================
// Per-agent isolation helpers (exported for testability)
// ============================================================================
/**
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
const match = sessionKey.match(/^agent:([^:]+):/);
const agentId = match?.[1];
// "main" is the primary session — fall back to configured userId
if (!agentId || agentId === "main") return undefined;
return agentId;
}
/**
* Derive the effective user_id from a session key, namespacing per-agent.
* Falls back to baseUserId when the session is not agent-scoped.
*/
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
const agentId = extractAgentId(sessionKey);
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
}
/** Build a user_id for an explicit agentId (e.g. from tool params). */
export function agentUserId(baseUserId: string, agentId: string): string {
return `${baseUserId}:agent:${agentId}`;
}
/**
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
*/
export function resolveUserId(
baseUserId: string,
opts: { agentId?: string; userId?: string },
currentSessionId?: string,
): string {
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
if (opts.userId) return opts.userId;
return effectiveUserId(baseUserId, currentSessionId);
}
// ============================================================================
// Plugin Definition
// ============================================================================
@@ -702,23 +624,14 @@ const memoryPlugin = {
// Track current session ID for tool-level session scoping
let currentSessionId: string | undefined;
// ========================================================================
// Per-agent isolation helpers (thin wrappers around exported functions)
// ========================================================================
const _effectiveUserId = (sessionKey?: string) =>
effectiveUserId(cfg.userId, sessionKey);
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
// Helper: build add options
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
const opts: AddOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
user_id: userIdOverride || cfg.userId,
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
@@ -736,10 +649,9 @@ const memoryPlugin = {
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const opts: SearchOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
user_id: userIdOverride || cfg.userId,
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: cfg.searchThreshold,
@@ -774,12 +686,6 @@ const memoryPlugin = {
"User ID to scope search (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -792,41 +698,39 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { query, limit, userId, agentId, scope = "all" } = params as {
const { query, limit, userId, scope = "all" } = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
};
try {
let results: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(userId, limit, currentSessionId),
);
}
} else if (scope === "long-term") {
results = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(userId, limit),
);
} else {
// "all" — search both scopes and combine
const longTermResults = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(userId, limit),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(userId, limit, currentSessionId),
);
}
// Deduplicate by ID, preferring long-term
@@ -899,12 +803,6 @@ const memoryPlugin = {
description: "User ID to scope this memory",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.",
}),
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Optional metadata to attach to this memory",
@@ -918,20 +816,18 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { text, userId, agentId, longTerm = true } = params as {
const { text, userId, longTerm = true } = params as {
text: string;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
};
try {
const uid = _resolveUserId({ agentId, userId });
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
const result = await provider.add(
[{ role: "user", content: text }],
buildAddOptions(uid, runId, currentSessionId),
buildAddOptions(userId, runId),
);
const added =
@@ -1023,7 +919,7 @@ const memoryPlugin = {
name: "memory_list",
label: "Memory List",
description:
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
"List all stored memories for a user. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
parameters: Type.Object({
userId: Type.Optional(
Type.String({
@@ -1031,12 +927,6 @@ const memoryPlugin = {
"User ID to list memories for (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -1049,11 +939,11 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
try {
let memories: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
const uid = userId || cfg.userId;
if (scope === "session") {
if (currentSessionId) {
@@ -1136,7 +1026,7 @@ const memoryPlugin = {
name: "memory_forget",
label: "Memory Forget",
description:
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(
Type.String({
@@ -1146,18 +1036,11 @@ const memoryPlugin = {
memoryId: Type.Optional(
Type.String({ description: "Specific memory ID to delete" }),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
}),
),
}),
async execute(_toolCallId, params) {
const { query, memoryId, agentId } = params as {
const { query, memoryId } = params as {
query?: string;
memoryId?: string;
agentId?: string;
};
try {
@@ -1172,10 +1055,9 @@ const memoryPlugin = {
}
if (query) {
const uid = _resolveUserId({ agentId });
const results = await provider.search(
query,
buildSearchOptions(uid, 5),
buildSearchOptions(undefined, 5),
);
if (!results || results.length === 0) {
@@ -1266,12 +1148,10 @@ const memoryPlugin = {
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", String(cfg.topK))
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
.option("--agent <agentId>", "Search a specific agent's memory namespace")
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
.action(async (query: string, opts: { limit: string; scope: string }) => {
try {
const limit = parseInt(opts.limit, 10);
const scope = opts.scope as "session" | "long-term" | "all";
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
let allResults: MemoryItem[] = [];
@@ -1279,7 +1159,7 @@ const memoryPlugin = {
if (currentSessionId) {
const sessionResults = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(undefined, limit, currentSessionId),
);
if (sessionResults?.length) {
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
@@ -1293,7 +1173,7 @@ const memoryPlugin = {
if (scope === "long-term" || scope === "all") {
const longTermResults = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(undefined, limit),
);
if (longTermResults?.length) {
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
@@ -1332,16 +1212,14 @@ const memoryPlugin = {
mem0
.command("stats")
.description("Show memory statistics from Mem0")
.option("--agent <agentId>", "Show stats for a specific agent")
.action(async (opts: { agent?: string }) => {
.action(async () => {
try {
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
const memories = await provider.getAll({
user_id: uid,
user_id: cfg.userId,
source: "OPENCLAW",
});
console.log(`Mode: ${cfg.mode}`);
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
console.log(`User: ${cfg.userId}`);
console.log(
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
);
@@ -1371,10 +1249,10 @@ const memoryPlugin = {
if (sessionId) currentSessionId = sessionId;
try {
// Search long-term memories (user-scoped, isolated per agent)
// Search long-term memories (user-scoped)
const longTermResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, undefined, sessionId),
buildSearchOptions(),
);
// Search session memories (session-scoped) if we have a session ID
@@ -1382,7 +1260,7 @@ const memoryPlugin = {
if (currentSessionId) {
sessionResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
buildSearchOptions(undefined, undefined, currentSessionId),
);
}
@@ -1487,7 +1365,7 @@ const memoryPlugin = {
if (formattedMessages.length === 0) return;
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
const addOpts = buildAddOptions(undefined, currentSessionId);
const result = await provider.add(
formattedMessages,
addOpts,
-30
View File
@@ -1,30 +0,0 @@
declare module "openclaw/plugin-sdk" {
export interface OpenClawPluginApi {
pluginConfig: Record<string, unknown>;
logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
debug(msg: string): void;
};
resolvePath(p: string): string;
registerTool(
definition: Record<string, unknown>,
metadata?: Record<string, unknown>,
): void;
on(
event: string,
handler: (event: any, ctx: any) => any,
): void;
registerCli(
handler: (context: { program: any }) => void,
options?: Record<string, unknown>,
): void;
registerService(service: {
id: string;
start: () => void;
stop: () => void;
}): void;
[key: string]: unknown;
}
}
+3 -26
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "0.3.3",
"version": "0.1.2",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -11,36 +11,13 @@
"mem0",
"long-term-memory"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"openclaw.plugin.json"
],
"scripts": {
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "^2.2.1"
},
"openclaw": {
"extensions": [
"./dist/index.js"
"./index.ts"
]
},
"devDependencies": {
"@types/node": "^22.15.0",
"@vitest/coverage-v8": "^4.0.18",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}
}
-4222
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
packages:
- '.'
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- protobufjs
-288
View File
@@ -1,288 +0,0 @@
/**
* Tests for SQLite resilience fixes:
* 1. disableHistory config passthrough
* 2. initPromise poisoning fix (retry after failure)
* 3. Graceful SQLite fallback in OSSProvider
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mem0ConfigSchema, createProvider } from "./index.ts";
// ---------------------------------------------------------------------------
// 1. Config: disableHistory passthrough
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema — disableHistory", () => {
const baseConfig = {
mode: "open-source",
oss: {
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
},
};
it("preserves oss.disableHistory: true through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
});
expect(cfg.oss?.disableHistory).toBe(true);
});
it("preserves oss.disableHistory: false through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: false },
});
expect(cfg.oss?.disableHistory).toBe(false);
});
it("omits disableHistory when not provided", () => {
const cfg = mem0ConfigSchema.parse(baseConfig);
expect(cfg.oss?.disableHistory).toBeUndefined();
});
it("does not reject unknown keys inside oss object", () => {
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
expect(() =>
mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
}),
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// 2. OSSProvider: disableHistory flows to Memory constructor
// ---------------------------------------------------------------------------
describe("OSSProvider — disableHistory passthrough to Memory", () => {
let capturedConfig: Record<string, unknown> | undefined;
let memoryCallCount: number;
beforeEach(() => {
capturedConfig = undefined;
memoryCallCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
memoryCallCount++;
capturedConfig = { ...config };
}
async add() { return { results: [] }; }
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async delete() { }
},
}));
});
it("passes disableHistory: true to Memory when configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Trigger lazy init by calling search
try {
await provider.search("test", { user_id: "u1" });
} catch { /* provider may fail on mock, that's ok */ }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBe(true);
});
it("does not set disableHistory when not configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
try {
await provider.search("test", { user_id: "u1" });
} catch { }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
// ---------------------------------------------------------------------------
describe("OSSProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
}
// Second+ call succeeds
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call: _init throws, but initPromise is cleared so retry is possible
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("SQLITE_CANTOPEN");
// Second call: should retry _init (not return cached rejection)
// callCount === 1 threw, so callCount === 2 should succeed
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
// ---------------------------------------------------------------------------
// 4. OSSProvider: graceful fallback disables history on init failure
// ---------------------------------------------------------------------------
describe("OSSProvider — graceful SQLite fallback", () => {
let capturedConfigs: Record<string, unknown>[];
beforeEach(() => {
capturedConfigs = [];
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfigs.push({ ...config });
if (!config.disableHistory) {
throw new Error("Could not locate the bindings file");
}
// Succeeds when disableHistory is true
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries with disableHistory: true when initial construction fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should succeed — first attempt fails, fallback with disableHistory succeeds
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
// Memory constructor was called twice
expect(capturedConfigs).toHaveLength(2);
expect(capturedConfigs[0].disableHistory).toBeFalsy();
expect(capturedConfigs[1].disableHistory).toBe(true);
// Warning was logged
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[mem0] Memory initialization failed"),
expect.stringContaining("bindings file"),
);
warnSpy.mockRestore();
});
it("does not retry when disableHistory is already true", async () => {
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
// Fail even with disableHistory (e.g. vector store issue)
throw new Error("vector store connection refused");
}
},
}));
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should throw — no fallback possible when disableHistory was already set
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("vector store connection refused");
});
});
// ---------------------------------------------------------------------------
// 5. PlatformProvider — initPromise retry after failure
// ---------------------------------------------------------------------------
describe("PlatformProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai", () => ({
default: class MockMemoryClient {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("Network timeout");
}
}
async search() { return []; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "platform",
apiKey: "test-api-key",
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call fails
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("Network timeout");
// Second call should retry (not return cached rejection)
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": ".",
"strict": false,
"noImplicitAny": false,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
});
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.6"
version = "1.0.4"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
@@ -20,7 +20,7 @@ dependencies = [
"posthog>=3.5.0",
"pytz>=2024.1",
"sqlalchemy>=2.0.31",
"protobuf>=5.29.6,<7.0.0",
"protobuf>=5.29.0,<6.0.0",
]
[project.optional-dependencies]
-189
View File
@@ -1,189 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Mem0.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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