Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a62dbb1f07 | |||
| 88abb29de9 | |||
| 66e6f58fc6 | |||
| 22c2545d61 | |||
| 410b79c750 | |||
| 08de18f860 | |||
| 46b4b2e9c8 | |||
| a029cc9d43 | |||
| 348f44b632 | |||
| 0c4d0290cb | |||
| b971b61cbb | |||
| ffd1b96916 | |||
| 4ffe1eaa4e | |||
| a172de9c22 | |||
| 7539463f50 | |||
| 577a5a2feb | |||
| d7a34c24dd | |||
| 214d2a1d0d | |||
| f0eb9e091f | |||
| 3cdcb6564c | |||
| 336fbce60a | |||
| 9eb5b9ed29 | |||
| 8230a5dac7 | |||
| 9864584c21 | |||
| 9eea060db9 | |||
| 15218d4a7f | |||
| 69001d7b1f | |||
| 35fe30aabd | |||
| 11a7d8378c | |||
| b4b73deada | |||
| bfe730aa38 | |||
| 82d67430dd | |||
| 8fcf2b0b29 | |||
| 2e5e290434 |
@@ -0,0 +1,100 @@
|
||||
name: openclaw checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd openclaw && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd openclaw && pnpm exec vitest run --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.node-version == 20
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
flags: openclaw
|
||||
directory: openclaw/coverage
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd openclaw && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -0,0 +1,110 @@
|
||||
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 unit tests
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run test:unit
|
||||
|
||||
- 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/
|
||||
|
||||
integration_ts_sdk:
|
||||
needs: build_ts_sdk
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 1
|
||||
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: Build
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run integration tests (with cleanup)
|
||||
working-directory: mem0-ts
|
||||
env:
|
||||
MEM0_API_KEY: ${{ secrets.MEM0_API_KEY }}
|
||||
run: pnpm run test:integration
|
||||
@@ -20,8 +20,8 @@ Mem0 provides a comprehensive REST API for integrating advanced memory capabilit
|
||||
Get started with Mem0 API in three simple steps:
|
||||
|
||||
1. **[Add Memories](/api-reference/memory/add-memories)** - Store information and context from user conversations
|
||||
2. **[Search Memories](/api-reference/memory/v2-search-memories)** - Retrieve relevant memories using semantic search
|
||||
3. **[Get Memories](/api-reference/memory/v2-get-memories)** - Fetch all memories for a specific entity
|
||||
2. **[Search Memories](/api-reference/memory/search-memories)** - Retrieve relevant memories using semantic search
|
||||
3. **[Get Memories](/api-reference/memory/get-memories)** - Fetch all memories for a specific entity
|
||||
|
||||
---
|
||||
|
||||
@@ -32,7 +32,7 @@ Get started with Mem0 API in three simple steps:
|
||||
Store new memories from conversations and interactions
|
||||
</Card>
|
||||
|
||||
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/v2-search-memories">
|
||||
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/search-memories">
|
||||
Find relevant memories using semantic search with filters
|
||||
</Card>
|
||||
|
||||
@@ -102,7 +102,7 @@ Get your API key from the [Mem0 Dashboard](https://app.mem0.ai/dashboard/api-key
|
||||
Start storing memories via the REST API
|
||||
</Card>
|
||||
|
||||
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/v2-search-memories">
|
||||
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/search-memories">
|
||||
Learn advanced search and filtering techniques
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -7,6 +7,22 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-03-16" description="v1.0.6">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Fixed telemetry vector store initialization still running when `MEM0_TELEMETRY` is disabled (#4351)
|
||||
- **Core:** Removed destructive `vector_store.reset()` call from `delete_all()` that was wiping the entire vector store instead of deleting only the target memories (#4349)
|
||||
- **OSS:** `OllamaLLM` now respects the configured URL instead of always falling back to localhost (#4320)
|
||||
- **Core:** Fixed `KeyError` when LLM omits the `entities` key in tool call response (#4313)
|
||||
- **Prompts:** Ensured JSON instruction is included in prompts when using `json_object` response format (#4271)
|
||||
- **Core:** Fixed incorrect database parameter handling (#3913)
|
||||
|
||||
**Dependencies:**
|
||||
- Updated LangChain dependencies to v1.0.0 (#4353)
|
||||
- Bumped protobuf dependency to 5.29.6 and extended upper bound to `<7.0.0` (#4326)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-03" description="v1.0.5">
|
||||
- **Telemetry Fix**
|
||||
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
|
||||
@@ -729,6 +745,16 @@ mode: "wide"
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-03-16" description="v2.4.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Fixed code block content extraction — content inside code blocks is now properly extracted instead of being deleted (#4317)
|
||||
|
||||
**Improvements:**
|
||||
- **Code Quality:** Fixed linting issues across the SDK (#4334)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-14" description="v2.4.0">
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
---
|
||||
title: MiroFish Swarm Memory
|
||||
description: "Build a multi-agent swarm simulation with graph-powered memory using Mem0 and MiroFish patterns."
|
||||
---
|
||||
|
||||
<Snippet file="blank-notif.mdx" />
|
||||
|
||||
Build a multi-agent swarm simulation with graph-powered memory using Mem0 OSS and [MiroFish](https://github.com/666ghj/MiroFish) patterns. MiroFish is a graph-centric system — it extracts entities and relationships from documents, builds a knowledge graph, and queries it throughout its pipeline. Mem0's Graph Memory is a natural replacement for its Zep Cloud integration.
|
||||
|
||||
<Note>
|
||||
This cookbook demonstrates the **core memory patterns** using a simplified simulation. MiroFish's actual architecture uses a factory pattern (`memory_factory.py`) with abstract providers, batch buffering with retries in `ZepGraphMemoryUpdater`, and IPC-based agent interviews. This cookbook focuses on the Mem0 API integration points — wrap these calls in your own retry/batch logic for production use.
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
This cookbook implements a **Housing Policy Prediction Simulation** following MiroFish's five-stage workflow:
|
||||
|
||||
1. **Graph Building** — Ingest seed documents, extract entities and relationships
|
||||
2. **Environment Setup** — Query the knowledge graph to enrich agent profiles
|
||||
3. **Simulation** — Track agent interactions with per-agent memory isolation
|
||||
4. **Report Generation** — Semantic search + graph traversal for analysis
|
||||
5. **Deep Interaction** — Query post-simulation memory and relationships (MiroFish also supports live agent interviews via IPC — not covered here)
|
||||
|
||||
Three agents debate a housing policy reform:
|
||||
- **Mayor Chen** — Policy advocate pushing for zoning reform
|
||||
- **Wang (Homeowner)** — Opposition leader organizing resistance
|
||||
- **Professor Li** — Academic providing data-driven analysis
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install "mem0ai[graph]"
|
||||
```
|
||||
|
||||
You need a graph backend. Choose one:
|
||||
|
||||
| Backend | Setup | Best for |
|
||||
|---|---|---|
|
||||
| **Neo4j Aura** (free tier) | [Sign up](https://neo4j.com/product/auradb/), get Bolt URI | Production, closest to Zep |
|
||||
| **Neo4j Docker** | `docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5` | Local development |
|
||||
| **Kuzu** (embedded) | No setup needed — runs in-process | Quick testing, zero dependencies |
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
|
||||
# Option A: Neo4j Docker (local development)
|
||||
docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
|
||||
# Option B: Neo4j Aura (production — free tier available)
|
||||
export NEO4J_URL="neo4j+s://<your-instance>.databases.neo4j.io"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="your-aura-password"
|
||||
|
||||
# Option C: Kuzu (zero setup — auto-detected when NEO4J_URL is not set)
|
||||
# No exports needed
|
||||
```
|
||||
|
||||
## Complete Implementation
|
||||
|
||||
```python
|
||||
"""
|
||||
MiroFish Swarm Prediction Simulation with Mem0 Graph Memory
|
||||
|
||||
MiroFish uses Zep Cloud as its knowledge graph backend. This implementation
|
||||
replaces Zep with Mem0 OSS Graph Memory, which provides:
|
||||
- Automatic entity extraction from text
|
||||
- Relationship mining (source → relationship → destination triples)
|
||||
- Combined vector + graph search returning memories AND relations
|
||||
- Per-agent isolation via run_id
|
||||
- Self-hosted with no node caps
|
||||
|
||||
Follows MiroFish's 5-stage pipeline:
|
||||
1. Graph Building - Ingest seed documents, extract entities
|
||||
2. Environment Setup - Query graph to enrich agent profiles
|
||||
3. Simulation - Track agent actions with per-agent isolation
|
||||
4. Report Generation - Semantic + graph search for analysis
|
||||
5. Deep Interaction - Query post-simulation knowledge graph
|
||||
|
||||
Run:
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from mem0 import Memory
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# MiroFish Agent Action Types (matches OASIS simulation output)
|
||||
# ======================================================================
|
||||
|
||||
# Twitter actions
|
||||
TWITTER_ACTIONS = [
|
||||
"CREATE_POST", "LIKE_POST", "REPOST", "FOLLOW",
|
||||
"DO_NOTHING", "QUOTE_POST",
|
||||
]
|
||||
|
||||
# Reddit actions (superset — includes moderation + discovery)
|
||||
REDDIT_ACTIONS = [
|
||||
"LIKE_POST", "DISLIKE_POST", "CREATE_POST", "CREATE_COMMENT",
|
||||
"LIKE_COMMENT", "DISLIKE_COMMENT", "SEARCH_POSTS", "SEARCH_USER",
|
||||
"TREND", "REFRESH", "DO_NOTHING", "FOLLOW", "MUTE",
|
||||
]
|
||||
|
||||
# Combined (DO_NOTHING is skipped during memory storage)
|
||||
MIROFISH_ACTIONS = list(set(TWITTER_ACTIONS + REDDIT_ACTIONS) - {"DO_NOTHING"})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Graph Memory Configuration
|
||||
# ======================================================================
|
||||
|
||||
def build_config():
|
||||
"""Build Mem0 config with Graph Memory.
|
||||
|
||||
Uses Neo4j if credentials are set, otherwise falls back to Kuzu (embedded).
|
||||
"""
|
||||
neo4j_url = os.environ.get("NEO4J_URL")
|
||||
|
||||
# Shared config for LLM, embedder, and vector store
|
||||
base = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "gpt-4o-mini", "temperature": 0.1}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "text-embedding-3-small", "embedding_dims": 1536}
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "mirofish",
|
||||
"embedding_model_dims": 1536,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
custom_prompt = (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
)
|
||||
|
||||
if neo4j_url:
|
||||
base["graph_store"] = {
|
||||
"provider": "neo4j",
|
||||
"config": {
|
||||
"url": neo4j_url,
|
||||
"username": os.environ.get("NEO4J_USERNAME", "neo4j"),
|
||||
"password": os.environ.get("NEO4J_PASSWORD", "password"),
|
||||
},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
else:
|
||||
# Fallback: Kuzu embedded (no external services needed)
|
||||
print(" NEO4J_URL not set — using Kuzu (embedded) graph store")
|
||||
base["graph_store"] = {
|
||||
"provider": "kuzu",
|
||||
"config": {"db": "/tmp/mirofish_graph.kuzu"},
|
||||
"custom_prompt": custom_prompt,
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Simulation Engine
|
||||
# ======================================================================
|
||||
|
||||
class MiroFishSimulation:
|
||||
"""
|
||||
Multi-agent simulation with graph-powered memory.
|
||||
|
||||
Uses Mem0 Graph Memory to replace MiroFish's Zep Cloud integration:
|
||||
- Entities and relationships are extracted automatically from text
|
||||
- search() returns both semantic memories AND graph relations
|
||||
- Per-agent isolation via run_id
|
||||
- Project isolation via user_id
|
||||
"""
|
||||
|
||||
def __init__(self, project_id: str, config: dict):
|
||||
self.project_id = project_id
|
||||
self.memory = Memory.from_config(config)
|
||||
self.stats = {
|
||||
"documents_ingested": 0,
|
||||
"activities_recorded": 0,
|
||||
"rounds_completed": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 1: Graph Building — Seed Document Ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest_documents(self, documents: list[str]):
|
||||
"""Ingest seed documents and extract entities + relationships.
|
||||
|
||||
MiroFish equivalent: GraphBuilderService.build_graph()
|
||||
Zep equivalent: graph.add_batch() with episode polling
|
||||
|
||||
With Mem0 Graph Memory, each document is processed by the LLM
|
||||
to extract entities (people, orgs, policies) and relationships
|
||||
(supports, opposes, filed). These become nodes and edges in the
|
||||
graph store, alongside vector embeddings for semantic search.
|
||||
"""
|
||||
print(" Ingesting documents and building knowledge graph...")
|
||||
for i, doc in enumerate(documents):
|
||||
result = self.memory.add(
|
||||
[{"role": "user", "content": doc}],
|
||||
user_id=self.project_id,
|
||||
metadata={"stage": "graph_building", "source": "seed_document", "chunk_index": i}
|
||||
)
|
||||
# Graph Memory returns extracted relations
|
||||
relations = result.get("relations", {})
|
||||
added = relations.get("added_entities", [])
|
||||
if added:
|
||||
print(f" Doc {i}: extracted {len(added)} entities/relations")
|
||||
|
||||
self.stats["documents_ingested"] = len(documents)
|
||||
print(f" Ingested {len(documents)} documents")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 2: Environment Setup — Agent Profile Enrichment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enrich_agent_profile(self, agent_name: str, persona_query: str) -> dict:
|
||||
"""Search memory + graph for context relevant to an agent's persona.
|
||||
|
||||
MiroFish equivalent: OasisProfileGenerator using graph.search()
|
||||
|
||||
Returns both semantic memories and graph relations that can be
|
||||
injected into the agent's system prompt.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
persona_query,
|
||||
user_id=self.project_id,
|
||||
limit=10
|
||||
)
|
||||
facts = [r["memory"] for r in results.get("results", [])]
|
||||
relations = results.get("relations", [])
|
||||
|
||||
print(f" {agent_name}: {len(facts)} facts, {len(relations)} relations")
|
||||
return {"facts": facts, "relations": relations}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 3: Simulation — Agent Activity Tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_action(self, agent_id: str, agent_name: str,
|
||||
action_type: str, content: str,
|
||||
platform: str, round_num: int):
|
||||
"""Record a single agent action as a memory with graph extraction.
|
||||
|
||||
MiroFish equivalent: ZepGraphMemoryUpdater.add_activity()
|
||||
Zep equivalent: graph.add(type="text", data=episode_text)
|
||||
|
||||
Agent memories use run_id to group by agent (no assistant
|
||||
memories involved). Graph Memory extracts entities/relationships
|
||||
from the action content automatically.
|
||||
"""
|
||||
formatted = f"{agent_name} [{action_type}]: {content}"
|
||||
|
||||
self.memory.add(
|
||||
[{"role": "user", "content": formatted}],
|
||||
run_id=agent_id,
|
||||
metadata={
|
||||
"action_type": action_type,
|
||||
"platform": platform,
|
||||
"round": round_num,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
)
|
||||
self.stats["activities_recorded"] += 1
|
||||
|
||||
def run_round(self, round_num: int, activities: list[tuple]):
|
||||
"""Execute one simulation round."""
|
||||
print(f" Round {round_num}: {len(activities)} actions")
|
||||
for agent_id, agent_name, action_type, content, platform in activities:
|
||||
self.record_action(agent_id, agent_name, action_type, content, platform, round_num)
|
||||
self.stats["rounds_completed"] = max(self.stats["rounds_completed"], round_num)
|
||||
|
||||
def recall_agent_memory(self, agent_id: str, query: str) -> dict:
|
||||
"""Agent recalls its own memories mid-simulation.
|
||||
|
||||
Searches by run_id to match the scope used during add().
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=5
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 4: Report Generation — Semantic + Graph Retrieval
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def quick_search(self, query: str, limit: int = 10) -> dict:
|
||||
"""Semantic search + graph relations across all agents.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.quick_search()
|
||||
Returns both vector-matched memories and related graph triples.
|
||||
"""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
user_id=self.project_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def panorama_search(self) -> dict:
|
||||
"""Retrieve all memories + all graph relations.
|
||||
|
||||
MiroFish equivalent: ZepToolsService.panorama_search()
|
||||
Returns the complete knowledge state for report generation.
|
||||
"""
|
||||
results = self.memory.get_all(user_id=self.project_id)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
def agent_search(self, agent_id: str, query: str, limit: int = 10) -> dict:
|
||||
"""Search within a single agent's memory space."""
|
||||
results = self.memory.search(
|
||||
query,
|
||||
run_id=agent_id,
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memories": [r["memory"] for r in results.get("results", [])],
|
||||
"relations": results.get("relations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
"""Delete all memories and graph data for this simulation."""
|
||||
self.memory.delete_all(user_id=self.project_id)
|
||||
print(f" Cleaned up all memories for {self.project_id}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Run the full 5-stage pipeline
|
||||
# ======================================================================
|
||||
|
||||
def main():
|
||||
project_id = f"mirofish_housing_{int(time.time())}"
|
||||
config = build_config()
|
||||
sim = MiroFishSimulation(project_id=project_id, config=config)
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 1: Graph Building — Ingest seed documents
|
||||
# ==================================================================
|
||||
print("=" * 60)
|
||||
print("STAGE 1: Graph Building")
|
||||
print("=" * 60)
|
||||
|
||||
sim.ingest_documents([
|
||||
"The city council proposed a new zoning reform allowing higher "
|
||||
"density housing in suburban areas. Mayor Chen expressed strong "
|
||||
"support, citing a 40% housing shortage affecting young professionals. "
|
||||
"The reform would allow buildings up to 8 stories in previously "
|
||||
"restricted 3-story zones.",
|
||||
|
||||
"Local homeowners association president Wang opposes the reform, "
|
||||
"arguing it will decrease property values by 15-20%. The association "
|
||||
"represents 5,000 homeowners in the affected districts. Wang has "
|
||||
"organized three community meetings and collected 2,000 signatures.",
|
||||
|
||||
"Professor Li from Beijing University published research showing "
|
||||
"similar reforms in Shenzhen led to 15% price drops in existing "
|
||||
"homes but created 30% more affordable housing units within 3 years. "
|
||||
"The study covered 12 districts and 50,000 housing units.",
|
||||
])
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 2: Environment Setup — Enrich agent profiles
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 2: Environment Setup")
|
||||
print("=" * 60)
|
||||
|
||||
mayor_context = sim.enrich_agent_profile(
|
||||
"Mayor Chen",
|
||||
"Mayor Chen housing reform zoning policy"
|
||||
)
|
||||
wang_context = sim.enrich_agent_profile(
|
||||
"Wang",
|
||||
"Wang homeowner opposition property values petition"
|
||||
)
|
||||
li_context = sim.enrich_agent_profile(
|
||||
"Professor Li",
|
||||
"Professor Li research housing data Shenzhen"
|
||||
)
|
||||
|
||||
print("\n Example profile context for Mayor Chen:")
|
||||
for fact in mayor_context["facts"][:3]:
|
||||
print(f" Fact: {fact}")
|
||||
for rel in mayor_context["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 3: Simulation — Run agent interactions
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 3: Simulation")
|
||||
print("=" * 60)
|
||||
|
||||
# Round 1: Opening statements
|
||||
sim.run_round(1, [
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"This reform will create 10,000 new housing units by 2028. "
|
||||
"Young families deserve affordable homes. #HousingForAll",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Our property values will plummet! The council ignores the "
|
||||
"voices of 5,000 homeowners. #StopTheReform",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"New analysis: Shenzhen zoning data shows net positive outcomes "
|
||||
"after 3 years. Short-term pain, long-term gain for housing equity.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 2: Debate and interaction
|
||||
sim.run_round(2, [
|
||||
("wang_homeowner", "Wang", "CREATE_COMMENT",
|
||||
"Replied to Professor Li: 'Shenzhen is a tier-1 city with "
|
||||
"completely different dynamics. Your comparison is misleading.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "LIKE_POST",
|
||||
"Liked Professor Li's post about Shenzhen housing data.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_COMMENT",
|
||||
"Replied to Wang: 'The methodology controls for city tier "
|
||||
"and population density. I invite you to review the full dataset.'",
|
||||
"twitter"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Data from @ProfLi confirms what we've been saying: zoning "
|
||||
"reform works. Let's move forward with evidence, not fear.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Round 3: Escalation and platform expansion
|
||||
sim.run_round(3, [
|
||||
("wang_homeowner", "Wang", "CREATE_POST",
|
||||
"Filing formal petition with 3,000 signatures against the "
|
||||
"zoning reform. Council meeting next Tuesday. All homeowners "
|
||||
"must attend!",
|
||||
"reddit"),
|
||||
|
||||
("mayor_chen", "Mayor Chen", "CREATE_POST",
|
||||
"Announcing public town hall on zoning reform this Saturday. "
|
||||
"All voices welcome. Data-driven decisions benefit everyone.",
|
||||
"twitter"),
|
||||
|
||||
("prof_li", "Professor Li", "CREATE_POST",
|
||||
"Published full dataset and methodology on my university page. "
|
||||
"Transparency is essential for informed public debate.",
|
||||
"twitter"),
|
||||
|
||||
("wang_homeowner", "Wang", "FOLLOW",
|
||||
"Followed @MayorChen to monitor policy updates.",
|
||||
"twitter"),
|
||||
])
|
||||
|
||||
# Mid-simulation: agent recalls own memory + graph
|
||||
print("\n Mid-simulation recall for Mayor Chen:")
|
||||
mayor_recall = sim.recall_agent_memory(
|
||||
"mayor_chen",
|
||||
"What positions have I taken on housing reform?"
|
||||
)
|
||||
for mem in mayor_recall["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in mayor_recall["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 4: Report Generation — Retrieve memories + graph for analysis
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 4: Report Generation")
|
||||
print("=" * 60)
|
||||
|
||||
# Quick search: targeted query
|
||||
print("\n Quick Search: 'opposition to housing reform'")
|
||||
opposition = sim.quick_search("opposition to housing reform", limit=5)
|
||||
for mem in opposition["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in opposition["relations"][:3]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# Agent-specific search
|
||||
print("\n Agent Search: Wang's activities")
|
||||
wang_activities = sim.agent_search("wang_homeowner", "all actions and statements")
|
||||
for mem in wang_activities["memories"]:
|
||||
print(f" Memory: {mem}")
|
||||
|
||||
# Panorama: full overview
|
||||
print("\n Panorama Search: all memories + relations")
|
||||
panorama = sim.panorama_search()
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
for mem in panorama["memories"][:5]:
|
||||
print(f" Memory: {mem}")
|
||||
if len(panorama["memories"]) > 5:
|
||||
print(f" ... and {len(panorama['memories']) - 5} more")
|
||||
for rel in panorama["relations"][:5]:
|
||||
src = rel.get("source", "?")
|
||||
edge = rel.get("relationship", "?")
|
||||
dst = rel.get("destination", rel.get("target", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# STAGE 5: Deep Interaction — Post-simulation queries
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("STAGE 5: Deep Interaction")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"How did the debate evolve across the three rounds?",
|
||||
"What evidence was cited by each side?",
|
||||
"Who supports and who opposes the reform?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n Query: '{query}'")
|
||||
results = sim.quick_search(query, limit=3)
|
||||
for mem in results["memories"][:2]:
|
||||
print(f" Memory: {mem}")
|
||||
for rel in results["relations"][:2]:
|
||||
src = rel.get("source", rel.get("source_node", "?"))
|
||||
edge = rel.get("relationship", rel.get("relation", "?"))
|
||||
dst = rel.get("destination", rel.get("destination_node", "?"))
|
||||
print(f" Relation: {src} --[{edge}]--> {dst}")
|
||||
|
||||
# ==================================================================
|
||||
# Summary
|
||||
# ==================================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("SIMULATION COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f" Project ID: {project_id}")
|
||||
print(f" Documents ingested: {sim.stats['documents_ingested']}")
|
||||
print(f" Activities tracked: {sim.stats['activities_recorded']}")
|
||||
print(f" Rounds completed: {sim.stats['rounds_completed']}")
|
||||
print(f" Total memories: {len(panorama['memories'])}")
|
||||
print(f" Total relations: {len(panorama['relations'])}")
|
||||
|
||||
# Cleanup (uncomment to delete all memories + graph data)
|
||||
# sim.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("MiroFish Swarm Prediction Simulation powered by Mem0 Graph Memory\n")
|
||||
main()
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Graph Memory: The Right Fit for MiroFish
|
||||
|
||||
MiroFish's entire pipeline revolves around a **knowledge graph** — it extracts entities from documents, builds relationships, and queries the graph throughout simulation and reporting. Mem0's Graph Memory provides the same capabilities:
|
||||
|
||||
| MiroFish needs | Zep Cloud | Mem0 Graph Memory |
|
||||
|---|---|---|
|
||||
| **Entity extraction** | Built-in via Zep API | Automatic via LLM extraction |
|
||||
| **Relationship mining** | Graph edges | `(source) --[relationship]--> (destination)` triples |
|
||||
| **Semantic + keyword search** | Semantic + BM25 | Vector similarity + graph relation retrieval |
|
||||
| **Graph traversal** | Node/edge queries | `relations` array in search results |
|
||||
| **Per-agent isolation** | Single shared graph in MiroFish | Native `run_id` scoping |
|
||||
| **Self-hosting** | No (cloud only) | Yes — Neo4j, Memgraph, Kuzu, Neptune |
|
||||
| **Node/memory limits** | Capped on free tier | Unlimited (self-hosted) |
|
||||
|
||||
### How search() Returns Both Memories and Relations
|
||||
|
||||
When Graph Memory is enabled, every `search()` call returns two arrays:
|
||||
|
||||
```python
|
||||
results = memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Vector-matched memories (ordered by similarity)
|
||||
results["results"] # [{"memory": "...", "score": 0.85, ...}, ...]
|
||||
|
||||
# Graph relations connected to query entities
|
||||
results["relations"] # [{"source": "mayor_chen", "relationship": "supports", "destination": "zoning_reform"}, ...]
|
||||
```
|
||||
|
||||
This is what makes Mem0 Graph Memory a natural replacement for Zep — you get semantic search AND structured graph data in a single call.
|
||||
|
||||
### Per-Agent Memory Isolation
|
||||
|
||||
`user_id` scopes the simulation project. `run_id` tags individual agent actions at storage time (we use `run_id` instead of `agent_id` since no assistant memories are involved). Searches use `user_id` for project-wide retrieval:
|
||||
|
||||
```python
|
||||
# Store project-level memories (seed documents)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen supports the zoning reform."}],
|
||||
user_id="my_sim"
|
||||
)
|
||||
|
||||
# Store agent-specific memories (simulation actions)
|
||||
memory.add(
|
||||
[{"role": "user", "content": "Mayor Chen [CREATE_POST]: Reform works!"}],
|
||||
run_id="mayor_chen"
|
||||
)
|
||||
|
||||
# Search project-level memories (seed docs)
|
||||
memory.search("housing reform", user_id="my_sim")
|
||||
|
||||
# Search agent-specific memories (actions stored with run_id)
|
||||
memory.search("housing reform", run_id="mayor_chen")
|
||||
|
||||
# Get all project-level memories + graph relations
|
||||
memory.get_all(user_id="my_sim")
|
||||
```
|
||||
|
||||
<Note>
|
||||
Use `user_id` for project-level data (seed documents) and `run_id` for agent actions — both for `add()` and `search()`. Always match the scope: if you `add()` with `run_id`, `search()` with `run_id`. Use the message list format `[{"role": "user", "content": "..."}]` for all `add()` calls — it works on both OSS and Cloud.
|
||||
</Note>
|
||||
|
||||
### Stage Mapping
|
||||
|
||||
| MiroFish Stage | What Happens | Mem0 Graph Memory Call |
|
||||
|---|---|---|
|
||||
| **1. Graph Building** | Ingest docs, extract entities | `memory.add(doc, user_id=project)` — entities/relations extracted automatically |
|
||||
| **2. Environment Setup** | Enrich agent personas from graph | `memory.search(query, user_id=project)` — returns facts + relations |
|
||||
| **3. Simulation** | Track per-agent actions | `memory.add(messages, run_id=agent)` |
|
||||
| **3. Simulation** | Mid-round recall | `memory.search(query, run_id=agent)` |
|
||||
| **4. Report Generation** | Targeted analysis | `memory.search(query, user_id=project)` — memories + graph |
|
||||
| **4. Report Generation** | Full overview | `memory.get_all(user_id=project)` — all memories + all relations |
|
||||
| **5. Deep Interaction** | Follow-up queries | `memory.search(query, user_id=project)` |
|
||||
|
||||
### Zep-to-Mem0 Migration Reference
|
||||
|
||||
For developers replacing MiroFish's Zep integration. Note that Mem0 Graph Memory covers the core graph operations but some Zep features have no direct equivalent — see caveats below.
|
||||
|
||||
| MiroFish Service | Zep Call | Mem0 Graph Memory Equivalent | Caveat |
|
||||
|---|---|---|---|
|
||||
| GraphBuilderService | `client.graph.create()` | Implicit on first `memory.add()` | |
|
||||
| GraphBuilderService | `client.graph.set_ontology()` | `custom_prompt` in graph_store config | Freeform text, not a typed schema like Zep's `EntityModel`/`EdgeModel` |
|
||||
| GraphBuilderService | `client.graph.add_batch(episodes)` | `memory.add()` per chunk | No batch API — call per chunk |
|
||||
| GraphBuilderService | `client.graph.episode.get(uuid)` | Not needed (add is synchronous in OSS) | |
|
||||
| GraphBuilderService | `client.graph.delete(id)` | `memory.delete_all(user_id=...)` | |
|
||||
| ZepEntityReader | `client.graph.node.get_by_graph_id()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepEntityReader | `client.graph.node.get(uuid)` | `memory.search(entity_name, user_id=...)` | Semantic search, not exact ID lookup |
|
||||
| ZepEntityReader | `client.graph.node.get_entity_edges()` | `memory.search(entity_name, user_id=...)` → `relations` | Returns all matching relations, not edges for a specific node |
|
||||
| ZepGraphMemoryUpdater | `client.graph.add(type="text")` | `memory.add(messages, run_id=...)` | No batch buffering or retry — implement in your wrapper |
|
||||
| ZepToolsService | `search_graph(query, scope)` | `memory.search(query, user_id=...)` → memories + relations | |
|
||||
| ZepToolsService | `get_entities()` | `memory.get_all(user_id=...)` → `relations` | |
|
||||
| ZepToolsService | Panorama (all nodes + edges) | `memory.get_all(user_id=...)` | No temporal fact separation (active vs historical) |
|
||||
| ZepToolsService | InsightForge (multi-query decomposition) | Not available | Implement LLM-driven sub-query decomposition in your own ReportAgent |
|
||||
| OasisProfileGenerator | `client.graph.search()` | `memory.search(query, user_id=...)` | |
|
||||
|
||||
<Note>
|
||||
**What Mem0 Graph Memory does not cover**: Zep's typed ontology schemas (`EntityModel`, `EdgeModel`), temporal fact lifecycle (`valid_at`/`invalid_at`/`expired_at`), single-node-by-ID lookup, and InsightForge's multi-query decomposition. For InsightForge-like functionality, implement sub-query logic in your own ReportAgent using `memory.search()` as the retrieval primitive.
|
||||
</Note>
|
||||
|
||||
### Custom Extraction Prompts
|
||||
|
||||
Guide what entities and relationships Mem0 extracts — analogous to (but less structured than) Zep's `set_ontology()`:
|
||||
|
||||
```python
|
||||
config = {
|
||||
"graph_store": {
|
||||
"provider": "neo4j",
|
||||
"config": {"url": "...", "username": "...", "password": "..."},
|
||||
"custom_prompt": (
|
||||
"Extract all people, organizations, policies, locations, "
|
||||
"and their relationships. Capture support/opposition stances, "
|
||||
"affiliations, and quantitative claims."
|
||||
),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action Types
|
||||
|
||||
MiroFish's OASIS engine produces these agent action types. Format them as natural language when storing. Skip `DO_NOTHING` actions (no memory value). `TREND` and `REFRESH` are Reddit-only discovery actions — store if you want to track browsing behavior.
|
||||
|
||||
| Action Type | Platform | Example Memory Content |
|
||||
|---|---|---|
|
||||
| `CREATE_POST` | Both | `"Mayor Chen [CREATE_POST]: This reform will create 10,000 units"` |
|
||||
| `CREATE_COMMENT` | Reddit | `"Wang [CREATE_COMMENT]: Replied to Prof Li: 'Your data is misleading'"` |
|
||||
| `LIKE_POST` | Both | `"Mayor Chen [LIKE_POST]: Liked Prof Li's post about Shenzhen data"` |
|
||||
| `REPOST` | Twitter | `"Prof Li [REPOST]: Reposted Mayor Chen's town hall announcement"` |
|
||||
| `FOLLOW` | Both | `"Wang [FOLLOW]: Followed @MayorChen"` |
|
||||
| `QUOTE_POST` | Twitter | `"Mayor Chen [QUOTE_POST]: 'Data confirms reform works' quoting Prof Li"` |
|
||||
| `DISLIKE_POST` | Reddit | `"Wang [DISLIKE_POST]: Downvoted Mayor Chen's reform post"` |
|
||||
| `TREND` | Reddit | `"Prof Li [TREND]: Browsed trending topics"` |
|
||||
| `DO_NOTHING` | Both | Skip — no memory value |
|
||||
|
||||
## Running the Example
|
||||
|
||||
```bash
|
||||
# Option A: Neo4j (production)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export NEO4J_URL="neo4j://localhost:7687"
|
||||
export NEO4J_USERNAME="neo4j"
|
||||
export NEO4J_PASSWORD="password"
|
||||
python mirofish_swarm_memory.py
|
||||
|
||||
# Option B: Kuzu (zero dependencies, just need OpenAI key)
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
python mirofish_swarm_memory.py # auto-detects missing NEO4J_URL, uses Kuzu
|
||||
```
|
||||
|
||||
<Note>
|
||||
Exact output varies as Mem0 automatically extracts and deduplicates entities. The specific relations and memory counts depend on LLM extraction quality.
|
||||
</Note>
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Unique `user_id` per simulation** — Use timestamps or UUIDs (e.g., `mirofish_housing_1742198400`) to prevent memory collisions between runs
|
||||
2. **Always set `run_id` for agent actions** — Per-agent isolation prevents memory cross-contamination between agents
|
||||
3. **Use `custom_prompt`** — Guide entity extraction to capture domain-specific relationships (people, policies, stances)
|
||||
4. **Format actions as natural language** — `"Mayor Chen [CREATE_POST]: content"` extracts better entities than raw JSON
|
||||
5. **Query relations for reports** — The `relations` array in search results gives structured `(source, relationship, destination)` triples for building analytical reports
|
||||
6. **Cleanup old simulations** — Call `delete_all(user_id=...)` when a simulation run is no longer needed
|
||||
|
||||
## Resources
|
||||
|
||||
- [MiroFish GitHub](https://github.com/666ghj/MiroFish) — Source code and setup guide
|
||||
- [MiroFish Documentation](https://deepwiki.com/666ghj/MiroFish) — Full framework docs
|
||||
- [Mem0 Graph Memory](/open-source/features/graph-memory) — Graph Memory documentation
|
||||
- [Mem0 Documentation](https://docs.mem0.ai/) — Full API reference
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Graph Memory" icon="network-wired" href="/open-source/features/graph-memory">
|
||||
Full Graph Memory documentation with provider setup.
|
||||
</Card>
|
||||
<Card title="MiroFish GitHub" icon="fish" href="https://github.com/666ghj/MiroFish">
|
||||
MiroFish source code and setup guide.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
+194
-1
@@ -375,7 +375,8 @@
|
||||
"cookbooks/frameworks/multimodal-retrieval",
|
||||
"cookbooks/frameworks/eliza-os-character",
|
||||
"cookbooks/frameworks/chrome-extension",
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp"
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp",
|
||||
"cookbooks/frameworks/mirofish-swarm-memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -858,6 +859,198 @@
|
||||
{
|
||||
"source": "/features/memory-export",
|
||||
"destination": "/platform/features/memory-export"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/components/:a/:b/:c",
|
||||
"destination": "/components/:a/:b/:c"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/components/:a/:b",
|
||||
"destination": "/components/:a/:b"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/core-concepts/:a/:b",
|
||||
"destination": "/core-concepts/:a/:b"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/integrations/:slug",
|
||||
"destination": "/integrations/:slug"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/open-source/:slug",
|
||||
"destination": "/open-source/:slug"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/introduction",
|
||||
"destination": "/introduction"
|
||||
},
|
||||
{
|
||||
"source": "/features/async-client",
|
||||
"destination": "/platform/features/async-client"
|
||||
},
|
||||
{
|
||||
"source": "/features/custom-prompts",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/selective-memory",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/custom-categories",
|
||||
"destination": "/platform/features/custom-categories"
|
||||
},
|
||||
{
|
||||
"source": "/components/config",
|
||||
"destination": "/open-source/configuration"
|
||||
},
|
||||
{
|
||||
"source": "/concepts/memory-scoring",
|
||||
"destination": "/core-concepts/memory-types"
|
||||
},
|
||||
{
|
||||
"source": "/cookbooks/research-copilot",
|
||||
"destination": "/cookbooks/operations/deep-research"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/organizations-projects",
|
||||
"destination": "/api-reference/organizations-projects"
|
||||
},
|
||||
{
|
||||
"source": "/playground",
|
||||
"destination": "/platform/quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/cdn-cgi/l/email-protection",
|
||||
"destination": "/introduction"
|
||||
},
|
||||
{
|
||||
"source": "/features/online-memory",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/multimodal",
|
||||
"destination": "/platform/features/multimodal-support"
|
||||
},
|
||||
{
|
||||
"source": "/features/inferences",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/graph-memory",
|
||||
"destination": "/platform/features/graph-memory"
|
||||
},
|
||||
{
|
||||
"source": "/features/:slug",
|
||||
"destination": "/platform/features/:slug"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/online-memory",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/multimodal",
|
||||
"destination": "/platform/features/multimodal-support"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/inferences",
|
||||
"destination": "/platform/features/platform-overview"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/custom-prompts",
|
||||
"destination": "/platform/features/custom-instructions"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/rest-api",
|
||||
"destination": "/open-source/features/rest-api"
|
||||
},
|
||||
{
|
||||
"source": "/components/embedders/models/google_ai",
|
||||
"destination": "/components/embedders/models/google_AI"
|
||||
},
|
||||
{
|
||||
"source": "/components/embedders/models/lm_studio",
|
||||
"destination": "/components/embedders/models/lmstudio"
|
||||
},
|
||||
{
|
||||
"source": "/components/llms/models/xai",
|
||||
"destination": "/components/llms/models/xAI"
|
||||
},
|
||||
{
|
||||
"source": "/components/llms/models/google_ai",
|
||||
"destination": "/components/llms/models/google_AI"
|
||||
},
|
||||
{
|
||||
"source": "/components/llms/models/mistral_ai",
|
||||
"destination": "/components/llms/models/mistral_AI"
|
||||
},
|
||||
{
|
||||
"source": "/components/llms/models/lm_studio",
|
||||
"destination": "/components/llms/models/lmstudio"
|
||||
},
|
||||
{
|
||||
"source": "/components/vectordbs/dbs/neptune-analytics",
|
||||
"destination": "/components/vectordbs/dbs/neptune_analytics"
|
||||
},
|
||||
{
|
||||
"source": "/components/vectordbs/dbs/s3-vectors",
|
||||
"destination": "/components/vectordbs/dbs/s3_vectors"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/python_quickstart",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/node_quickstart",
|
||||
"destination": "/open-source/node-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/rest-api",
|
||||
"destination": "/open-source/features/rest-api"
|
||||
},
|
||||
{
|
||||
"source": "/cookbooks/deep-research",
|
||||
"destination": "/cookbooks/operations/deep-research"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/overview",
|
||||
"destination": "/platform/overview"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/quickstart",
|
||||
"destination": "/platform/quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/v0x/faqs",
|
||||
"destination": "/platform/faqs"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/multion",
|
||||
"destination": "/integrations"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/composio",
|
||||
"destination": "/integrations"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/qdrant",
|
||||
"destination": "/components/vectordbs/dbs/qdrant"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/anthropic",
|
||||
"destination": "/components/llms/models/anthropic"
|
||||
},
|
||||
{
|
||||
"source": "/llms",
|
||||
"destination": "/components/llms/overview"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/graph-memory",
|
||||
"destination": "/open-source/features/graph-memory"
|
||||
},
|
||||
{
|
||||
"source": "/cookbooks/customer-support-agent",
|
||||
"destination": "/cookbooks/operations/support-inbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-4
@@ -104,7 +104,7 @@ Key differentiators:
|
||||
- [Together](https://docs.mem0.ai/components/llms/models/together): Open-source model inference platform
|
||||
- [DeepSeek](https://docs.mem0.ai/components/llms/models/deepseek): Advanced reasoning models
|
||||
- [Sarvam](https://docs.mem0.ai/components/llms/models/sarvam): Indian language models
|
||||
- [XAI](https://docs.mem0.ai/components/llms/models/xai): xAI models integration
|
||||
- [XAI](https://docs.mem0.ai/components/llms/models/xAI): xAI models integration
|
||||
- [LiteLLM](https://docs.mem0.ai/components/llms/models/litellm): Unified LLM interface and proxy
|
||||
- [LangChain](https://docs.mem0.ai/components/llms/models/langchain): LangChain LLM integration
|
||||
- [OpenAI Structured](https://docs.mem0.ai/components/llms/models/openai_structured): OpenAI with structured output support
|
||||
@@ -120,7 +120,7 @@ Key differentiators:
|
||||
- [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus): Open-source vector database for AI applications at scale
|
||||
- [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis): Real-time vector storage and search with Redis Stack
|
||||
- [Supabase](https://docs.mem0.ai/components/vectordbs/dbs/supabase): Open-source Firebase alternative with vector support
|
||||
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash_vector): Serverless vector database
|
||||
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash-vector): Serverless vector database
|
||||
- [Elasticsearch](https://docs.mem0.ai/components/vectordbs/dbs/elasticsearch): Distributed search and analytics engine
|
||||
- [OpenSearch](https://docs.mem0.ai/components/vectordbs/dbs/opensearch): Open-source search and analytics platform
|
||||
- [FAISS](https://docs.mem0.ai/components/vectordbs/dbs/faiss): Facebook AI Similarity Search library
|
||||
@@ -136,9 +136,9 @@ Key differentiators:
|
||||
|
||||
- [OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/openai): High-quality text embeddings with customizable dimensions
|
||||
- [Azure OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/azure_openai): Enterprise Azure-hosted embedding models
|
||||
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_ai): Gemini embedding models
|
||||
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_AI): Gemini embedding models
|
||||
- [AWS Bedrock](https://docs.mem0.ai/components/embedders/models/aws_bedrock): Amazon embedding models through Bedrock
|
||||
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/hugging_face): Open-source embedding models for local deployment
|
||||
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/huggingface): Open-source embedding models for local deployment
|
||||
- [Vertex AI](https://docs.mem0.ai/components/embedders/models/vertexai): Google Cloud's enterprise embedding models
|
||||
- [Ollama](https://docs.mem0.ai/components/embedders/models/ollama): Local embedding models for privacy-focused applications
|
||||
- [Together](https://docs.mem0.ai/components/embedders/models/together): Open-source model embeddings
|
||||
|
||||
@@ -370,7 +370,7 @@ If you encounter issues, you can revert immediately by switching your import bac
|
||||
|
||||
- [Platform Dashboard](https://app.mem0.ai) - Monitor usage and manage settings.
|
||||
- [Webhooks Setup](/platform/features/webhooks) - Configure real-time event notifications.
|
||||
- [Organizations & Projects](/platform/features/organizations-projects) - Set up multi-tenancy for your team.
|
||||
- [Organizations & Projects](/api-reference/organizations-projects) - Set up multi-tenancy for your team.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
|
||||
@@ -46,6 +46,7 @@ Replace `<client-name>` with the desired client name and `<user-id>` with the va
|
||||
| Witsy | `npx install-mcp http://localhost:8765/mcp/witsy/sse/<user-id> --client witsy` |
|
||||
| Enconvo | `npx install-mcp http://localhost:8765/mcp/enconvo/sse/<user-id> --client enconvo` |
|
||||
| Augment | `npx install-mcp http://localhost:8765/mcp/augment/sse/<user-id> --client augment` |
|
||||
| Claude Code | `claude mcp add -s user -t sse OpenMemory http://localhost:8765/mcp/openmemory/sse/<user-id>` |
|
||||
|
||||
### What This Does
|
||||
|
||||
|
||||
@@ -117,6 +117,38 @@ OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id> # The User ID you want to associate the memories with
|
||||
```
|
||||
|
||||
#### LLM Configuration (optional)
|
||||
|
||||
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
|
||||
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
|
||||
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
|
||||
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
|
||||
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
|
||||
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
|
||||
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
|
||||
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
|
||||
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
|
||||
|
||||
**Example: Using Ollama (fully local)**
|
||||
```bash
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=llama3.1:latest
|
||||
EMBEDDER_PROVIDER=ollama
|
||||
EMBEDDER_MODEL=nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
**Example: Using Anthropic**
|
||||
```bash
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-sonnet-4-20250514
|
||||
LLM_API_KEY=sk-ant-xxx
|
||||
```
|
||||
|
||||
#### Example `/ui/.env`
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8765
|
||||
@@ -150,6 +182,11 @@ You can configure the MCP client using the following command (replace `username`
|
||||
npx @openmemory/install local "http://localhost:8765/mcp/cursor/sse/username" --client cursor
|
||||
```
|
||||
|
||||
**For Claude Code (CLI):**
|
||||
```bash
|
||||
claude mcp add -s user -t sse OpenMemory "http://localhost:8765/mcp/openmemory/sse/username"
|
||||
```
|
||||
|
||||
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories and check connection status with your MCP clients.
|
||||
|
||||
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ const response = await fetch("https://api.mem0.ai/v1/memories", {
|
||||
|
||||
## Sample workflow
|
||||
|
||||
- [Build a Customer Support Agent](/cookbooks/customer-support-agent)
|
||||
- [Build a Customer Support Agent](/cookbooks/operations/support-inbox)
|
||||
|
||||
{/* DEBUG: verify CTA targets */}
|
||||
|
||||
|
||||
+2
-2
@@ -124,10 +124,10 @@ Walk through a real request/response. Include sample payloads and highlight nota
|
||||
{/* DEBUG: verify CTA targets */}
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/concepts/memory-scoring">
|
||||
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
|
||||
Understand how Mem0 ranks memories under the hood.
|
||||
</Card>
|
||||
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/research-copilot">
|
||||
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
|
||||
See advanced retrieval driving a full knowledge assistant.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
...require("./jest.config"),
|
||||
testMatch: ["**/integration/**/*.test.ts"],
|
||||
globalSetup: "<rootDir>/src/client/tests/integration/global-setup.ts",
|
||||
globalTeardown: "<rootDir>/src/client/tests/integration/global-teardown.ts",
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -37,6 +37,9 @@
|
||||
"start": "pnpm run example memory",
|
||||
"example": "ts-node src/oss/examples/vector-stores/index.ts",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"test:unit": "jest --coverage --ci --testPathIgnorePatterns='/node_modules/' '/dist/' 'integration'",
|
||||
"test:integration": "jest --config jest.integration.config.js --forceExit",
|
||||
"test:ts": "jest --config jest.config.js",
|
||||
"test:watch": "jest --config jest.config.js --watch",
|
||||
"format": "npm run clean && prettier --write .",
|
||||
@@ -103,7 +106,7 @@
|
||||
"@azure/search-documents": "^12.0.0",
|
||||
"@cloudflare/workers-types": "^4.20250504.0",
|
||||
"@google/genai": "^1.2.0",
|
||||
"@langchain/core": "^0.3.44",
|
||||
"@langchain/core": "^1.0.0",
|
||||
"@mistralai/mistralai": "^1.5.2",
|
||||
"@qdrant/js-client-rest": "1.13.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
|
||||
Generated
+65
-45
@@ -23,8 +23,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)
|
||||
"@langchain/core":
|
||||
specifier: ^0.3.44
|
||||
version: 0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
|
||||
specifier: ^1.0.0
|
||||
version: 1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
"@mistralai/mistralai":
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(zod@3.24.2)
|
||||
@@ -917,12 +917,12 @@ packages:
|
||||
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
|
||||
}
|
||||
|
||||
"@langchain/core@0.3.44":
|
||||
"@langchain/core@1.1.32":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-3BsSFf7STvPPZyl2kMANgtVnCUvDdyP4k+koP+nY2Tczd5V+RFkuazIn/JOj/xxy/neZjr4PxFU4BFyF1aKXOA==,
|
||||
integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
engines: { node: ">=20" }
|
||||
|
||||
"@mistralai/mistralai@1.5.2":
|
||||
resolution:
|
||||
@@ -1214,6 +1214,12 @@ packages:
|
||||
integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==,
|
||||
}
|
||||
|
||||
"@standard-schema/spec@1.1.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==,
|
||||
}
|
||||
|
||||
"@supabase/auth-js@2.68.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -1383,12 +1389,6 @@ packages:
|
||||
integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==,
|
||||
}
|
||||
|
||||
"@types/retry@0.12.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==,
|
||||
}
|
||||
|
||||
"@types/stack-utils@2.0.3":
|
||||
resolution:
|
||||
{
|
||||
@@ -1813,6 +1813,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
chalk@5.6.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==,
|
||||
}
|
||||
engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 }
|
||||
|
||||
char-regex@1.0.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -3295,16 +3302,28 @@ packages:
|
||||
integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==,
|
||||
}
|
||||
|
||||
langsmith@0.3.15:
|
||||
langsmith@0.5.10:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-cv3ebg0Hh0gRbl72cv/uzaZ+KOdfa2mGF1s74vmB2vlNVO/Ap/O9RYaHV+tpR8nwhGZ50R3ILnTOwSwGP+XQxw==,
|
||||
integrity: sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": "*"
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "*"
|
||||
"@opentelemetry/sdk-trace-base": "*"
|
||||
openai: "*"
|
||||
ws: ">=7"
|
||||
peerDependenciesMeta:
|
||||
"@opentelemetry/api":
|
||||
optional: true
|
||||
"@opentelemetry/exporter-trace-otlp-proto":
|
||||
optional: true
|
||||
"@opentelemetry/sdk-trace-base":
|
||||
optional: true
|
||||
openai:
|
||||
optional: true
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
leven@3.1.0:
|
||||
resolution:
|
||||
@@ -3794,13 +3813,6 @@ packages:
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
p-retry@4.6.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
p-timeout@3.2.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4271,13 +4283,6 @@ packages:
|
||||
engines: { node: ">= 0.4" }
|
||||
hasBin: true
|
||||
|
||||
retry@0.13.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==,
|
||||
}
|
||||
engines: { node: ">= 4" }
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4928,6 +4933,13 @@ packages:
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -5143,6 +5155,12 @@ packages:
|
||||
integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==,
|
||||
}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==,
|
||||
}
|
||||
|
||||
snapshots:
|
||||
"@ampproject/remapping@2.3.0":
|
||||
dependencies:
|
||||
@@ -5759,22 +5777,25 @@ snapshots:
|
||||
"@jridgewell/resolve-uri": 3.1.2
|
||||
"@jridgewell/sourcemap-codec": 1.5.0
|
||||
|
||||
"@langchain/core@0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))":
|
||||
"@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)":
|
||||
dependencies:
|
||||
"@cfworker/json-schema": 4.1.1
|
||||
"@standard-schema/spec": 1.1.0
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.19
|
||||
langsmith: 0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
|
||||
langsmith: 0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
uuid: 10.0.0
|
||||
zod: 3.24.2
|
||||
zod-to-json-schema: 3.24.5(zod@3.24.2)
|
||||
uuid: 11.1.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- "@opentelemetry/api"
|
||||
- "@opentelemetry/exporter-trace-otlp-proto"
|
||||
- "@opentelemetry/sdk-trace-base"
|
||||
- openai
|
||||
- ws
|
||||
|
||||
"@mistralai/mistralai@1.5.2(zod@3.24.2)":
|
||||
dependencies:
|
||||
@@ -5919,6 +5940,8 @@ snapshots:
|
||||
dependencies:
|
||||
"@sinonjs/commons": 3.0.1
|
||||
|
||||
"@standard-schema/spec@1.1.0": {}
|
||||
|
||||
"@supabase/auth-js@2.68.0":
|
||||
dependencies:
|
||||
"@supabase/node-fetch": 2.6.15
|
||||
@@ -6038,8 +6061,6 @@ snapshots:
|
||||
|
||||
"@types/phoenix@1.6.6": {}
|
||||
|
||||
"@types/retry@0.12.0": {}
|
||||
|
||||
"@types/stack-utils@2.0.3": {}
|
||||
|
||||
"@types/uuid@10.0.0": {}
|
||||
@@ -6307,6 +6328,8 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
char-regex@1.0.2: {}
|
||||
|
||||
charenc@0.0.2: {}
|
||||
@@ -7389,17 +7412,17 @@ snapshots:
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
langsmith@0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)):
|
||||
langsmith@0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1):
|
||||
dependencies:
|
||||
"@types/uuid": 10.0.0
|
||||
chalk: 4.1.2
|
||||
chalk: 5.6.2
|
||||
console-table-printer: 2.12.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
semver: 7.7.1
|
||||
uuid: 10.0.0
|
||||
optionalDependencies:
|
||||
openai: 4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)
|
||||
ws: 8.18.1
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
@@ -7641,11 +7664,6 @@ snapshots:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-retry@4.6.2:
|
||||
dependencies:
|
||||
"@types/retry": 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@3.2.0:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
@@ -7900,8 +7918,6 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rimraf@5.0.10:
|
||||
@@ -8313,6 +8329,8 @@ snapshots:
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
@@ -8415,3 +8433,5 @@ snapshots:
|
||||
zod: 3.24.2
|
||||
|
||||
zod@3.24.2: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
@@ -18,9 +18,26 @@ export type {
|
||||
AllUsers,
|
||||
User,
|
||||
FeedbackPayload,
|
||||
Feedback,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
export { Feedback, WebhookEvent } from "./mem0.types";
|
||||
|
||||
// Export the main client
|
||||
export { MemoryClient };
|
||||
export default MemoryClient;
|
||||
|
||||
// Export structured exceptions
|
||||
export {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
} from "../common/exceptions";
|
||||
|
||||
export type { MemoryErrorOptions } from "../common/exceptions";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -155,7 +156,7 @@ export default class MemoryClient {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new APIError(`API request failed: ${errorData}`);
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
}
|
||||
const jsonResponse = await response.json();
|
||||
return jsonResponse;
|
||||
@@ -200,8 +201,8 @@ export default class MemoryClient {
|
||||
if (project_id && !this.projectId) this.projectId = project_id;
|
||||
if (user_email) this.telemetryId = user_email;
|
||||
} catch (error: any) {
|
||||
// Convert generic errors to APIError with meaningful messages
|
||||
if (error instanceof APIError) {
|
||||
// Pass through structured exceptions and APIError
|
||||
if (error instanceof MemoryError || error instanceof APIError) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new APIError(
|
||||
@@ -310,7 +311,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_all", [payloadKeys]);
|
||||
const { api_version, page, page_size, ...otherOptions } = options!;
|
||||
const { api_version, page, page_size, ...otherOptions } = options ?? {};
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
otherOptions.org_name = this.organizationName;
|
||||
otherOptions.project_name = this.projectName;
|
||||
@@ -361,7 +362,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("search", [payloadKeys]);
|
||||
const { api_version, ...otherOptions } = options!;
|
||||
const { api_version, ...otherOptions } = options ?? {};
|
||||
const payload = { query, ...otherOptions };
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
payload.org_name = this.organizationName;
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface PromptUpdatePayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
enum WebhookEvent {
|
||||
export enum WebhookEvent {
|
||||
MEMORY_ADDED = "memory_add",
|
||||
MEMORY_UPDATED = "memory_update",
|
||||
MEMORY_DELETED = "memory_delete",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Test helpers for MemoryClient unit tests.
|
||||
* Provides mock fetch, factory functions, and constants.
|
||||
*/
|
||||
|
||||
// ─── Mock Fetch ──────────────────────────────────────────
|
||||
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock fetch function that matches URL patterns to responses.
|
||||
* Patterns are matched using string includes, sorted longest-first
|
||||
* so more specific routes (e.g. /v1/memories/search/) win over
|
||||
* broader ones (e.g. /v1/memories/) regardless of insertion order.
|
||||
*/
|
||||
export function createMockFetch(
|
||||
responses: Map<string, MockResponse>,
|
||||
): jest.Mock {
|
||||
return jest.fn(
|
||||
async (url: string | URL | Request, _options?: RequestInit) => {
|
||||
const urlStr =
|
||||
typeof url === "string"
|
||||
? url
|
||||
: url instanceof URL
|
||||
? url.toString()
|
||||
: url.url;
|
||||
|
||||
// Sort patterns longest-first so specific routes match before broad ones
|
||||
const sortedPatterns = [...responses.entries()].sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
);
|
||||
|
||||
for (const [pattern, response] of sortedPatterns) {
|
||||
if (urlStr.includes(pattern)) {
|
||||
return {
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
statusText: response.status === 200 ? "OK" : "Error",
|
||||
json: async () => response.body,
|
||||
text: async () =>
|
||||
typeof response.body === "string"
|
||||
? response.body
|
||||
: JSON.stringify(response.body),
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({ error: "Not found" }),
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Factory Functions ───────────────────────────────────
|
||||
|
||||
export interface MockMemory {
|
||||
id: string;
|
||||
memory?: string;
|
||||
data?: { memory: string } | null;
|
||||
event?: string;
|
||||
user_id?: string;
|
||||
agent_id?: string | null;
|
||||
app_id?: string | null;
|
||||
run_id?: string | null;
|
||||
hash?: string;
|
||||
categories?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
score?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
export function createMockMemory(
|
||||
overrides: Partial<MockMemory> = {},
|
||||
): MockMemory {
|
||||
return {
|
||||
id: "mem_test_123",
|
||||
memory: "Test memory content",
|
||||
user_id: "user_test",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
categories: [],
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockMemoryHistory {
|
||||
id: string;
|
||||
memory_id: string;
|
||||
input: Array<{ role: string; content: string }>;
|
||||
old_memory: string | null;
|
||||
new_memory: string | null;
|
||||
user_id: string;
|
||||
categories: string[];
|
||||
event: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function createMockMemoryHistory(
|
||||
overrides: Partial<MockMemoryHistory> = {},
|
||||
): MockMemoryHistory {
|
||||
return {
|
||||
id: "hist_test_123",
|
||||
memory_id: "mem_test_123",
|
||||
input: [{ role: "user", content: "test" }],
|
||||
old_memory: null,
|
||||
new_memory: "Test memory",
|
||||
user_id: "user_test",
|
||||
categories: [],
|
||||
event: "ADD",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_memories: number;
|
||||
owner: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
|
||||
return {
|
||||
id: "user_123",
|
||||
name: "test_user",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
total_memories: 5,
|
||||
owner: "owner_123",
|
||||
type: "user",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockAllUsers {
|
||||
count: number;
|
||||
results: MockUser[];
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export function createMockAllUsers(users: MockUser[] = []): MockAllUsers {
|
||||
return {
|
||||
count: users.length,
|
||||
results: users,
|
||||
next: null,
|
||||
previous: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
export const TEST_API_KEY = "test-api-key-12345";
|
||||
export const TEST_HOST = "https://api.test.mem0.ai";
|
||||
export const TEST_ORG_ID = "org_test_123";
|
||||
export const TEST_PROJECT_ID = "proj_test_456";
|
||||
|
||||
export const MOCK_PING_RESPONSE = {
|
||||
status: "ok",
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
user_email: "test@example.com",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standard set of mock responses for common MemoryClient operations.
|
||||
* Returns a Map that can be extended with additional patterns before passing to createMockFetch.
|
||||
*/
|
||||
export function createStandardMockResponses(): Map<string, MockResponse> {
|
||||
const responses = new Map<string, MockResponse>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
return responses;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Integration tests: Batch operations.
|
||||
*
|
||||
* Tests batch update and batch delete against the real API.
|
||||
*
|
||||
* Run: MEM0_API_KEY=your-key npx jest batch.test.ts --forceExit
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
describeIntegration,
|
||||
createTestClient,
|
||||
suppressTelemetryNoise,
|
||||
seedTestMemories,
|
||||
cleanupTestUser,
|
||||
} from "./helpers";
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
const TEST_USER_ID = `integration-batch-${randomUUID()}`;
|
||||
|
||||
describeIntegration("MemoryClient Integration — Batch Operations", () => {
|
||||
let client: MemoryClient;
|
||||
let cleanup: () => void;
|
||||
let memoryIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
cleanup = suppressTelemetryNoise();
|
||||
client = createTestClient();
|
||||
memoryIds = await seedTestMemories(client, TEST_USER_ID);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUser(client, TEST_USER_ID);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("batch updates memories", async () => {
|
||||
expect(memoryIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const batchPayload = memoryIds
|
||||
.slice(0, Math.min(2, memoryIds.length))
|
||||
.map((id) => ({
|
||||
memoryId: id,
|
||||
text: `Batch updated content for ${id}`,
|
||||
}));
|
||||
|
||||
const result = await client.batchUpdate(batchPayload);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Verify the update took effect on at least one memory
|
||||
const updated = await client.get(memoryIds[0]);
|
||||
expect(typeof updated.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("batch deletes memories that exist", async () => {
|
||||
// Use one of the seeded memory IDs that we know exists
|
||||
expect(memoryIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const toDelete = [memoryIds[memoryIds.length - 1]];
|
||||
const result = await client.batchDelete(toDelete);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Integration tests: Memory CRUD operations.
|
||||
*
|
||||
* Tests the full lifecycle: add → get → getAll → update → delete.
|
||||
* Validates response shapes against the real API.
|
||||
*
|
||||
* Run: MEM0_API_KEY=your-key npx jest crud.test.ts --forceExit
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import { MemoryError } from "../../../common/exceptions";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
describeIntegration,
|
||||
createTestClient,
|
||||
suppressTelemetryNoise,
|
||||
waitForMemories,
|
||||
cleanupTestUser,
|
||||
} from "./helpers";
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
const TEST_USER_ID = `integration-crud-${randomUUID()}`;
|
||||
|
||||
describeIntegration("MemoryClient Integration — CRUD", () => {
|
||||
let client: MemoryClient;
|
||||
let cleanup: () => void;
|
||||
let memoryIds: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
cleanup = suppressTelemetryNoise();
|
||||
client = createTestClient();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUser(client, TEST_USER_ID);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Add ──────────────────────────────────────────────────
|
||||
describe("add memories", () => {
|
||||
test("add returns a pending response with event_id", async () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "Hi, I'm integration-test-user. My favorite color is blue.",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"Nice to meet you! I'll remember that your favorite color is blue.",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await client.add(messages, { user_id: TEST_USER_ID });
|
||||
|
||||
// API processes memories asynchronously — returns PENDING
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate response shape
|
||||
for (const item of result) {
|
||||
expect(item).toHaveProperty("status");
|
||||
expect(item).toHaveProperty("event_id");
|
||||
}
|
||||
});
|
||||
|
||||
test("adds a second batch of messages", async () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "I work as a software engineer at Acme Corp.",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Got it, you're a software engineer at Acme Corp!",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await client.add(messages, { user_id: TEST_USER_ID });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("memories become available after async processing", async () => {
|
||||
const memories = await waitForMemories(client, TEST_USER_ID, 1);
|
||||
|
||||
expect(memories.length).toBeGreaterThan(0);
|
||||
|
||||
// Store IDs for later tests
|
||||
memoryIds = memories.map((m) => m.id);
|
||||
expect(memoryIds.length).toBeGreaterThan(0);
|
||||
expect(typeof memoryIds[0]).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Get by ID ────────────────────────────────────────────
|
||||
describe("get memory by ID", () => {
|
||||
test("retrieves a specific memory with correct shape", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
expect(memoryId).toBeDefined();
|
||||
|
||||
const memory = await client.get(memoryId);
|
||||
|
||||
expect(memory.id).toBe(memoryId);
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
expect(memory.memory!.length).toBeGreaterThan(0);
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Get all ──────────────────────────────────────────────
|
||||
describe("get all memories", () => {
|
||||
test("returns all memories for test user", async () => {
|
||||
const memories = await client.getAll({ user_id: TEST_USER_ID });
|
||||
|
||||
expect(Array.isArray(memories)).toBe(true);
|
||||
expect(memories.length).toBeGreaterThanOrEqual(memoryIds.length);
|
||||
|
||||
for (const mem of memories) {
|
||||
expect(typeof mem.id).toBe("string");
|
||||
expect(typeof mem.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns paginated results with page and page_size", async () => {
|
||||
const page1 = await client.getAll({
|
||||
user_id: TEST_USER_ID,
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
});
|
||||
|
||||
// Paginated response is an object with results array
|
||||
expect(page1).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────
|
||||
describe("update memory", () => {
|
||||
test("updates memory text and verifies the content changed", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
|
||||
// Read original text before update
|
||||
const original = await client.get(memoryId);
|
||||
const originalText = original.memory;
|
||||
|
||||
await client.update(memoryId, {
|
||||
text: "My favorite color is green (updated)",
|
||||
});
|
||||
|
||||
const updated = await client.get(memoryId);
|
||||
expect(typeof updated.memory).toBe("string");
|
||||
expect(updated.memory).not.toBe(originalText);
|
||||
});
|
||||
|
||||
test("updates memory metadata", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
|
||||
await client.update(memoryId, {
|
||||
metadata: { source: "integration-test", priority: "high" },
|
||||
});
|
||||
|
||||
const updated = await client.get(memoryId);
|
||||
expect(updated.metadata).toBeDefined();
|
||||
expect(updated.metadata.source).toBe("integration-test");
|
||||
expect(updated.metadata.priority).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete single ────────────────────────────────────────
|
||||
describe("delete memory", () => {
|
||||
test("deletes a single memory by ID", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
expect(memoryId).toBeDefined();
|
||||
|
||||
const result = await client.delete(memoryId);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.message).toBe("string");
|
||||
});
|
||||
|
||||
test("getting deleted memory throws MemoryError", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
await expect(client.get(memoryId)).rejects.toThrow(MemoryError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────
|
||||
describe("edge cases", () => {
|
||||
test("add with metadata attaches metadata to the memory", async () => {
|
||||
const result = await client.add(
|
||||
[
|
||||
{ role: "user" as const, content: "I prefer dark mode in all apps." },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Noted, dark mode preference saved!",
|
||||
},
|
||||
],
|
||||
{
|
||||
user_id: TEST_USER_ID,
|
||||
metadata: { source: "integration-test", category: "preferences" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("getAll for non-existent user returns empty array", async () => {
|
||||
const memories = await client.getAll({
|
||||
user_id: `nonexistent-user-${randomUUID()}`,
|
||||
});
|
||||
|
||||
expect(Array.isArray(memories)).toBe(true);
|
||||
expect(memories.length).toBe(0);
|
||||
});
|
||||
|
||||
test("deleteAll for non-existent user does not throw", async () => {
|
||||
const result = await client.deleteAll({
|
||||
user_id: `nonexistent-user-${randomUUID()}`,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.message).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete all + delete user ─────────────────────────────
|
||||
describe("cleanup operations", () => {
|
||||
test("deletes all memories for test user", async () => {
|
||||
const result = await client.deleteAll({ user_id: TEST_USER_ID });
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.message).toBe("string");
|
||||
});
|
||||
|
||||
test("deletes the test user entity", async () => {
|
||||
const result = await client.deleteUsers({ user_id: TEST_USER_ID });
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Jest global setup for integration tests.
|
||||
*
|
||||
* Runs a full project cleanup before any integration test starts,
|
||||
* then waits 10 seconds for the async cleanup to propagate.
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
|
||||
export default async function globalSetup() {
|
||||
const apiKey = process.env.MEM0_API_KEY;
|
||||
if (!apiKey) return; // skip if no key — tests will be skipped too
|
||||
|
||||
const client = new MemoryClient({ apiKey });
|
||||
await client.ping();
|
||||
|
||||
console.log("[integration] Running pre-test cleanup...");
|
||||
|
||||
// Full project wipe — all four filters set explicitly
|
||||
try {
|
||||
await client.deleteAll({
|
||||
user_id: "*",
|
||||
agent_id: "*",
|
||||
app_id: "*",
|
||||
run_id: "*",
|
||||
});
|
||||
} catch {
|
||||
// ignore — may 404 if no data exists
|
||||
}
|
||||
|
||||
try {
|
||||
await client.deleteUsers();
|
||||
} catch {
|
||||
// ignore — may throw "No entities to delete"
|
||||
}
|
||||
|
||||
// Wait 10 seconds for async cleanup to propagate
|
||||
console.log("[integration] Waiting 10s for cleanup to propagate...");
|
||||
await new Promise((r) => setTimeout(r, 10_000));
|
||||
console.log("[integration] Pre-test cleanup done.");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Jest global teardown for integration tests.
|
||||
*
|
||||
* Runs a full project cleanup after all integration tests complete
|
||||
* so no test data is left behind.
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
|
||||
export default async function globalTeardown() {
|
||||
const apiKey = process.env.MEM0_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
const client = new MemoryClient({ apiKey });
|
||||
await client.ping();
|
||||
|
||||
console.log("[integration] Running post-test cleanup...");
|
||||
|
||||
try {
|
||||
await client.deleteAll({
|
||||
user_id: "*",
|
||||
agent_id: "*",
|
||||
app_id: "*",
|
||||
run_id: "*",
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
await client.deleteUsers();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log("[integration] Post-test cleanup done.");
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Shared helpers for MemoryClient real integration tests.
|
||||
*
|
||||
* Provides environment gating, client factory, polling helpers,
|
||||
* and console suppression for telemetry noise.
|
||||
*
|
||||
* All helpers use only the SDK's public API — no internal method access.
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import type { Memory } from "../../mem0.types";
|
||||
import { NetworkError, RateLimitError } from "../../../common/exceptions";
|
||||
|
||||
// ─── Environment gate ────────────────────────────────────
|
||||
export const API_KEY = process.env.MEM0_API_KEY;
|
||||
export const describeIntegration = API_KEY ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Create a MemoryClient with the real API key.
|
||||
* Call this inside beforeAll — not at module scope — so it only
|
||||
* runs when the suite is not skipped.
|
||||
*/
|
||||
export function createTestClient(): MemoryClient {
|
||||
return new MemoryClient({ apiKey: API_KEY! });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an async SDK call on transient errors (NetworkError, RateLimitError).
|
||||
* Use this to wrap any SDK call that may flake in CI.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries = 2,
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
const isTransient =
|
||||
error instanceof NetworkError || error instanceof RateLimitError;
|
||||
if (isTransient && attempt < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 3_000 * attempt));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("withRetry: unreachable");
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll getAll until memories appear for a user.
|
||||
* The Mem0 API processes memories asynchronously — after add()
|
||||
* we need to wait for them to be available.
|
||||
*/
|
||||
export async function waitForMemories(
|
||||
client: MemoryClient,
|
||||
userId: string,
|
||||
minCount: number,
|
||||
maxWaitMs = 60_000,
|
||||
): Promise<Memory[]> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const memories = await withRetry(() => client.getAll({ user_id: userId }));
|
||||
if (Array.isArray(memories) && memories.length >= minCount) {
|
||||
return memories;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
return await client.getAll({ user_id: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll search until results appear. Only used by search tests —
|
||||
* other test files should NOT call this to avoid wasting API credits.
|
||||
*/
|
||||
export async function waitForSearchResults(
|
||||
client: MemoryClient,
|
||||
query: string,
|
||||
options: Record<string, any>,
|
||||
maxWaitMs = 60_000,
|
||||
): Promise<Memory[]> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const results = await withRetry(() => client.search(query, options));
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
return results;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
return await client.search(query, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress telemetry console noise during tests.
|
||||
* Returns a cleanup function to call in afterAll.
|
||||
*/
|
||||
export function suppressTelemetryNoise(): () => void {
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
if (
|
||||
String(args[0] ?? "").match(
|
||||
/Telemetry|Failed to initialize|Failed to capture/,
|
||||
)
|
||||
)
|
||||
return;
|
||||
originalError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return;
|
||||
originalWarn(...args);
|
||||
});
|
||||
|
||||
return () => jest.restoreAllMocks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add test memories and wait for them to be processed.
|
||||
* Returns the memory IDs once available via getAll.
|
||||
*
|
||||
* NOTE: This only waits for the listing index. If your test needs
|
||||
* search results, call waitForSearchResults() separately.
|
||||
*/
|
||||
export async function seedTestMemories(
|
||||
client: MemoryClient,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
await withRetry(() =>
|
||||
client.add(
|
||||
[
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "Hi, I'm integration-test-user. My favorite color is blue.",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"Nice to meet you! I'll remember that your favorite color is blue.",
|
||||
},
|
||||
],
|
||||
{ user_id: userId },
|
||||
),
|
||||
);
|
||||
|
||||
await withRetry(() =>
|
||||
client.add(
|
||||
[
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "I work as a software engineer at Acme Corp.",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Got it, you're a software engineer at Acme Corp!",
|
||||
},
|
||||
],
|
||||
{ user_id: userId },
|
||||
),
|
||||
);
|
||||
|
||||
const memories = await waitForMemories(client, userId, 1);
|
||||
return memories.map((m) => m.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all test data for a user. Best-effort — ignores errors.
|
||||
*/
|
||||
export async function cleanupTestUser(
|
||||
client: MemoryClient,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.deleteAll({ user_id: userId });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await client.deleteUsers({ user_id: userId });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full project wipe — deletes all memories and all entities.
|
||||
* Equivalent to Python SDK's:
|
||||
* client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
|
||||
*
|
||||
* Used as cleanup before and after integration test runs so tests
|
||||
* start from a clean slate and don't leave data behind.
|
||||
*/
|
||||
export async function fullProjectCleanup(client: MemoryClient): Promise<void> {
|
||||
// Delete all memories — all four filters set explicitly
|
||||
try {
|
||||
await client.deleteAll({
|
||||
user_id: "*",
|
||||
agent_id: "*",
|
||||
app_id: "*",
|
||||
run_id: "*",
|
||||
});
|
||||
} catch {
|
||||
// ignore — may 404 if no data exists
|
||||
}
|
||||
|
||||
// Delete all entities (users, agents, apps, runs)
|
||||
try {
|
||||
await client.deleteUsers();
|
||||
} catch {
|
||||
// ignore — may throw "No entities to delete"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Integration tests: Client initialization and error handling.
|
||||
*
|
||||
* Tests ping, org/project resolution, and invalid credentials.
|
||||
* These tests do NOT need pre-seeded memories.
|
||||
*
|
||||
* Run: MEM0_API_KEY=your-key npx jest initialization.test.ts --forceExit
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import {
|
||||
MemoryError,
|
||||
MemoryNotFoundError,
|
||||
ValidationError,
|
||||
} from "../../../common/exceptions";
|
||||
import {
|
||||
describeIntegration,
|
||||
createTestClient,
|
||||
suppressTelemetryNoise,
|
||||
} from "./helpers";
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describeIntegration("MemoryClient Integration — Initialization", () => {
|
||||
let client: MemoryClient;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeAll(() => {
|
||||
cleanup = suppressTelemetryNoise();
|
||||
client = createTestClient();
|
||||
});
|
||||
|
||||
afterAll(() => cleanup());
|
||||
|
||||
test("client pings successfully and resolves org/project", async () => {
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBeTruthy();
|
||||
expect(client.projectId).toBeTruthy();
|
||||
});
|
||||
|
||||
test("get with invalid ID throws ValidationError", async () => {
|
||||
// Non-UUID string triggers a 400 ValidationError, not a 404
|
||||
await expect(client.get("nonexistent-memory-id-12345")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("get with non-existent UUID throws MemoryNotFoundError", async () => {
|
||||
await expect(
|
||||
client.get("00000000-0000-0000-0000-000000000000"),
|
||||
).rejects.toThrow(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("all SDK exceptions are MemoryError subclasses", async () => {
|
||||
await expect(client.get("nonexistent-memory-id-12345")).rejects.toThrow(
|
||||
MemoryError,
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid API key throws on ping", async () => {
|
||||
const badClient = new MemoryClient({ apiKey: "invalid-key-12345" });
|
||||
await expect(badClient.ping()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Integration tests: User management and project configuration.
|
||||
*
|
||||
* Tests users(), getProject(), and updateProject() against the real API.
|
||||
*
|
||||
* Note: Webhook tests (createWebhook, updateWebhook) are excluded because
|
||||
* the SDK has a known bug where it sends camelCase keys (eventTypes) instead
|
||||
* of snake_case (event_types). These will be added once the SDK is fixed.
|
||||
*
|
||||
* Run: MEM0_API_KEY=your-key npx jest management.test.ts --forceExit
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
describeIntegration,
|
||||
createTestClient,
|
||||
suppressTelemetryNoise,
|
||||
seedTestMemories,
|
||||
cleanupTestUser,
|
||||
} from "./helpers";
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
const TEST_USER_ID = `integration-mgmt-${randomUUID()}`;
|
||||
|
||||
describeIntegration("MemoryClient Integration — Users & Project", () => {
|
||||
let client: MemoryClient;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
cleanup = suppressTelemetryNoise();
|
||||
client = createTestClient();
|
||||
await seedTestMemories(client, TEST_USER_ID);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUser(client, TEST_USER_ID);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────
|
||||
describe("user management", () => {
|
||||
test("lists users and finds test user", async () => {
|
||||
const allUsers = await client.users();
|
||||
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
expect(Array.isArray(allUsers.results)).toBe(true);
|
||||
|
||||
if (allUsers.results.length > 0) {
|
||||
const user = allUsers.results[0];
|
||||
expect(typeof user.id).toBe("string");
|
||||
expect(typeof user.name).toBe("string");
|
||||
expect(typeof user.type).toBe("string");
|
||||
}
|
||||
|
||||
const testUser = allUsers.results.find((u) => u.name === TEST_USER_ID);
|
||||
expect(testUser).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Project ──────────────────────────────────────────────
|
||||
describe("project management", () => {
|
||||
let originalInstructions: string | undefined;
|
||||
|
||||
test("gets project with custom_instructions field", async () => {
|
||||
const project = await client.getProject({
|
||||
fields: ["custom_instructions"],
|
||||
});
|
||||
|
||||
expect(project).toBeDefined();
|
||||
expect(typeof project).toBe("object");
|
||||
expect("custom_instructions" in project).toBe(true);
|
||||
|
||||
originalInstructions = project.custom_instructions;
|
||||
});
|
||||
|
||||
test("updates project custom_instructions via updateProject()", async () => {
|
||||
const testInstruction = `integration-test-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const result = await client.updateProject({
|
||||
custom_instructions: testInstruction,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Verify the update took effect
|
||||
const project = await client.getProject({
|
||||
fields: ["custom_instructions"],
|
||||
});
|
||||
expect(project.custom_instructions).toBe(testInstruction);
|
||||
|
||||
// Restore original
|
||||
await client.updateProject({
|
||||
custom_instructions: originalInstructions || "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration tests: Search and history operations.
|
||||
*
|
||||
* Tests search v1, search v2, and memory history against the real API.
|
||||
*
|
||||
* Run: MEM0_API_KEY=your-key npx jest search.test.ts --forceExit
|
||||
*/
|
||||
import { MemoryClient } from "../../mem0";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
describeIntegration,
|
||||
createTestClient,
|
||||
suppressTelemetryNoise,
|
||||
seedTestMemories,
|
||||
cleanupTestUser,
|
||||
waitForSearchResults,
|
||||
} from "./helpers";
|
||||
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
const TEST_USER_ID = `integration-search-${randomUUID()}`;
|
||||
|
||||
describeIntegration("MemoryClient Integration — Search & History", () => {
|
||||
let client: MemoryClient;
|
||||
let cleanup: () => void;
|
||||
let memoryIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
cleanup = suppressTelemetryNoise();
|
||||
client = createTestClient();
|
||||
memoryIds = await seedTestMemories(client, TEST_USER_ID);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUser(client, TEST_USER_ID);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Search v1 ────────────────────────────────────────────
|
||||
describe("search v1", () => {
|
||||
test("searches memories by user_id and returns results with scores", async () => {
|
||||
// Search index may lag behind listing index — poll until ready
|
||||
const results = await waitForSearchResults(
|
||||
client,
|
||||
"What is my favorite color?",
|
||||
{ user_id: TEST_USER_ID },
|
||||
);
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
const first = results[0];
|
||||
expect(typeof first.id).toBe("string");
|
||||
expect(typeof first.memory).toBe("string");
|
||||
expect(typeof first.score).toBe("number");
|
||||
expect(first.score).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search v2 ────────────────────────────────────────────
|
||||
describe("search v2", () => {
|
||||
test("searches with OR filters and returns results", async () => {
|
||||
const results = await waitForSearchResults(
|
||||
client,
|
||||
"What do you know about me?",
|
||||
{
|
||||
filters: { OR: [{ user_id: TEST_USER_ID }] },
|
||||
api_version: "v2",
|
||||
},
|
||||
);
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
const first = results[0];
|
||||
expect(typeof first.id).toBe("string");
|
||||
expect(typeof first.memory).toBe("string");
|
||||
expect(typeof first.score).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── History ──────────────────────────────────────────────
|
||||
describe("memory history", () => {
|
||||
test("returns history with at least an ADD event", async () => {
|
||||
const memoryId = memoryIds[0];
|
||||
const history = await client.history(memoryId);
|
||||
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const entry = history[0];
|
||||
expect(typeof entry.id).toBe("string");
|
||||
expect(typeof entry.memory_id).toBe("string");
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event);
|
||||
expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date");
|
||||
expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date");
|
||||
expect(
|
||||
entry.new_memory === null || typeof entry.new_memory === "string",
|
||||
).toBe(true);
|
||||
expect(
|
||||
entry.old_memory === null || typeof entry.old_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
const events = history.map((h) => h.event);
|
||||
expect(events).toContain("ADD");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases ─────────────────────────────────────────
|
||||
describe("edge cases", () => {
|
||||
test("search for non-existent user returns empty results", async () => {
|
||||
const results = await client.search("anything", {
|
||||
user_id: `nonexistent-user-${randomUUID()}`,
|
||||
});
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("search with limit param does not throw", async () => {
|
||||
const results = await client.search(
|
||||
"Tell me about integration test user",
|
||||
{
|
||||
user_id: TEST_USER_ID,
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — batchUpdate, batchDelete.
|
||||
* Tests verify payload transformation (memoryId → memory_id, string → object).
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── batchUpdate() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchUpdate()", () => {
|
||||
test("sends PUT to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([{ memoryId: "mem_1", text: "updated 1" }]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("transforms memoryId to memory_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([
|
||||
{ memoryId: "mem_1", text: "updated 1" },
|
||||
{ memoryId: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memories).toEqual([
|
||||
{ memory_id: "mem_1", text: "updated 1" },
|
||||
{ memory_id: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batchDelete() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchDelete()", () => {
|
||||
test("sends DELETE to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1"]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "DELETE")).toBeDefined();
|
||||
});
|
||||
|
||||
test("wraps string IDs into {memory_id} objects", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1", "mem_2", "mem_3"]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([
|
||||
{ memory_id: "mem_1" },
|
||||
{ memory_id: "mem_2" },
|
||||
{ memory_id: "mem_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* MemoryClient unit tests — add, get, getAll, update, delete, deleteAll, history.
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory, MemoryHistory } from "../mem0.types";
|
||||
import {
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── add() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - add()", () => {
|
||||
test("sends POST to /v1/memories/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "Hello" }], { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes messages in request body", async () => {
|
||||
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add(messages, { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual(messages);
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], {
|
||||
user_id: "user_1",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("user_1");
|
||||
});
|
||||
|
||||
test("attaches org_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("attaches project_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sends empty messages array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - get()", () => {
|
||||
test("sends GET to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws on 404 with error message from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/nonexistent/", {
|
||||
status: 404,
|
||||
body: "Memory not found",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getAll()", () => {
|
||||
test("uses v2 POST endpoint when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("uses v1 GET endpoint by default with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("appends page and page_size to URL as query params", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({
|
||||
user_id: "u1",
|
||||
api_version: "v2",
|
||||
page: 2,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("page="),
|
||||
);
|
||||
expect(call![0]).toContain("page=2");
|
||||
expect(call![0]).toContain("page_size=25");
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.getAll();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - update()", () => {
|
||||
test("sends PUT to /v1/memories/:id/ with text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { text: "Updated text" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(call).toBeDefined();
|
||||
expect(getFetchBody(call!).text).toBe("Updated text");
|
||||
});
|
||||
|
||||
test("sends metadata in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { metadata: { priority: "high" } });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).metadata).toEqual({ priority: "high" });
|
||||
});
|
||||
|
||||
test("sends timestamp in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { timestamp: 1710600000 });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("includes all fields when text + metadata + timestamp provided", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", {
|
||||
text: "Updated",
|
||||
metadata: { source: "test" },
|
||||
timestamp: 1710600000,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.text).toBe("Updated");
|
||||
expect(body.metadata).toEqual({ source: "test" });
|
||||
expect(body.timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("throws when no fields provided", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.update("mem_123", {})).rejects.toThrow(
|
||||
"At least one of text, metadata, or timestamp must be provided",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - delete()", () => {
|
||||
test("sends DELETE to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: { message: "Memory deleted successfully" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.delete("mem_123");
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem_123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteAll()", () => {
|
||||
test("sends DELETE to /v1/memories/ with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("URL-encodes special characters in user_id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "user@email.com" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - history()", () => {
|
||||
test("sends GET to /v1/memories/:id/history/", async () => {
|
||||
const historyEntries = [
|
||||
createMockMemoryHistory({
|
||||
memory_id: "mem_123",
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "I am Alex",
|
||||
}),
|
||||
];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", {
|
||||
status: 200,
|
||||
body: historyEntries,
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.history("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/history/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles empty history without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: MemoryHistory[] = await client.history("mem_123");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — constructor, validation, ping.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
MemoryNotFoundError,
|
||||
ValidationError,
|
||||
MemoryError,
|
||||
} from "../../common/exceptions";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_HOST,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
installConsoleSuppression,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Initialization", () => {
|
||||
beforeEach(() => setupMockFetch());
|
||||
|
||||
test("throws when API key is empty string", () => {
|
||||
expect(() => new MemoryClient({ apiKey: "" })).toThrow(
|
||||
"Mem0 API key is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is whitespace only", () => {
|
||||
expect(() => new MemoryClient({ apiKey: " " })).toThrow(
|
||||
"Mem0 API key cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is not a string", () => {
|
||||
expect(
|
||||
() => new MemoryClient({ apiKey: 123 as unknown as string }),
|
||||
).toThrow("Mem0 API key must be a string");
|
||||
});
|
||||
|
||||
test("sets default host to https://api.mem0.ai", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.host).toBe("https://api.mem0.ai");
|
||||
});
|
||||
|
||||
test("uses custom host when provided", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY, host: TEST_HOST });
|
||||
expect(client.host).toBe(TEST_HOST);
|
||||
});
|
||||
|
||||
test("sets organizationId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets Authorization header with Token prefix", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.headers["Authorization"]).toBe(`Token ${TEST_API_KEY}`);
|
||||
});
|
||||
|
||||
test("creates axios client with 60s timeout", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.client.defaults.timeout).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ping ────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - ping()", () => {
|
||||
test("sets organizationId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets telemetryId from user_email in response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.telemetryId).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("preserves constructor organizationId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe("my_org");
|
||||
});
|
||||
|
||||
test("preserves constructor projectId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe("my_proj");
|
||||
});
|
||||
|
||||
test("throws AuthenticationError on 401 response", async () => {
|
||||
const { AuthenticationError } = await import("../../common/exceptions");
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 401,
|
||||
body: "Invalid API key",
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: "bad-key" });
|
||||
await expect(client.ping()).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("throws on invalid (non-object) response format", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: "not an object" });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("Invalid response format");
|
||||
});
|
||||
|
||||
test("throws on status !== ok in response", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 200,
|
||||
body: { status: "error", message: "API Key is invalid" },
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("API Key is invalid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Error Handling", () => {
|
||||
test("404 throws MemoryNotFoundError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/gone/", { status: 404, body: "Memory not found" });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("gone")).rejects.toThrow(MemoryNotFoundError);
|
||||
await expect(client.get("gone")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
|
||||
test("500 throws MemoryError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/err/", {
|
||||
status: 500,
|
||||
body: "Internal server error",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("err")).rejects.toThrow(MemoryError);
|
||||
await expect(client.get("err")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
test("400 throws ValidationError with details from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/bad/", {
|
||||
status: 400,
|
||||
body: "Invalid request: user_id is required",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("bad")).rejects.toThrow(ValidationError);
|
||||
await expect(client.get("bad")).rejects.toThrow(
|
||||
"Invalid request: user_id is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("Authorization header is included in fetch calls", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_1/", {
|
||||
status: 200,
|
||||
body: { id: "mem_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_1");
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_1/"),
|
||||
);
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("network failure (fetch throws) is propagated", async () => {
|
||||
global.fetch = jest.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/v1/memories/net_err/")) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (urlStr.includes("/v1/ping/")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => MOCK_PING_RESPONSE,
|
||||
text: async () => JSON.stringify(MOCK_PING_RESPONSE),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("net_err")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getProject, updateProject, exports, feedback.
|
||||
* Tests verify request construction and validation behavior.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { Feedback } from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── getProject() ───────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getProject()", () => {
|
||||
test("throws when organizationId and projectId not set", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: { status: "ok" } });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
try {
|
||||
await client.ping();
|
||||
} catch {
|
||||
// ping might throw — but orgId stays null
|
||||
}
|
||||
|
||||
await expect(
|
||||
client.getProject({ fields: ["custom_instructions"] }),
|
||||
).rejects.toThrow("organizationId and projectId must be set");
|
||||
});
|
||||
|
||||
test("sends GET to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Be helpful" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getProject({ fields: ["custom_instructions"] });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/orgs/organizations/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("fields=custom_instructions");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProject() ────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - updateProject()", () => {
|
||||
test("sends PATCH to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes custom_instructions in PATCH body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(getFetchBody(call!).custom_instructions).toBe(
|
||||
"Updated instructions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── feedback() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - feedback()", () => {
|
||||
test("sends POST to /v1/feedback/ with payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes memory_id, feedback, and reason in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memory_id).toBe("mem_123");
|
||||
expect(body.feedback).toBe("POSITIVE");
|
||||
expect(body.feedback_reason).toBe("Very helpful");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Memory Exports ─────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Memory Exports", () => {
|
||||
test("createMemoryExport throws when missing filters or schema", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(
|
||||
client.createMemoryExport({
|
||||
filters: null as never,
|
||||
schema: null as never,
|
||||
}),
|
||||
).rejects.toThrow("Missing filters or schema");
|
||||
});
|
||||
|
||||
test("createMemoryExport sends POST to /v1/exports/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Export created", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory", "user_id"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createMemoryExport attaches org_id and project_id to body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Created", id: "exp_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/exports/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("getMemoryExport throws when missing both id and filters", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(client.getMemoryExport({} as never)).rejects.toThrow(
|
||||
"Missing memory_export_id or filters",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMemoryExport sends POST to /v1/exports/get/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/get/", {
|
||||
status: 200,
|
||||
body: { message: "Export data", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getMemoryExport({ memory_export_id: "exp_123" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/get/", "POST")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MemoryClient unit tests — search (v1/v2 routing, filters).
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory } from "../mem0.types";
|
||||
import { createMockMemory, TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - search()", () => {
|
||||
test("sends POST to /v1/memories/search/ by default", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes query in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).query).toBe("What is my name?");
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("u1");
|
||||
});
|
||||
|
||||
test("uses /v2/memories/search/ when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("passes filters through to the v2 API body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("query", {
|
||||
api_version: "v2",
|
||||
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.filters).toEqual({
|
||||
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("query");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty results array", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("nonexistent query", {
|
||||
user_id: "u1",
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
import { MemoryClient } from "../mem0";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const apiKey = process.env.MEM0_API_KEY || "";
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationId: "org_gRNd1RrQa4y52iK4tG8o59hXyVbaULikgq4kethC", projectId: "proj_7RfMkWs0PMgXYweGUNKqV9M9mgIRNt5XcupE7mSP" });
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationName: "saket-default-org", projectName: "default-project" });
|
||||
const client = new MemoryClient({ apiKey, host: "https://api.mem0.ai" });
|
||||
|
||||
// Generate a random string
|
||||
const randomString = () => {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
};
|
||||
|
||||
describe("MemoryClient API", () => {
|
||||
let userId: string, memoryId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
userId = randomString();
|
||||
});
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user", content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant", content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
it("should add messages successfully", async () => {
|
||||
const res = await client.add(messages1, { user_id: userId || "" });
|
||||
|
||||
// Validate the response contains an iterable list
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
|
||||
// Validate the fields of the first message in the response
|
||||
const message = res[0];
|
||||
expect(typeof message.id).toBe("string");
|
||||
expect(typeof message.data?.memory).toBe("string");
|
||||
expect(typeof message.event).toBe("string");
|
||||
|
||||
// Store the memory ID for later use
|
||||
memoryId = message.id;
|
||||
});
|
||||
|
||||
it("should retrieve the specific memory by ID", async () => {
|
||||
const memory = await client.get(memoryId);
|
||||
|
||||
// Validate that the memory fields have the correct types and values
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or any object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(Array.isArray(memory.categories) || memory.categories === null).toBe(
|
||||
true,
|
||||
);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
it("should retrieve all users successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
|
||||
// Validate the number of users is a number
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
|
||||
// Validate the structure of the first user
|
||||
const firstUser = allUsers.results[0];
|
||||
expect(typeof firstUser.id).toBe("string");
|
||||
expect(typeof firstUser.name).toBe("string");
|
||||
expect(typeof firstUser.created_at).toBe("string");
|
||||
expect(typeof firstUser.updated_at).toBe("string");
|
||||
expect(typeof firstUser.total_memories).toBe("number");
|
||||
expect(typeof firstUser.type).toBe("string");
|
||||
|
||||
// Find the user with the name matching userId
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
expect(entity).not.toBeUndefined();
|
||||
|
||||
// Store the entity ID for later use
|
||||
const entity_id = entity?.id;
|
||||
expect(typeof entity_id).toBe("string");
|
||||
});
|
||||
|
||||
it("should retrieve all memories for the user", async () => {
|
||||
const res3 = await client.getAll({ user_id: userId });
|
||||
|
||||
// Validate that res3 is an iterable list (array)
|
||||
expect(Array.isArray(res3)).toBe(true);
|
||||
|
||||
if (res3.length > 0) {
|
||||
// Iterate through the first memory for validation (you can loop through all if needed)
|
||||
const memory = res3[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
} else {
|
||||
// If there are no memories, assert that the list is empty
|
||||
expect(res3.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query and filters (API version 2)", async () => {
|
||||
const searchOptionsV2 = {
|
||||
query: "What do you know about me?",
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
};
|
||||
|
||||
const searchResultV2 = await client.search(
|
||||
"What do you know about me?",
|
||||
searchOptionsV2,
|
||||
);
|
||||
|
||||
// Validate that searchResultV2 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV2)).toBe(true);
|
||||
|
||||
if (searchResultV2.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV2[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
if (memory.user_id) {
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
}
|
||||
|
||||
if (memory.agent_id) {
|
||||
// Should be a string (agent_id)
|
||||
expect(typeof memory.agent_id).toBe("string");
|
||||
expect(memory.agent_id).toBe("shopping-assistant");
|
||||
}
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV2.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query (API version 1)", async () => {
|
||||
const searchResultV1 = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
// Validate that searchResultV1 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV1)).toBe(true);
|
||||
|
||||
if (searchResultV1.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV1[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV1.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retrieve history of a specific memory and validate the fields", async () => {
|
||||
const res22 = await client.history(memoryId);
|
||||
|
||||
// Validate that res22 is an iterable list (array)
|
||||
expect(Array.isArray(res22)).toBe(true);
|
||||
|
||||
if (res22.length > 0) {
|
||||
// Iterate through the first history entry for validation (you can loop through all if needed)
|
||||
const historyEntry = res22[0];
|
||||
|
||||
// Should be a string (history entry id)
|
||||
expect(typeof historyEntry.id).toBe("string");
|
||||
|
||||
// Should be a string (memory id related to the history entry)
|
||||
expect(typeof historyEntry.memory_id).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof historyEntry.user_id).toBe("string");
|
||||
expect(historyEntry.user_id).toBe(userId);
|
||||
|
||||
// Should be a string or null (old memory)
|
||||
expect(
|
||||
historyEntry.old_memory === null ||
|
||||
typeof historyEntry.old_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be a string or null (new memory)
|
||||
expect(
|
||||
historyEntry.new_memory === null ||
|
||||
typeof historyEntry.new_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(historyEntry.categories) ||
|
||||
historyEntry.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.categories)) {
|
||||
historyEntry.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(historyEntry.created_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(historyEntry.updated_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a string, one of: ADD, UPDATE, DELETE, NOOP
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(historyEntry.event);
|
||||
|
||||
// Validate conditions based on event type
|
||||
if (historyEntry.event === "ADD") {
|
||||
expect(historyEntry.old_memory).toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "UPDATE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "DELETE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).toBeNull();
|
||||
}
|
||||
|
||||
// Should be a list of objects or null (input)
|
||||
expect(
|
||||
Array.isArray(historyEntry.input) || historyEntry.input === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.input)) {
|
||||
historyEntry.input.forEach((input) => {
|
||||
// Each input should be an object
|
||||
expect(typeof input).toBe("object");
|
||||
|
||||
// Should have string content
|
||||
expect(typeof input.content).toBe("string");
|
||||
|
||||
// Should have a role that is either 'user' or 'assistant'
|
||||
expect(["user", "assistant"]).toContain(input.role);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If no history entries, assert that the list is empty
|
||||
expect(res22.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete the user successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
|
||||
if (entity) {
|
||||
const deletedUser = await client.deleteUser(entity.id);
|
||||
|
||||
// Validate the deletion message
|
||||
expect(deletedUser.message).toBe("Entity deleted successfully!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* MemoryClient unit tests — users, deleteUser, deleteUsers.
|
||||
* Tests verify entity type routing and request construction.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── users() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - users()", () => {
|
||||
test("sends GET to /v1/entities/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser()]),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.users();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/entities/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUsers() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
function createClientWithMockedAxios() {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
const axiosDeleteMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
client.client.delete = axiosDeleteMock;
|
||||
return { client, axiosDeleteMock };
|
||||
}
|
||||
|
||||
test("routes user_id to DELETE /v2/entities/user/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ user_id: "u1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith("/v2/entities/user/u1/", {
|
||||
params: expect.objectContaining({
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes agent_id to DELETE /v2/entities/agent/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ agent_id: "agent_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/agent/agent_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes app_id to DELETE /v2/entities/app/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ app_id: "app_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/app/app_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes run_id to DELETE /v2/entities/run/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ run_id: "run_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/run/run_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 'Entity deleted successfully.' for single entity", async () => {
|
||||
const { client } = createClientWithMockedAxios();
|
||||
const result = await client.deleteUsers({ user_id: "u1" });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
|
||||
test("returns 'All users, agents, apps and runs deleted.' when no params given", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser({ name: "u1", type: "user" })]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
|
||||
const result = await client.deleteUsers();
|
||||
expect(result.message).toBe("All users, agents, apps and runs deleted.");
|
||||
});
|
||||
|
||||
test("throws when no entities exist to delete", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest.fn();
|
||||
|
||||
await expect(client.deleteUsers()).rejects.toThrow("No entities to delete");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUser() (deprecated) ──────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
test("sends DELETE to /v1/entities/:type/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/123/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({
|
||||
entity_id: 123 as never,
|
||||
entity_type: "user",
|
||||
});
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("defaults entity_type to 'user' when empty", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/456/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({ entity_id: 456 as never, entity_type: "" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
|
||||
* Tests verify request URL and HTTP method, not mock response values.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { WebhookEvent } from "../mem0.types";
|
||||
import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - Webhooks", () => {
|
||||
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getWebhooks();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook includes webhook payload in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("new-hook");
|
||||
expect(body.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("updateWebhook includes updated fields in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("updated-hook");
|
||||
expect(body.url).toBe("https://new-url.com");
|
||||
});
|
||||
|
||||
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook deleted" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteWebhook({ webhookId: "wh_1" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared test setup for MemoryClient unit tests.
|
||||
* Provides mock fetch wiring, console suppression, and utility finders.
|
||||
*/
|
||||
import {
|
||||
createMockFetch,
|
||||
createStandardMockResponses,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
// ─── Global fetch mock + telemetry suppression ───────────
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
export function setupMockFetch(
|
||||
extraResponses?: Map<string, { status: number; body: unknown }>,
|
||||
): jest.Mock {
|
||||
const responses = createStandardMockResponses();
|
||||
if (extraResponses) {
|
||||
for (const [key, value] of extraResponses) {
|
||||
responses.set(key, value);
|
||||
}
|
||||
}
|
||||
const mockFetch = createMockFetch(responses);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
export function installConsoleSuppression(): void {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (
|
||||
msg.includes("Telemetry") ||
|
||||
msg.includes("Failed to initialize") ||
|
||||
msg.includes("Failed to capture")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (msg.includes("telemetry") || msg.includes("Telemetry")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helper: find specific fetch calls ───────────────────
|
||||
|
||||
export function findFetchCall(
|
||||
mock: jest.Mock,
|
||||
urlPattern: string,
|
||||
method?: string,
|
||||
): [string, RequestInit] | undefined {
|
||||
return mock.mock.calls.find((call: [string, RequestInit]) => {
|
||||
const urlMatch = call[0].includes(urlPattern);
|
||||
if (!method) return urlMatch;
|
||||
return urlMatch && call[1]?.method === method;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFetchBody(
|
||||
call: [string, RequestInit],
|
||||
): Record<string, unknown> {
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
export { MOCK_PING_RESPONSE };
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
HTTP_STATUS_TO_EXCEPTION,
|
||||
} from "./exceptions";
|
||||
|
||||
describe("MemoryError", () => {
|
||||
const error = new MemoryError("test error", "MEM_001", {
|
||||
details: { operation: "add" },
|
||||
suggestion: "Try again",
|
||||
debugInfo: { requestId: "req_123" },
|
||||
});
|
||||
|
||||
test("is an instance of Error", () => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test("has correct message", () => {
|
||||
expect(error.message).toBe("test error");
|
||||
});
|
||||
|
||||
test("has correct errorCode", () => {
|
||||
expect(error.errorCode).toBe("MEM_001");
|
||||
});
|
||||
|
||||
test("has correct details", () => {
|
||||
expect(error.details).toEqual({ operation: "add" });
|
||||
});
|
||||
|
||||
test("has correct suggestion", () => {
|
||||
expect(error.suggestion).toBe("Try again");
|
||||
});
|
||||
|
||||
test("has correct debugInfo", () => {
|
||||
expect(error.debugInfo).toEqual({ requestId: "req_123" });
|
||||
});
|
||||
|
||||
test("defaults details to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.details).toEqual({});
|
||||
});
|
||||
|
||||
test("defaults suggestion to undefined", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
test("defaults debugInfo to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.debugInfo).toEqual({});
|
||||
});
|
||||
|
||||
test("is throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new MemoryError("fail", "MEM_001");
|
||||
}).toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exception subclasses", () => {
|
||||
const subclasses = [
|
||||
{ Class: AuthenticationError, name: "AuthenticationError" },
|
||||
{ Class: RateLimitError, name: "RateLimitError" },
|
||||
{ Class: ValidationError, name: "ValidationError" },
|
||||
{ Class: MemoryNotFoundError, name: "MemoryNotFoundError" },
|
||||
{ Class: NetworkError, name: "NetworkError" },
|
||||
{ Class: ConfigurationError, name: "ConfigurationError" },
|
||||
{ Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" },
|
||||
] as const;
|
||||
|
||||
test.each(subclasses)("$name extends MemoryError", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name extends Error", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name has correct name", ({ Class, name }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error.name).toBe(name);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name supports instanceof checks", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error instanceof Class).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createExceptionFromResponse", () => {
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 401 to errorCode HTTP_401", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.errorCode).toBe("HTTP_401");
|
||||
});
|
||||
|
||||
test("maps 401 to authentication suggestion", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.suggestion).toBe(
|
||||
"Please check your API key and authentication credentials",
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error).toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 429 passes debugInfo through", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error.debugInfo).toEqual({ retryAfter: 60 });
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
const error = createExceptionFromResponse(404, "Not found");
|
||||
expect(error).toBeInstanceOf(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 400 to ValidationError", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request");
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
const error = createExceptionFromResponse(413, "Quota exceeded");
|
||||
expect(error).toBeInstanceOf(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test.each([502, 503, 504])("maps %i to NetworkError", (code) => {
|
||||
const error = createExceptionFromResponse(code, "Service unavailable");
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 500 to errorCode HTTP_500", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error.errorCode).toBe("HTTP_500");
|
||||
});
|
||||
|
||||
test("maps unknown status to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps unknown status to correct errorCode", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.errorCode).toBe("HTTP_418");
|
||||
});
|
||||
|
||||
test("maps unknown status to retry suggestion", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.suggestion).toBe("Please try again later");
|
||||
});
|
||||
|
||||
test("uses response text as message", () => {
|
||||
const error = createExceptionFromResponse(400, "Invalid user_id format");
|
||||
expect(error.message).toBe("Invalid user_id format");
|
||||
});
|
||||
|
||||
test("falls back to generic message when response text is empty", () => {
|
||||
const error = createExceptionFromResponse(500, "");
|
||||
expect(error.message).toBe("HTTP 500 error");
|
||||
});
|
||||
|
||||
test("passes details through", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request", {
|
||||
details: { field: "user_id", value: "" },
|
||||
});
|
||||
expect(error.details).toEqual({ field: "user_id", value: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP_STATUS_TO_EXCEPTION", () => {
|
||||
test("maps 400 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 403 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 408 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 409 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test("maps 422 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 502 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 503 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 504 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Structured exception classes for mem0 TypeScript SDK.
|
||||
*
|
||||
* Provides specific, actionable exceptions with error codes, suggestions,
|
||||
* and debug information. Maps HTTP status codes to appropriate exception types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { RateLimitError, MemoryNotFoundError } from 'mem0ai'
|
||||
*
|
||||
* try {
|
||||
* await client.get(memoryId)
|
||||
* } catch (e) {
|
||||
* if (e instanceof MemoryNotFoundError) {
|
||||
* console.log(e.suggestion) // "The requested resource was not found"
|
||||
* } else if (e instanceof RateLimitError) {
|
||||
* await sleep(e.debugInfo.retryAfter ?? 60)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface MemoryErrorOptions {
|
||||
details?: Record<string, unknown>;
|
||||
suggestion?: string;
|
||||
debugInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base exception for all memory-related errors.
|
||||
*
|
||||
* Every mem0 exception includes an error code for programmatic handling,
|
||||
* optional details, a user-friendly suggestion, and debug information.
|
||||
*/
|
||||
export class MemoryError extends Error {
|
||||
readonly errorCode: string;
|
||||
readonly details: Record<string, unknown>;
|
||||
readonly suggestion?: string;
|
||||
readonly debugInfo: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options: MemoryErrorOptions = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MemoryError";
|
||||
this.errorCode = errorCode;
|
||||
this.details = options.details ?? {};
|
||||
this.suggestion = options.suggestion;
|
||||
this.debugInfo = options.debugInfo ?? {};
|
||||
|
||||
// Fix prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when authentication fails (401, 403). */
|
||||
export class AuthenticationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when rate limits are exceeded (429). */
|
||||
export class RateLimitError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when input validation fails (400, 409, 422). */
|
||||
export class ValidationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a memory is not found (404). */
|
||||
export class MemoryNotFoundError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when network connectivity issues occur (408, 502, 503, 504). */
|
||||
export class NetworkError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when client configuration is invalid. */
|
||||
export class ConfigurationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when memory quota is exceeded (413). */
|
||||
export class MemoryQuotaExceededError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryQuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Status → Exception Mapping ─────────────────────
|
||||
|
||||
type MemoryErrorConstructor = new (
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) => MemoryError;
|
||||
|
||||
export const HTTP_STATUS_TO_EXCEPTION: Record<number, MemoryErrorConstructor> =
|
||||
{
|
||||
400: ValidationError,
|
||||
401: AuthenticationError,
|
||||
403: AuthenticationError,
|
||||
404: MemoryNotFoundError,
|
||||
408: NetworkError,
|
||||
409: ValidationError,
|
||||
413: MemoryQuotaExceededError,
|
||||
422: ValidationError,
|
||||
429: RateLimitError,
|
||||
500: MemoryError,
|
||||
502: NetworkError,
|
||||
503: NetworkError,
|
||||
504: NetworkError,
|
||||
};
|
||||
|
||||
const HTTP_SUGGESTIONS: Record<number, string> = {
|
||||
400: "Please check your request parameters and try again",
|
||||
401: "Please check your API key and authentication credentials",
|
||||
403: "You don't have permission to perform this operation",
|
||||
404: "The requested resource was not found",
|
||||
408: "Request timed out. Please try again",
|
||||
409: "Resource conflict. Please check your request",
|
||||
413: "Request too large. Please reduce the size of your request",
|
||||
422: "Invalid request data. Please check your input",
|
||||
429: "Rate limit exceeded. Please wait before making more requests",
|
||||
500: "Internal server error. Please try again later",
|
||||
502: "Service temporarily unavailable. Please try again later",
|
||||
503: "Service unavailable. Please try again later",
|
||||
504: "Gateway timeout. Please try again later",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an appropriate exception based on HTTP response status code.
|
||||
*
|
||||
* @param statusCode - HTTP status code from the response
|
||||
* @param responseText - Response body text
|
||||
* @param options - Additional error context (details, debugInfo)
|
||||
* @returns An instance of the appropriate MemoryError subclass
|
||||
*/
|
||||
export function createExceptionFromResponse(
|
||||
statusCode: number,
|
||||
responseText: string,
|
||||
options: Omit<MemoryErrorOptions, "suggestion"> = {},
|
||||
): MemoryError {
|
||||
const ExceptionClass = HTTP_STATUS_TO_EXCEPTION[statusCode] ?? MemoryError;
|
||||
const errorCode = `HTTP_${statusCode}`;
|
||||
const suggestion = HTTP_SUGGESTIONS[statusCode] ?? "Please try again later";
|
||||
|
||||
return new ExceptionClass(
|
||||
responseText || `HTTP ${statusCode} error`,
|
||||
errorCode,
|
||||
{ ...options, suggestion },
|
||||
);
|
||||
}
|
||||
@@ -20,15 +20,28 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const baseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
userConf?.url;
|
||||
const embeddingDims =
|
||||
userConf?.embeddingDims ??
|
||||
((userConf as Record<string, unknown>)?.embedding_dims as
|
||||
| number
|
||||
| undefined);
|
||||
|
||||
return {
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
: defaultConf.apiKey,
|
||||
model: finalModel,
|
||||
baseURL: userConf?.baseURL,
|
||||
baseURL,
|
||||
url: userConf?.url,
|
||||
embeddingDims: userConf?.embeddingDims,
|
||||
embeddingDims,
|
||||
modelProperties:
|
||||
userConf?.modelProperties !== undefined
|
||||
? userConf.modelProperties
|
||||
@@ -91,8 +104,17 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const llmBaseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
defaultConf.baseURL;
|
||||
|
||||
return {
|
||||
baseURL: userConf?.baseURL || defaultConf.baseURL,
|
||||
baseURL: llmBaseURL,
|
||||
url: userConf?.url,
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import OpenAI from "openai";
|
||||
import { Embedder } from "./base";
|
||||
import { EmbeddingConfig } from "../types";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioEmbedder implements Embedder {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
|
||||
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
|
||||
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
|
||||
this.model = config.model || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const normalized =
|
||||
typeof text === "string" ? text.replace(/\n/g, " ") : String(text);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
const normalized = texts.map((t) =>
|
||||
typeof t === "string" ? t.replace(/\n/g, " ") : String(t),
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class OllamaEmbedder implements Embedder {
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.url || "http://localhost:11434",
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "nomic-embed-text:latest";
|
||||
this.embeddingDims = config.embeddingDims || 768;
|
||||
@@ -27,14 +27,18 @@ export class OllamaEmbedder implements Embedder {
|
||||
} catch (err) {
|
||||
logger.error(`Error ensuring model exists: ${err}`);
|
||||
}
|
||||
// Ollama's Go server requires prompt to be a string. Coerce defensively
|
||||
// since callers may pass values parsed from untrusted LLM JSON output.
|
||||
const prompt = typeof text === "string" ? text : JSON.stringify(text);
|
||||
const response = await this.ollama.embeddings({
|
||||
// Coerce defensively since callers may pass values parsed from untrusted LLM JSON output.
|
||||
const input = typeof text === "string" ? text : JSON.stringify(text);
|
||||
const response = await this.ollama.embed({
|
||||
model: this.model,
|
||||
prompt,
|
||||
input,
|
||||
});
|
||||
return response.embedding;
|
||||
if (!response.embeddings || response.embeddings.length === 0) {
|
||||
throw new Error(
|
||||
`Ollama embed() returned no embeddings for model '${this.model}'`,
|
||||
);
|
||||
}
|
||||
return response.embeddings[0];
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
@@ -42,12 +46,21 @@ export class OllamaEmbedder implements Embedder {
|
||||
return response;
|
||||
}
|
||||
|
||||
private static normalizeModelName(name: string): string {
|
||||
return name.includes(":") ? name : `${name}:latest`;
|
||||
}
|
||||
|
||||
private async ensureModelExists(): Promise<boolean> {
|
||||
if (this.initialized) {
|
||||
return true;
|
||||
}
|
||||
const local_models = await this.ollama.list();
|
||||
if (!local_models.models.find((m: any) => m.name === this.model)) {
|
||||
const target = OllamaEmbedder.normalizeModelName(this.model);
|
||||
if (
|
||||
!local_models.models.find(
|
||||
(m: any) => OllamaEmbedder.normalizeModelName(m.name) === target,
|
||||
)
|
||||
) {
|
||||
logger.info(`Pulling model ${this.model}...`);
|
||||
await this.ollama.pull({ model: this.model });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./types";
|
||||
export * from "./embeddings/base";
|
||||
export * from "./embeddings/openai";
|
||||
export * from "./embeddings/ollama";
|
||||
export * from "./embeddings/lmstudio";
|
||||
export * from "./embeddings/google";
|
||||
export * from "./embeddings/azure";
|
||||
export * from "./embeddings/langchain";
|
||||
@@ -14,6 +15,7 @@ export * from "./llms/openai_structured";
|
||||
export * from "./llms/anthropic";
|
||||
export * from "./llms/groq";
|
||||
export * from "./llms/ollama";
|
||||
export * from "./llms/lmstudio";
|
||||
export * from "./llms/mistral";
|
||||
export * from "./llms/langchain";
|
||||
export * from "./vector_stores/base";
|
||||
|
||||
@@ -16,25 +16,50 @@ export class GoogleLLM implements LLM {
|
||||
responseFormat?: { type: string },
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
const completion = await this.google.models.generateContent({
|
||||
contents: messages.map((msg) => ({
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
},
|
||||
],
|
||||
role: msg.role === "system" ? "model" : "user",
|
||||
})),
|
||||
const contents = messages.map((msg) => ({
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
},
|
||||
],
|
||||
role: msg.role === "system" ? "model" : "user",
|
||||
}));
|
||||
|
||||
// Build config with tools if provided
|
||||
const config: Record<string, any> = {};
|
||||
if (tools && tools.length > 0) {
|
||||
config.tools = [
|
||||
{
|
||||
functionDeclarations: tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const completion = await this.google.models.generateContent({
|
||||
contents,
|
||||
model: this.model,
|
||||
// config: {
|
||||
// responseSchema: {}, // Add response schema if needed
|
||||
// },
|
||||
config,
|
||||
});
|
||||
|
||||
// Handle function call responses
|
||||
if (completion.functionCalls && completion.functionCalls.length > 0) {
|
||||
return {
|
||||
content: completion.text || "",
|
||||
role: "assistant",
|
||||
toolCalls: completion.functionCalls.map((call) => ({
|
||||
name: call.name!,
|
||||
arguments: JSON.stringify(call.args),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const text = completion.text
|
||||
?.replace(/^```json\n/, "")
|
||||
.replace(/\n```$/, "");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { OpenAILLM } from "./openai";
|
||||
import { LLMConfig, Message } from "../types";
|
||||
import { LLMResponse } from "./base";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioLLM extends OpenAILLM {
|
||||
constructor(config: LLMConfig) {
|
||||
super({
|
||||
...config,
|
||||
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
model: config.model || DEFAULT_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
messages: Message[],
|
||||
responseFormat?: { type: string },
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
try {
|
||||
return await super.generateResponse(messages, responseFormat, tools);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
||||
try {
|
||||
return await super.generateChat(messages);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.config?.url || "http://localhost:11434",
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "llama3.1:8b";
|
||||
this.ensureModelExists().catch((err) => {
|
||||
|
||||
@@ -278,9 +278,7 @@ 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();
|
||||
// Extract content inside code fences, handling both complete and
|
||||
// truncated blocks (where the closing ``` never arrives).
|
||||
return text.replace(/```(?:\w+)?\n?([\s\S]*?)(?:```|$)/g, "$1").trim();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("backward compat: ConfigManager.mergeConfig", () => {
|
||||
expect(cfg.embedder.provider).toBe("openai");
|
||||
expect(cfg.vectorStore.provider).toBe("memory");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("memories");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(1536);
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
expect(cfg.llm.provider).toBe("openai");
|
||||
expect(cfg.historyStore).toBeDefined();
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface HistoryStoreConfig {
|
||||
export interface LLMConfig {
|
||||
provider?: string;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
config?: Record<string, any>;
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
@@ -143,6 +144,7 @@ export const MemoryConfigSchema = z.object({
|
||||
model: z.union([z.string(), z.any()]).optional(),
|
||||
modelProperties: z.record(z.string(), z.any()).optional(),
|
||||
baseURL: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
historyDbPath: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OpenAIEmbedder } from "../embeddings/openai";
|
||||
import { OllamaEmbedder } from "../embeddings/ollama";
|
||||
import { LMStudioEmbedder } from "../embeddings/lmstudio";
|
||||
import { OpenAILLM } from "../llms/openai";
|
||||
import { OpenAIStructuredLLM } from "../llms/openai_structured";
|
||||
import { AnthropicLLM } from "../llms/anthropic";
|
||||
@@ -19,6 +20,7 @@ import { Qdrant } from "../vector_stores/qdrant";
|
||||
import { VectorizeDB } from "../vector_stores/vectorize";
|
||||
import { RedisDB } from "../vector_stores/redis";
|
||||
import { OllamaLLM } from "../llms/ollama";
|
||||
import { LMStudioLLM } from "../llms/lmstudio";
|
||||
import { SupabaseDB } from "../vector_stores/supabase";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
|
||||
@@ -40,6 +42,8 @@ export class EmbedderFactory {
|
||||
return new OpenAIEmbedder(config);
|
||||
case "ollama":
|
||||
return new OllamaEmbedder(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioEmbedder(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleEmbedder(config);
|
||||
@@ -66,6 +70,8 @@ export class LLMFactory {
|
||||
return new GroqLLM(config);
|
||||
case "ollama":
|
||||
return new OllamaLLM(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioLLM(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleLLM(config);
|
||||
|
||||
@@ -282,7 +282,11 @@ export class Qdrant implements VectorStore {
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.status === 409) {
|
||||
if (
|
||||
error?.status === 409 ||
|
||||
error?.status === 401 ||
|
||||
error?.status === 403
|
||||
) {
|
||||
// Collection already exists — verify configuration for the main collection
|
||||
if (name === this.collectionName) {
|
||||
try {
|
||||
|
||||
@@ -85,4 +85,513 @@ describe("ConfigManager", () => {
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LLM url passthrough for Ollama", () => {
|
||||
const baseEmbedder = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
const baseVectorStore = {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test" },
|
||||
};
|
||||
|
||||
it("should preserve url in LLM config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
|
||||
it("should prefer baseURL over url when both are provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "llama3.2:3b",
|
||||
baseURL: "http://custom:11434",
|
||||
url: "http://fallback:11434",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.baseURL).toBe("http://custom:11434");
|
||||
expect(config.llm.config.url).toBe("http://fallback:11434");
|
||||
});
|
||||
|
||||
it("should use default baseURL when no url or baseURL provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBeUndefined();
|
||||
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
|
||||
it("should preserve url in embedder config (existing behavior)", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "nomic-embed-text",
|
||||
url: "http://10.0.0.100:11434",
|
||||
},
|
||||
},
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// LM Studio snake_case normalization
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
describe("mergeConfig - LM Studio embedder config", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5");
|
||||
});
|
||||
|
||||
it("normalizes embedding_dims to embeddingDims for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("prefers camelCase embeddingDims over snake_case embedding_dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
embeddingDims: 1536,
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
});
|
||||
|
||||
it("passes through camelCase config without issues", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LM Studio LLM config", () => {
|
||||
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("meta-llama-3.1");
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("falls back to default baseURL when neither is provided for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: { provider: "lmstudio", config: { model: "test-model" } },
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - full OpenClaw-style LM Studio config", () => {
|
||||
it("handles the exact config from issue #4235", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: "192.168.200.12",
|
||||
port: 6333,
|
||||
checkCompatibility: false,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe(
|
||||
"text-embedding-gte-qwen2-1.5b-instruct",
|
||||
);
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b");
|
||||
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.host).toBe("192.168.200.12");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Memory class – LM Studio end-to-end flow (mocked factories)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – LM Studio end-to-end flow", () => {
|
||||
let MemoryClass: any;
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let mockEmbedder: any;
|
||||
let mockVStore: any;
|
||||
let mockLlm: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
mockEmbedder = {
|
||||
embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]),
|
||||
};
|
||||
mockVStore = {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockLlm = {
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
};
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) };
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("creates Memory with lmstudio embedder and llm providers", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-detects embedding dimension via probe with lmstudio", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("handles snake_case OpenClaw config through full Memory stack", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-oss-20b",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("search flow works with lmstudio embedder", async () => {
|
||||
mockVStore.search.mockResolvedValueOnce([
|
||||
{
|
||||
id: "mem-1",
|
||||
payload: {
|
||||
data: "User likes hiking",
|
||||
user_id: "u1",
|
||||
hash: "abc123",
|
||||
created_at: "2026-01-01",
|
||||
},
|
||||
score: 0.95,
|
||||
},
|
||||
]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
const result = await mem.search("What does the user like?", {
|
||||
userId: "u1",
|
||||
});
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
|
||||
expect(mockVStore.search).toHaveBeenCalled();
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].memory).toBe("User likes hiking");
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce(
|
||||
'{"facts":["User loves sushi"]}',
|
||||
);
|
||||
mockVStore.search.mockResolvedValue([]);
|
||||
mockVStore.list.mockResolvedValue([[], 0]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.add("I love sushi", { userId: "u1" });
|
||||
|
||||
expect(mockLlm.generateResponse).toHaveBeenCalled();
|
||||
expect(mockEmbedder.embed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { VectorStoreFactory } from "../src/utils/factory";
|
||||
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create Azure AI Search vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
serviceName: "test-service",
|
||||
apiKey: "test-api-key",
|
||||
embeddingModelDims: 1536,
|
||||
compressionType: "none" as const,
|
||||
useFloat16: false,
|
||||
hybridSearch: false,
|
||||
vectorFilterMode: "preFilter" as const,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
|
||||
|
||||
expect(vectorStore).toBeInstanceOf(AzureAISearch);
|
||||
});
|
||||
|
||||
it("should create memory vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("memory", config);
|
||||
|
||||
expect(vectorStore).toBeDefined();
|
||||
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
|
||||
});
|
||||
|
||||
it("should throw error for unsupported provider", () => {
|
||||
const config = {};
|
||||
|
||||
expect(() => {
|
||||
VectorStoreFactory.create("unsupported-provider", config);
|
||||
}).toThrow("Unsupported vector store provider: unsupported-provider");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory.
|
||||
* Mocks all provider modules to avoid external dependency crashes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
|
||||
// Mock all provider modules before importing factory
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/ollama", () => ({
|
||||
OllamaEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/langchain", () => ({
|
||||
LangchainEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/lmstudio", () => ({
|
||||
LMStudioEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-embedder", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/openai_structured", () => ({
|
||||
OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({
|
||||
type: "openai-structured-llm",
|
||||
config,
|
||||
})),
|
||||
}));
|
||||
jest.mock("../src/llms/anthropic", () => ({
|
||||
AnthropicLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "anthropic-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/groq", () => ({
|
||||
GroqLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "groq-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/ollama", () => ({
|
||||
OllamaLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/azure", () => ({
|
||||
AzureOpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/mistral", () => ({
|
||||
MistralLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "mistral-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/langchain", () => ({
|
||||
LangchainLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/lmstudio", () => ({
|
||||
LMStudioLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-llm", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/vector_stores/qdrant", () => ({
|
||||
Qdrant: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "qdrant", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/redis", () => ({
|
||||
RedisDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "redis", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/supabase", () => ({
|
||||
SupabaseDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/langchain", () => ({
|
||||
LangchainVectorStore: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-vs", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/vectorize", () => ({
|
||||
VectorizeDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "vectorize", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
AzureAISearch: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase-history", config })),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbedderFactory,
|
||||
LLMFactory,
|
||||
VectorStoreFactory,
|
||||
HistoryManagerFactory,
|
||||
} from "../src/utils/factory";
|
||||
import type {
|
||||
EmbeddingConfig,
|
||||
LLMConfig,
|
||||
VectorStoreConfig,
|
||||
HistoryStoreConfig,
|
||||
} from "../src/types";
|
||||
|
||||
const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" };
|
||||
const dummyLLMConfig: LLMConfig = { apiKey: "test" };
|
||||
const dummyVSConfig: VectorStoreConfig = {
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
// ─── EmbedderFactory ────────────────────────────────────
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates embedder for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create(provider, dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("OpenAI", dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("nonexistent", dummyEmbedConfig),
|
||||
).toThrow("Unsupported embedder provider: nonexistent");
|
||||
});
|
||||
|
||||
test("passes config to created embedder", () => {
|
||||
const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" };
|
||||
const result = EmbedderFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLMFactory ─────────────────────────────────────────
|
||||
|
||||
describe("LLMFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["openai_structured"],
|
||||
["anthropic"],
|
||||
["groq"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["mistral"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates LLM for provider '%s'", (provider) => {
|
||||
expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow(
|
||||
"Unsupported LLM provider: nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes config to created LLM", () => {
|
||||
const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" };
|
||||
const result = LLMFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VectorStoreFactory ─────────────────────────────────
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
test("creates memory vector store", () => {
|
||||
// MemoryVectorStore is real (not mocked) — needs valid config
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("memory", {
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["qdrant"],
|
||||
["redis"],
|
||||
["supabase"],
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("nonexistent", dummyVSConfig),
|
||||
).toThrow("Unsupported vector store provider: nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HistoryManagerFactory ──────────────────────────────
|
||||
|
||||
describe("HistoryManagerFactory", () => {
|
||||
test("creates SQLite history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: ":memory:" },
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates supabase history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "supabase",
|
||||
config: { supabaseUrl: "http://test", supabaseKey: "key" },
|
||||
};
|
||||
expect(() =>
|
||||
HistoryManagerFactory.create("supabase", config),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates memory history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "memory",
|
||||
config: {},
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
const config: HistoryStoreConfig = { provider: "bad", config: {} };
|
||||
expect(() => HistoryManagerFactory.create("bad", config)).toThrow(
|
||||
"Unsupported history store provider: bad",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Google LLM — unit tests (mocked @google/genai).
|
||||
*
|
||||
* Regression tests for #4380: tools parameter was ignored, causing graph
|
||||
* memory operations to silently fail with Gemini models.
|
||||
*/
|
||||
|
||||
const mockGenerateContent = jest.fn();
|
||||
|
||||
jest.mock("@google/genai", () => ({
|
||||
GoogleGenAI: jest.fn().mockImplementation(() => ({
|
||||
models: { generateContent: mockGenerateContent },
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GoogleLLM } from "../src/llms/google";
|
||||
|
||||
describe("GoogleLLM (unit)", () => {
|
||||
beforeEach(() => mockGenerateContent.mockClear());
|
||||
|
||||
it("returns text response when no tools are provided", async () => {
|
||||
mockGenerateContent.mockResolvedValueOnce({
|
||||
text: '{"facts": ["fact1"]}',
|
||||
functionCalls: null,
|
||||
});
|
||||
|
||||
const llm = new GoogleLLM({ apiKey: "test-key" });
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Hello" },
|
||||
]);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe('{"facts": ["fact1"]}');
|
||||
|
||||
// Verify tools are not in config
|
||||
const callArgs = mockGenerateContent.mock.calls[0][0];
|
||||
expect(callArgs.config.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards tools as functionDeclarations to Gemini API", async () => {
|
||||
mockGenerateContent.mockResolvedValueOnce({
|
||||
text: "",
|
||||
functionCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
args: { entities: [{ entity: "Alice", entity_type: "person" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract_entities",
|
||||
description: "Extract entities from text",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entities: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entity: { type: "string" },
|
||||
entity_type: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["entities"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const llm = new GoogleLLM({ apiKey: "test-key" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "Alice is a person" }],
|
||||
undefined,
|
||||
tools,
|
||||
);
|
||||
|
||||
// Verify functionDeclarations were passed in config
|
||||
const callArgs = mockGenerateContent.mock.calls[0][0];
|
||||
expect(callArgs.config.tools).toBeDefined();
|
||||
expect(callArgs.config.tools[0].functionDeclarations).toHaveLength(1);
|
||||
expect(callArgs.config.tools[0].functionDeclarations[0].name).toBe(
|
||||
"extract_entities",
|
||||
);
|
||||
|
||||
// Verify toolCalls in response
|
||||
expect(result).toHaveProperty("toolCalls");
|
||||
const response = result as { toolCalls: any[] };
|
||||
expect(response.toolCalls).toHaveLength(1);
|
||||
expect(response.toolCalls[0].name).toBe("extract_entities");
|
||||
expect(JSON.parse(response.toolCalls[0].arguments)).toEqual({
|
||||
entities: [{ entity: "Alice", entity_type: "person" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text when tools are provided but model returns text", async () => {
|
||||
mockGenerateContent.mockResolvedValueOnce({
|
||||
text: "Just a text response",
|
||||
functionCalls: null,
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "noop",
|
||||
description: "No operation",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const llm = new GoogleLLM({ apiKey: "test-key" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "Hello" }],
|
||||
undefined,
|
||||
tools,
|
||||
);
|
||||
|
||||
// Should return text, not toolCalls
|
||||
expect(result).toBe("Just a text response");
|
||||
});
|
||||
|
||||
it("strips markdown code fences from text responses", async () => {
|
||||
mockGenerateContent.mockResolvedValueOnce({
|
||||
text: '```json\n{"facts": ["fact1"]}\n```',
|
||||
functionCalls: null,
|
||||
});
|
||||
|
||||
const llm = new GoogleLLM({ apiKey: "test-key" });
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Extract facts" },
|
||||
]);
|
||||
|
||||
expect(result).toBe('{"facts": ["fact1"]}');
|
||||
});
|
||||
|
||||
it("handles multiple function calls in response", async () => {
|
||||
mockGenerateContent.mockResolvedValueOnce({
|
||||
text: "",
|
||||
functionCalls: [
|
||||
{
|
||||
name: "add_graph_memory",
|
||||
args: { source: "Alice", destination: "Bob", relationship: "knows" },
|
||||
},
|
||||
{
|
||||
name: "add_graph_memory",
|
||||
args: {
|
||||
source: "Bob",
|
||||
destination: "Charlie",
|
||||
relationship: "works_with",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "add_graph_memory",
|
||||
description: "Add a graph memory",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const llm = new GoogleLLM({ apiKey: "test-key" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "Alice knows Bob, Bob works with Charlie" }],
|
||||
undefined,
|
||||
tools,
|
||||
);
|
||||
|
||||
const response = result as { toolCalls: any[] };
|
||||
expect(response.toolCalls).toHaveLength(2);
|
||||
expect(response.toolCalls[0].name).toBe("add_graph_memory");
|
||||
expect(response.toolCalls[1].name).toBe("add_graph_memory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio Embedder — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
const mockCreate = jest.fn().mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
embeddings: { create: mockCreate },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioEmbedder (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("Sample text to embed.");
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0]).toEqual({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
input: "Sample text to embed.",
|
||||
encoding_format: "float",
|
||||
});
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embed() normalizes newlines", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await embedder.embed("Line one\nLine two");
|
||||
|
||||
expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two");
|
||||
});
|
||||
|
||||
it("embed() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await expect(embedder.embed("text")).rejects.toThrow(
|
||||
"LM Studio embedder failed: Connection refused",
|
||||
);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const mockBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
|
||||
});
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]);
|
||||
expect(result).toEqual(mockBatch);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio LLM — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioLLM } from "../src/llms/lmstudio";
|
||||
|
||||
const mockCreate = jest.fn();
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
chat: { completions: { create: mockCreate } },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioLLM (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("generateResponse() returns a text response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Hello, world!",
|
||||
role: "assistant",
|
||||
tool_calls: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Hi" },
|
||||
]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("generateResponse() handles tool calls", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "London"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "What is the weather?" }],
|
||||
undefined,
|
||||
[{ type: "function", function: { name: "get_weather" } }],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "",
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it("generateResponse() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateResponse([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Connection refused");
|
||||
});
|
||||
|
||||
it("generateChat() returns LLMResponse shape", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateChat([
|
||||
{ role: "user", content: "Help me" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "I can help with that.",
|
||||
role: "assistant",
|
||||
});
|
||||
});
|
||||
|
||||
it("generateChat() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateChat([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Timeout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* OSS Memory unit tests — add() with inference, without inference, filter validation, metadata.
|
||||
* Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["extracted fact from input"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "extracted fact from input",
|
||||
old_memory: "",
|
||||
new_memory: "extracted fact from input",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-add-${Date.now()}`,
|
||||
dimension: 1536,
|
||||
dbPath: ":memory:",
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - add()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `add_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array for string input", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns at least one result with an id", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
test("result item has a memory string field", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(typeof result.results[0].memory).toBe("string");
|
||||
});
|
||||
|
||||
test("accepts Message[] input", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris." },
|
||||
];
|
||||
const result: SearchResult = await memory.add(messages, { userId });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with agentId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", {
|
||||
agentId: "agent_1",
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with runId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", { runId: "run_1" });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.add("test", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes metadata through to stored memory", async () => {
|
||||
const result: SearchResult = await memory.add("I love TypeScript", {
|
||||
userId,
|
||||
metadata: { source: "chat", tag: "programming" },
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ source: "chat", tag: "programming" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
// When infer=false, the literal message text is stored
|
||||
expect(result.results[0].memory).toBe("Direct storage content");
|
||||
});
|
||||
|
||||
test("with infer=false marks event as ADD in metadata", async () => {
|
||||
const result: SearchResult = await memory.add("Direct fact", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results[0].metadata).toEqual(
|
||||
expect.objectContaining({ event: "ADD" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history.
|
||||
* Content-based LLM mock. Tests verify real behavior, not mock echoes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["stored fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "stored fact",
|
||||
old_memory: "",
|
||||
new_memory: "stored fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
dbPath: ":memory:",
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("Memory - get()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `get_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns the memory matching the ID from add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("I love AI", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.id).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a string for the memory field", async () => {
|
||||
const addResult: SearchResult = await memory.add("Testing get", {
|
||||
userId,
|
||||
});
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const item = await memory.get("nonexistent-uuid-12345");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
test("returns hash and createdAt on stored memory", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash test", { userId });
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.hash).toBe("string");
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - update()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `update_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Original", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const result = await memory.update(id, "Updated");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
test("persists the updated text", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before update", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After update");
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item!.memory).toBe("After update");
|
||||
});
|
||||
|
||||
test("preserves createdAt and sets updatedAt", async () => {
|
||||
const addResult: SearchResult = await memory.add("Timestamp test", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
const originalCreatedAt = before!.createdAt;
|
||||
|
||||
await memory.update(id, "New text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.createdAt).toBe(originalCreatedAt);
|
||||
expect(after!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("updates the hash", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash change", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
await memory.update(id, "Completely different text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.hash).not.toBe(before!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - delete()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `delete_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Delete me", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const result = await memory.delete(addResult.results[0].id);
|
||||
expect(result.message).toBe("Memory deleted successfully!");
|
||||
});
|
||||
|
||||
test("get() returns null after deletion", async () => {
|
||||
const addResult: SearchResult = await memory.add("Temporary", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.delete(id);
|
||||
expect(await memory.get(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("Memory - deleteAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `deleteall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("removes all memories for the user and returns success", async () => {
|
||||
await memory.add("Fact A", { userId });
|
||||
await memory.add("Fact B", { userId });
|
||||
const result = await memory.deleteAll({ userId });
|
||||
expect(result.message).toBe("Memories deleted successfully!");
|
||||
const remaining: SearchResult = await memory.getAll({ userId });
|
||||
expect(remaining.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws when no filter is provided", async () => {
|
||||
await expect(memory.deleteAll({} as any)).rejects.toThrow(
|
||||
"At least one filter is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - getAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `getall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns all stored memories for the user", async () => {
|
||||
await memory.add("First", { userId });
|
||||
await memory.add("Second", { userId });
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("each result has id and memory fields", async () => {
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
for (const item of result.results) {
|
||||
expect(item.id).toBeDefined();
|
||||
expect(typeof item.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no memories exist", async () => {
|
||||
const result: SearchResult = await memory.getAll({
|
||||
userId: "no_such_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - search()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `search_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
await memory.add("I love TypeScript", { userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array", async () => {
|
||||
const result: SearchResult = await memory.search("TypeScript", { userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns results with score field", async () => {
|
||||
const result: SearchResult = await memory.search("content", { userId });
|
||||
if (result.results.length > 0) {
|
||||
expect(typeof result.results[0].score).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.search("query", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty results for user with no memories", async () => {
|
||||
const result: SearchResult = await memory.search("query", {
|
||||
userId: "empty_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("Memory - history()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `history_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("records ADD event after add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("New fact", { userId });
|
||||
const history = await memory.history(addResult.results[0].id);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("records additional entry after update()", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After");
|
||||
const history = await memory.history(id);
|
||||
expect(history.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for non-existent memory ID", async () => {
|
||||
const history = await memory.history("nonexistent-id");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* OSS Memory unit tests — constructor, initialization, config validation, reset.
|
||||
* Mocks LLM/Embedder at module level. No API keys needed.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
// ─── Content-based LLM mock (reviewer #9) ────────────────
|
||||
// Returns facts for system-prompt calls, memory actions for user-only calls.
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["test fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "test fact",
|
||||
old_memory: "",
|
||||
new_memory: "test fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-init", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - Initialization", () => {
|
||||
test("constructs without throwing with valid config", () => {
|
||||
expect(() => createMemory()).not.toThrow();
|
||||
});
|
||||
|
||||
test("fromConfig creates instance from config dict", () => {
|
||||
const config = {
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4" },
|
||||
},
|
||||
};
|
||||
const mem = Memory.fromConfig(config);
|
||||
expect(mem).toBeInstanceOf(Memory);
|
||||
});
|
||||
|
||||
test("fromConfig throws on invalid config", () => {
|
||||
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
|
||||
const mem = createMemory({ disableHistory: true });
|
||||
// If DummyHistoryManager is used, history returns [] without error
|
||||
const result = await mem.history("nonexistent-id");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory - reset()", () => {
|
||||
test("reset clears all stored memories", async () => {
|
||||
const mem = createMemory();
|
||||
const userId = `reset_test_${Date.now()}`;
|
||||
|
||||
await mem.add("Remember this fact", { userId });
|
||||
const before: SearchResult = await mem.getAll({ userId });
|
||||
expect(before.results.length).toBeGreaterThan(0);
|
||||
|
||||
await mem.reset();
|
||||
|
||||
const after: SearchResult = await mem.getAll({ userId });
|
||||
expect(after.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
jest.setTimeout(30000); // Increase timeout to 30 seconds
|
||||
|
||||
describe("Memory Class", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with default configuration
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
// Reset all memories before each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("Basic Memory Operations", () => {
|
||||
it("should add a single memory", async () => {
|
||||
const result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add multiple messages", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
|
||||
const result = (await memory.add(messages, userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get a single memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const result = (await memory.get(memoryId)) as MemoryItem;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(memoryId);
|
||||
expect(result.memory).toBeDefined();
|
||||
expect(typeof result.memory).toBe("string");
|
||||
});
|
||||
|
||||
it("should update a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const updatedContent = "Updated content";
|
||||
const result = await memory.update(memoryId, updatedContent);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
|
||||
// Verify the update by getting the memory
|
||||
const updatedMemory = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updatedMemory.memory).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it("should get all memories for a user", async () => {
|
||||
// Add a few memories
|
||||
await memory.add("I love visiting new places in the winters", userId);
|
||||
await memory.add("I like to rule the world", userId);
|
||||
|
||||
const result = (await memory.getAll(userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should search memories", async () => {
|
||||
// Add some test memories
|
||||
await memory.add("I love programming in Python", userId);
|
||||
await memory.add("JavaScript is my favorite language", userId);
|
||||
|
||||
const result = (await memory.search(
|
||||
"What programming languages do I know?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get memory history", async () => {
|
||||
// Add and update a memory to create history
|
||||
const addResult = (await memory.add(
|
||||
"I like swimming in warm water",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
|
||||
const history = await memory.history(memoryId);
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love to drink vodka in summers",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
|
||||
// Delete the memory
|
||||
await memory.delete(memoryId);
|
||||
|
||||
// Try to get the deleted memory - should throw or return null
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("should work with custom configuration", async () => {
|
||||
const result = (await customMemory.add(
|
||||
"I love programming in Python",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should perform semantic search with custom embeddings", async () => {
|
||||
// Add test memories
|
||||
await customMemory.add("The weather in London is rainy today", userId);
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", userId);
|
||||
|
||||
const result = (await customMemory.search(
|
||||
"What is the weather like?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
// Results should be ordered by relevance
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Ollama Embedder — unit tests (mocked Ollama client).
|
||||
*/
|
||||
|
||||
import { OllamaEmbedder } from "../src/embeddings/ollama";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
const mockEmbed = jest.fn().mockResolvedValue({
|
||||
model: "nomic-embed-text:latest",
|
||||
embeddings: [mockEmbedding],
|
||||
});
|
||||
const mockList = jest.fn().mockResolvedValue({
|
||||
models: [{ name: "nomic-embed-text:latest" }],
|
||||
});
|
||||
const mockPull = jest.fn().mockResolvedValue({});
|
||||
|
||||
jest.mock("ollama", () => ({
|
||||
Ollama: jest.fn().mockImplementation(() => ({
|
||||
embed: mockEmbed,
|
||||
list: mockList,
|
||||
pull: mockPull,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("OllamaEmbedder (unit)", () => {
|
||||
beforeEach(() => {
|
||||
mockEmbed.mockClear();
|
||||
mockList.mockClear();
|
||||
mockPull.mockClear();
|
||||
});
|
||||
|
||||
it("embed() calls ollama.embed with model and input, returns first embedding", async () => {
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("Sample text to embed.");
|
||||
|
||||
expect(mockEmbed).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmbed.mock.calls[0][0]).toEqual({
|
||||
model: "nomic-embed-text:latest",
|
||||
input: "Sample text to embed.",
|
||||
});
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embed() coerces non-string input to JSON string", async () => {
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
// Force a non-string through the type boundary
|
||||
await embedder.embed(42 as any);
|
||||
|
||||
expect(mockEmbed.mock.calls[0][0].input).toBe("42");
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
|
||||
expect(mockEmbed).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([mockEmbedding, mockEmbedding]);
|
||||
});
|
||||
|
||||
it("ensureModelExists() does not pull when model is already present", async () => {
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
await embedder.embed("trigger ensureModelExists");
|
||||
|
||||
expect(mockList).toHaveBeenCalled();
|
||||
expect(mockPull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureModelExists() pulls model when not found locally", async () => {
|
||||
mockList.mockResolvedValueOnce({ models: [] });
|
||||
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
await embedder.embed("trigger ensureModelExists");
|
||||
|
||||
expect(mockPull).toHaveBeenCalledWith({ model: "nomic-embed-text:latest" });
|
||||
});
|
||||
|
||||
it("ensureModelExists() normalizes model name with :latest tag", async () => {
|
||||
mockList.mockResolvedValue({
|
||||
models: [{ name: "nomic-embed-text:latest" }],
|
||||
});
|
||||
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text",
|
||||
});
|
||||
|
||||
await embedder.embed("trigger ensureModelExists");
|
||||
|
||||
expect(mockPull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("embed() throws when embeddings array is empty", async () => {
|
||||
mockEmbed.mockResolvedValueOnce({
|
||||
model: "nomic-embed-text:latest",
|
||||
embeddings: [],
|
||||
});
|
||||
|
||||
const embedder = new OllamaEmbedder({
|
||||
model: "nomic-embed-text:latest",
|
||||
});
|
||||
|
||||
await expect(embedder.embed("text")).rejects.toThrow(
|
||||
"Ollama embed() returned no embeddings",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -27,4 +27,42 @@ describe("removeCodeBlocks", () => {
|
||||
expect(removeCodeBlocks(input)).toContain('"facts"');
|
||||
expect(removeCodeBlocks(input)).not.toContain("```");
|
||||
});
|
||||
|
||||
// Truncated LLM response cases (issue #4401)
|
||||
it("handles truncated code block missing closing fence", () => {
|
||||
const input = '```json\n{"facts": ["hello"]}';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("handles truncated code block with incomplete JSON", () => {
|
||||
const input = '```json\n{"key": "value"';
|
||||
expect(removeCodeBlocks(input)).toBe('{"key": "value"');
|
||||
});
|
||||
|
||||
it("handles orphan trailing fence", () => {
|
||||
const input = '{"result": true}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"result": true}');
|
||||
});
|
||||
|
||||
it("handles truncated block with bare fence (no language tag)", () => {
|
||||
const input = '```\n{"facts": ["test"]}';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["test"]}');
|
||||
});
|
||||
|
||||
it("handles complete block followed by truncated block", () => {
|
||||
const input = '```json\n{"a":1}\n```\nsome text\n```python\nprint("hi")';
|
||||
const result = removeCodeBlocks(input);
|
||||
expect(result).toContain('{"a":1}');
|
||||
expect(result).toContain('print("hi")');
|
||||
expect(result).not.toMatch(/^```/);
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(removeCodeBlocks("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles CRLF line endings from LLM proxies", () => {
|
||||
const input = '```json\r\n{"facts": ["hello"]}\r\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Storage manager unit tests — SQLiteManager, DummyHistoryManager.
|
||||
* Uses real in-memory SQLite, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { SQLiteManager } from "../src/storage/SQLiteManager";
|
||||
import { DummyHistoryManager } from "../src/storage/DummyHistoryManager";
|
||||
import { MemoryHistoryManager } from "../src/storage/MemoryHistoryManager";
|
||||
|
||||
// ─── SQLiteManager ──────────────────────────────────────
|
||||
|
||||
describe("SQLiteManager", () => {
|
||||
let db: SQLiteManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new SQLiteManager(":memory:");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory inserts a record retrievable by getHistory", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns records in reverse chronological order", async () => {
|
||||
await db.addHistory("mem1", null, "first", "ADD", "2026-01-01");
|
||||
await db.addHistory("mem1", "first", "second", "UPDATE", "2026-01-02");
|
||||
await db.addHistory("mem1", "second", "third", "UPDATE", "2026-01-03");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
// DESC order by id: most recent first
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
const history = await db.getHistory("nonexistent");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("addHistory stores previous_value for UPDATE", async () => {
|
||||
await db.addHistory("mem1", "old text", "new text", "UPDATE");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].previous_value).toBe("old text");
|
||||
expect(history[0].new_value).toBe("new text");
|
||||
});
|
||||
|
||||
test("addHistory stores null new_value for DELETE", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
"deleted text",
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].action).toBe("DELETE");
|
||||
expect(history[0].new_value).toBeNull();
|
||||
expect(history[0].is_deleted).toBe(1);
|
||||
});
|
||||
|
||||
test("reset clears all history and recreates table", async () => {
|
||||
await db.addHistory("mem1", null, "data", "ADD");
|
||||
await db.addHistory("mem2", null, "data", "ADD");
|
||||
await db.reset();
|
||||
expect(await db.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(0);
|
||||
// Table still works after reset
|
||||
await db.addHistory("mem3", null, "after reset", "ADD");
|
||||
expect(await db.getHistory("mem3")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stores createdAt and updatedAt timestamps", async () => {
|
||||
const created = "2026-03-17T10:00:00Z";
|
||||
const updated = "2026-03-17T11:00:00Z";
|
||||
await db.addHistory("mem1", null, "data", "ADD", created, updated);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].created_at).toBe(created);
|
||||
expect(history[0].updated_at).toBe(updated);
|
||||
});
|
||||
|
||||
test("handles multiple memories independently", async () => {
|
||||
await db.addHistory("mem1", null, "data1", "ADD");
|
||||
await db.addHistory("mem2", null, "data2", "ADD");
|
||||
expect(await db.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DummyHistoryManager ────────────────────────────────
|
||||
|
||||
describe("DummyHistoryManager", () => {
|
||||
let dummy: DummyHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
dummy = new DummyHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(dummy).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory is a no-op that resolves", async () => {
|
||||
await expect(
|
||||
dummy.addHistory("id", null, "val", "ADD"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("getHistory returns empty array", async () => {
|
||||
const result = await dummy.getHistory("any-id");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("reset resolves without throwing", async () => {
|
||||
await expect(dummy.reset()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => dummy.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryHistoryManager ───────────────────────────────
|
||||
|
||||
describe("MemoryHistoryManager", () => {
|
||||
let mgr: MemoryHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new MemoryHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(mgr).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory + getHistory round-trips correctly", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns entries sorted by date descending", async () => {
|
||||
await mgr.addHistory("mem1", null, "first", "ADD", "2026-01-01T00:00:00Z");
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"first",
|
||||
"second",
|
||||
"UPDATE",
|
||||
"2026-01-02T00:00:00Z",
|
||||
);
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"second",
|
||||
"third",
|
||||
"UPDATE",
|
||||
"2026-01-03T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
expect(await mgr.getHistory("nonexistent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("getHistory caps at 100 entries", async () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
`entry-${i}`,
|
||||
"ADD",
|
||||
`2026-01-01T00:${String(i).padStart(2, "0")}:00Z`,
|
||||
);
|
||||
}
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("reset clears all entries", async () => {
|
||||
await mgr.addHistory("mem1", null, "data", "ADD");
|
||||
await mgr.addHistory("mem2", null, "data", "ADD");
|
||||
await mgr.reset();
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => mgr.close()).not.toThrow();
|
||||
});
|
||||
|
||||
test("isolates history by memory_id", async () => {
|
||||
await mgr.addHistory("mem1", null, "d1", "ADD");
|
||||
await mgr.addHistory("mem2", null, "d2", "ADD");
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Drift-prevention test: ensures every peerDependency in package.json
|
||||
* is listed in tsup.config.ts's external array so tsup never bundles
|
||||
* optional provider SDKs into the dist output.
|
||||
*/
|
||||
describe("tsup.config.ts externals", () => {
|
||||
let peerDeps: string[];
|
||||
let directDeps: string[];
|
||||
let externalDeps: string[];
|
||||
|
||||
beforeAll(() => {
|
||||
const pkgPath = path.resolve(__dirname, "../../../package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
||||
// Filter out @types/* packages — they are type-only and not bundled at runtime
|
||||
peerDeps = Object.keys(pkg.peerDependencies || {}).filter(
|
||||
(dep) => !dep.startsWith("@types/"),
|
||||
);
|
||||
directDeps = Object.keys(pkg.dependencies || {});
|
||||
|
||||
const tsupConfigPath = path.resolve(__dirname, "../../../tsup.config.ts");
|
||||
const tsupContent = fs.readFileSync(tsupConfigPath, "utf-8");
|
||||
|
||||
// Extract strings from the external array (supports double, single, and backtick quotes)
|
||||
const externalMatch = tsupContent.match(
|
||||
/const external\s*=\s*\[([\s\S]*?)\];/,
|
||||
);
|
||||
if (!externalMatch) {
|
||||
throw new Error("Could not find external array in tsup.config.ts");
|
||||
}
|
||||
const matches = externalMatch[1].match(/["'`]([^"'`]+)["'`]/g);
|
||||
externalDeps = (matches || []).map((m) => m.replace(/["'`]/g, ""));
|
||||
});
|
||||
|
||||
it("should have every peerDependency in the external array", () => {
|
||||
const missing = peerDeps.filter((dep) => !externalDeps.includes(dep));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not have stale entries that are not in package.json", () => {
|
||||
const allDeps = [...peerDeps, ...directDeps];
|
||||
const stale = externalDeps.filter((dep) => !allDeps.includes(dep));
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("should have peerDependencies defined in package.json", () => {
|
||||
expect(peerDeps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity.
|
||||
* Uses real SQLite in-memory DB, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import type { VectorStoreResult } from "../src/types";
|
||||
|
||||
const DIM = 4; // Small dimension for fast tests
|
||||
|
||||
function createStore(): MemoryVectorStore {
|
||||
return new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: DIM,
|
||||
dbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
function vec(values: number[]): number[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
describe("MemoryVectorStore - insert + get", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test("inserts and retrieves a vector by ID", async () => {
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0])],
|
||||
["id1"],
|
||||
[{ data: "hello", userId: "u1" }],
|
||||
);
|
||||
const result: VectorStoreResult | null = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("id1");
|
||||
expect(result!.payload.data).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const result = await store.get("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during insert", async () => {
|
||||
await expect(
|
||||
store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - search", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])],
|
||||
["a", "b", "c"],
|
||||
[
|
||||
{ data: "north", userId: "u1" },
|
||||
{ data: "east", userId: "u1" },
|
||||
{ data: "north-ish", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns results sorted by cosine similarity descending", async () => {
|
||||
const results: VectorStoreResult[] = await store.search(
|
||||
vec([1, 0, 0, 0]),
|
||||
10,
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].id).toBe("a"); // exact match
|
||||
// scores should be descending
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!);
|
||||
}
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
|
||||
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty when filter matches nothing", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, {
|
||||
userId: "nobody",
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws on query dimension mismatch", async () => {
|
||||
await expect(store.search(vec([1, 0]), 10)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - update", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]);
|
||||
});
|
||||
|
||||
test("updates payload and vector", async () => {
|
||||
await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" });
|
||||
const result = await store.get("upd1");
|
||||
expect(result!.payload.data).toBe("updated");
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during update", async () => {
|
||||
await expect(
|
||||
store.update("upd1", vec([1, 0]), { data: "bad" }),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - delete + deleteCol", () => {
|
||||
test("delete removes a vector", async () => {
|
||||
const store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]);
|
||||
await store.delete("del1");
|
||||
expect(await store.get("del1")).toBeNull();
|
||||
});
|
||||
|
||||
test("deleteCol clears all vectors", async () => {
|
||||
const store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0])],
|
||||
["x", "y"],
|
||||
[{ data: "a" }, { data: "b" }],
|
||||
);
|
||||
await store.deleteCol();
|
||||
const [results] = await store.list();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - list", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])],
|
||||
["l1", "l2", "l3"],
|
||||
[
|
||||
{ data: "a", userId: "u1" },
|
||||
{ data: "b", userId: "u1" },
|
||||
{ data: "c", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns all vectors without filter", async () => {
|
||||
const [results, count] = await store.list();
|
||||
expect(count).toBe(3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const [results, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(2);
|
||||
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
|
||||
});
|
||||
|
||||
test("respects limit", async () => {
|
||||
const [results] = await store.list(undefined, 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - userId tracking", () => {
|
||||
test("getUserId generates and persists a random ID", async () => {
|
||||
const store = createStore();
|
||||
const id = await store.getUserId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
// Calling again returns same ID
|
||||
expect(await store.getUserId()).toBe(id);
|
||||
});
|
||||
|
||||
test("setUserId overrides the stored ID", async () => {
|
||||
const store = createStore();
|
||||
await store.setUserId("custom-id");
|
||||
expect(await store.getUserId()).toBe("custom-id");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,16 @@ const external = [
|
||||
"better-sqlite3",
|
||||
"@qdrant/js-client-rest",
|
||||
"redis",
|
||||
"ollama",
|
||||
"@google/genai",
|
||||
"@mistralai/mistralai",
|
||||
"neo4j-driver",
|
||||
"@supabase/supabase-js",
|
||||
"@azure/search-documents",
|
||||
"@azure/identity",
|
||||
"cloudflare",
|
||||
"@cloudflare/workers-types",
|
||||
"@langchain/core",
|
||||
];
|
||||
|
||||
export default defineConfig([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
@@ -46,3 +47,8 @@ class LLMRerankerConfig(BaseRerankerConfig):
|
||||
default=None,
|
||||
description="Custom prompt template for scoring documents"
|
||||
)
|
||||
llm: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Nested LLM configuration with 'provider' and 'config' keys. "
|
||||
"Overrides top-level provider/model/api_key when provided.",
|
||||
)
|
||||
|
||||
@@ -31,12 +31,21 @@ class OllamaEmbedding(EmbeddingBase):
|
||||
self.client = Client(host=self.config.ollama_base_url)
|
||||
self._ensure_model_exists()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_name(name: str) -> str:
|
||||
return name if ":" in name else f"{name}:latest"
|
||||
|
||||
def _ensure_model_exists(self):
|
||||
"""
|
||||
Ensure the specified model exists locally. If not, pull it from Ollama.
|
||||
"""
|
||||
local_models = self.client.list()["models"]
|
||||
if not any(model.get("name") == self.config.model or model.get("model") == self.config.model for model in local_models):
|
||||
target = self._normalize_model_name(self.config.model)
|
||||
if not any(
|
||||
self._normalize_model_name(model.get("name", "")) == target
|
||||
or self._normalize_model_name(model.get("model", "")) == target
|
||||
for model in local_models
|
||||
):
|
||||
self.client.pull(self.config.model)
|
||||
|
||||
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
|
||||
@@ -49,5 +58,8 @@ class OllamaEmbedding(EmbeddingBase):
|
||||
Returns:
|
||||
list: The embedding vector.
|
||||
"""
|
||||
response = self.client.embeddings(model=self.config.model, prompt=text)
|
||||
return response["embedding"]
|
||||
response = self.client.embed(model=self.config.model, input=text)
|
||||
embeddings = response.get("embeddings") or []
|
||||
if not embeddings:
|
||||
raise ValueError(f"Ollama embed() returned no embeddings for model '{self.config.model}'")
|
||||
return embeddings[0]
|
||||
|
||||
@@ -43,7 +43,12 @@ class OpenAIEmbedding(EmbeddingBase):
|
||||
"""
|
||||
text = text.replace("\n", " ")
|
||||
return (
|
||||
self.client.embeddings.create(input=[text], model=self.config.model, dimensions=self.config.embedding_dims)
|
||||
self.client.embeddings.create(
|
||||
input=[text],
|
||||
model=self.config.model,
|
||||
dimensions=self.config.embedding_dims,
|
||||
encoding_format="float",
|
||||
)
|
||||
.data[0]
|
||||
.embedding
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ class NeptuneBase(ABC):
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
@@ -144,7 +144,7 @@ class NeptuneBase(ABC):
|
||||
|
||||
entities = []
|
||||
if extracted_entities["tool_calls"]:
|
||||
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
|
||||
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
||||
|
||||
entities = self._remove_spaces_from_entities(entities)
|
||||
logger.debug(f"Extracted entities: {entities}")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .base import NeptuneBase
|
||||
|
||||
@@ -114,7 +113,7 @@ class MemoryGraph(NeptuneBase):
|
||||
"name": destination,
|
||||
"type": destination_type,
|
||||
"user_id": user_id,
|
||||
"created_at": datetime.now(pytz.timezone("US/Pacific")).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.vector_store.insert(
|
||||
vectors=[dest_embedding],
|
||||
@@ -189,7 +188,7 @@ class MemoryGraph(NeptuneBase):
|
||||
"name": source,
|
||||
"type": source_type,
|
||||
"user_id": user_id,
|
||||
"created_at": datetime.now(pytz.timezone("US/Pacific")).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.vector_store.insert(
|
||||
vectors=[source_embedding],
|
||||
@@ -316,14 +315,14 @@ class MemoryGraph(NeptuneBase):
|
||||
"name": source,
|
||||
"type": source_type,
|
||||
"user_id": user_id,
|
||||
"created_at": datetime.now(pytz.timezone("US/Pacific")).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
destination_id = str(uuid.uuid4())
|
||||
destination_payload = {
|
||||
"name": destination,
|
||||
"type": destination_type,
|
||||
"user_id": user_id,
|
||||
"created_at": datetime.now(pytz.timezone("US/Pacific")).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.vector_store.insert(
|
||||
vectors=[source_embedding, dest_embedding],
|
||||
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -8,6 +9,7 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class OllamaLLM(LLMBase):
|
||||
@@ -61,7 +63,28 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
if isinstance(response, dict):
|
||||
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||
else:
|
||||
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||
|
||||
for tool_call in raw_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
else:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", "") if fn else ""
|
||||
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(extract_json(arguments))
|
||||
|
||||
processed_response["tool_calls"].append(
|
||||
{"name": name, "arguments": arguments}
|
||||
)
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -113,5 +136,8 @@ class OllamaLLM(LLMBase):
|
||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.chat(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
@@ -215,7 +215,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
@@ -241,7 +241,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
+157
-72
@@ -8,10 +8,9 @@ import os
|
||||
import uuid
|
||||
import warnings
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mem0.configs.base import MemoryConfig, MemoryItem
|
||||
@@ -24,10 +23,12 @@ from mem0.exceptions import ValidationError as Mem0ValidationError
|
||||
from mem0.memory.base import MemoryBase
|
||||
from mem0.memory.setup import mem0_dir, setup_config
|
||||
from mem0.memory.storage import SQLiteManager
|
||||
from mem0.memory.telemetry import capture_event
|
||||
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
|
||||
from mem0.memory.utils import (
|
||||
ensure_json_instruction,
|
||||
extract_json,
|
||||
get_fact_retrieval_messages,
|
||||
normalize_facts,
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
@@ -37,8 +38,8 @@ from mem0.utils.factory import (
|
||||
EmbedderFactory,
|
||||
GraphStoreFactory,
|
||||
LlmFactory,
|
||||
VectorStoreFactory,
|
||||
RerankerFactory,
|
||||
VectorStoreFactory,
|
||||
)
|
||||
|
||||
# Suppress SWIG deprecation warnings globally
|
||||
@@ -49,18 +50,90 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*swigva
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_iso_timestamp_to_utc(timestamp: Optional[str]) -> Optional[str]:
|
||||
"""Normalize timezone-aware ISO timestamps to UTC without rewriting naive values."""
|
||||
if not timestamp:
|
||||
return timestamp
|
||||
try:
|
||||
parsed = datetime.fromisoformat(timestamp)
|
||||
except ValueError:
|
||||
return timestamp
|
||||
if parsed.tzinfo is None:
|
||||
return timestamp
|
||||
return parsed.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# Fields that hold runtime auth/connection objects and must be preserved.
|
||||
# These are non-serializable objects (e.g. AWSV4SignerAuth, RequestsHttpConnection)
|
||||
# needed by clients like OpenSearch — not sensitive strings to redact.
|
||||
_RUNTIME_FIELDS = frozenset({
|
||||
"http_auth",
|
||||
"auth",
|
||||
"connection_class",
|
||||
"ssl_context",
|
||||
"use_azure_credential",
|
||||
})
|
||||
|
||||
# Fields that are known to contain sensitive secrets and must be redacted.
|
||||
_SENSITIVE_FIELDS_EXACT = frozenset({
|
||||
"api_key",
|
||||
"secret_key",
|
||||
"private_key",
|
||||
"access_key",
|
||||
"password",
|
||||
"credentials",
|
||||
"credential",
|
||||
"secret",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"auth_token",
|
||||
"session_token",
|
||||
"client_secret",
|
||||
"auth_client_secret",
|
||||
"azure_client_secret",
|
||||
"service_account_json",
|
||||
"aws_session_token",
|
||||
})
|
||||
|
||||
# Suffixes that indicate a field likely holds a secret value.
|
||||
_SENSITIVE_SUFFIXES = (
|
||||
"_password",
|
||||
"_secret",
|
||||
"_token",
|
||||
"_credential",
|
||||
"_credentials",
|
||||
)
|
||||
|
||||
|
||||
def _is_sensitive_field(field_name: str) -> bool:
|
||||
"""Check if a field should be redacted for telemetry safety.
|
||||
|
||||
Uses a layered approach:
|
||||
1. Runtime fields (allowlist) — always preserved, highest priority.
|
||||
2. Exact deny list — known secret field names.
|
||||
3. Suffix deny list — catches patterns like db_password, auth_secret, etc.
|
||||
"""
|
||||
name = field_name.lower().strip()
|
||||
if name in _RUNTIME_FIELDS:
|
||||
return False
|
||||
if name in _SENSITIVE_FIELDS_EXACT:
|
||||
return True
|
||||
return any(name.endswith(suffix) for suffix in _SENSITIVE_SUFFIXES)
|
||||
|
||||
|
||||
def _safe_deepcopy_config(config):
|
||||
"""Safely deepcopy config, falling back to JSON serialization for non-serializable objects."""
|
||||
"""Safely deepcopy config, falling back to dict-based cloning for non-serializable objects."""
|
||||
try:
|
||||
return deepcopy(config)
|
||||
except Exception as e:
|
||||
logger.debug(f"Deepcopy failed, using JSON serialization: {e}")
|
||||
|
||||
logger.debug(f"Deepcopy failed, using dict-based cloning: {e}")
|
||||
|
||||
config_class = type(config)
|
||||
|
||||
|
||||
if hasattr(config, "model_dump"):
|
||||
try:
|
||||
clone_dict = config.model_dump(mode="json")
|
||||
clone_dict = config.model_dump()
|
||||
except Exception:
|
||||
clone_dict = {k: v for k, v in config.__dict__.items()}
|
||||
elif hasattr(config, "__dataclass_fields__"):
|
||||
@@ -68,12 +141,11 @@ def _safe_deepcopy_config(config):
|
||||
clone_dict = asdict(config)
|
||||
else:
|
||||
clone_dict = {k: v for k, v in config.__dict__.items()}
|
||||
|
||||
sensitive_tokens = ("auth", "credential", "password", "token", "secret", "key", "connection_class")
|
||||
|
||||
for field_name in list(clone_dict.keys()):
|
||||
if any(token in field_name.lower() for token in sensitive_tokens):
|
||||
if _is_sensitive_field(field_name):
|
||||
clone_dict[field_name] = None
|
||||
|
||||
|
||||
try:
|
||||
return config_class(**clone_dict)
|
||||
except Exception as reconstruction_error:
|
||||
@@ -204,32 +276,32 @@ class Memory(MemoryBase):
|
||||
self.enable_graph = True
|
||||
else:
|
||||
self.graph = None
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
if MEM0_TELEMETRY:
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
|
||||
# Set path for file-based vector stores
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
# Set path for file-based vector stores
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
capture_event("mem0.init", self, {"sync_type": "sync"})
|
||||
|
||||
@classmethod
|
||||
@@ -431,6 +503,9 @@ class Memory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = self.llm.generate_response(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -446,11 +521,12 @@ class Memory(MemoryBase):
|
||||
else:
|
||||
try:
|
||||
# First try direct JSON parsing
|
||||
new_retrieved_facts = json.loads(response)["facts"]
|
||||
new_retrieved_facts = json.loads(response, strict=False)["facts"]
|
||||
except json.JSONDecodeError:
|
||||
# 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 = json.loads(extracted_json, strict=False)["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 = []
|
||||
@@ -513,7 +589,7 @@ class Memory(MemoryBase):
|
||||
new_memories_with_actions = {}
|
||||
else:
|
||||
response = remove_code_blocks(response)
|
||||
new_memories_with_actions = json.loads(response)
|
||||
new_memories_with_actions = json.loads(response, strict=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid JSON response: {e}")
|
||||
new_memories_with_actions = {}
|
||||
@@ -573,7 +649,10 @@ class Memory(MemoryBase):
|
||||
updated_metadata["agent_id"] = metadata["agent_id"]
|
||||
if metadata.get("run_id"):
|
||||
updated_metadata["run_id"] = metadata["run_id"]
|
||||
updated_metadata["updated_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
updated_metadata["created_at"] = _normalize_iso_timestamp_to_utc(
|
||||
updated_metadata.get("created_at")
|
||||
)
|
||||
updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.vector_store.update(
|
||||
vector_id=memory_id,
|
||||
@@ -636,8 +715,8 @@ class Memory(MemoryBase):
|
||||
id=memory.id,
|
||||
memory=memory.payload.get("data", ""),
|
||||
hash=memory.payload.get("hash"),
|
||||
created_at=memory.payload.get("created_at"),
|
||||
updated_at=memory.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(memory.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(memory.payload.get("updated_at")),
|
||||
).model_dump()
|
||||
|
||||
for key in promoted_payload_keys:
|
||||
@@ -739,8 +818,8 @@ class Memory(MemoryBase):
|
||||
id=mem.id,
|
||||
memory=mem.payload.get("data", ""),
|
||||
hash=mem.payload.get("hash"),
|
||||
created_at=mem.payload.get("created_at"),
|
||||
updated_at=mem.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")),
|
||||
).model_dump(exclude={"score"})
|
||||
|
||||
for key in promoted_payload_keys:
|
||||
@@ -971,8 +1050,8 @@ class Memory(MemoryBase):
|
||||
id=mem.id,
|
||||
memory=mem.payload.get("data", ""),
|
||||
hash=mem.payload.get("hash"),
|
||||
created_at=mem.payload.get("created_at"),
|
||||
updated_at=mem.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")),
|
||||
score=mem.score,
|
||||
).model_dump()
|
||||
|
||||
@@ -1046,11 +1125,10 @@ class Memory(MemoryBase):
|
||||
|
||||
keys, encoded_ids = process_telemetry_filters(filters)
|
||||
capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"})
|
||||
# delete all vector memories and reset the collections
|
||||
# delete matching vector memories individually (do NOT reset the collection)
|
||||
memories = self.vector_store.list(filters=filters)[0]
|
||||
for memory in memories:
|
||||
self._delete_memory(memory.id)
|
||||
self.vector_store.reset()
|
||||
|
||||
logger.info(f"Deleted {len(memories)} memories")
|
||||
|
||||
@@ -1082,7 +1160,7 @@ class Memory(MemoryBase):
|
||||
metadata = metadata or {}
|
||||
metadata["data"] = data
|
||||
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
metadata["created_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.vector_store.insert(
|
||||
vectors=[embeddings],
|
||||
@@ -1154,8 +1232,8 @@ class Memory(MemoryBase):
|
||||
|
||||
new_metadata["data"] = data
|
||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
new_metadata["created_at"] = existing_memory.payload.get("created_at")
|
||||
new_metadata["updated_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
new_metadata["created_at"] = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
||||
new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Preserve session identifiers from existing memory only if not provided in new metadata
|
||||
if "user_id" not in new_metadata and "user_id" in existing_memory.payload:
|
||||
@@ -1272,14 +1350,14 @@ class AsyncMemory(MemoryBase):
|
||||
else:
|
||||
self.graph = None
|
||||
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
|
||||
if MEM0_TELEMETRY:
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
capture_event("mem0.init", self, {"sync_type": "async"})
|
||||
|
||||
@classmethod
|
||||
@@ -1460,6 +1538,9 @@ class AsyncMemory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
self.llm.generate_response,
|
||||
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
|
||||
@@ -1472,11 +1553,12 @@ class AsyncMemory(MemoryBase):
|
||||
else:
|
||||
try:
|
||||
# First try direct JSON parsing
|
||||
new_retrieved_facts = json.loads(response)["facts"]
|
||||
new_retrieved_facts = json.loads(response, strict=False)["facts"]
|
||||
except json.JSONDecodeError:
|
||||
# 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 = json.loads(extracted_json, strict=False)["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 = []
|
||||
@@ -1542,7 +1624,7 @@ class AsyncMemory(MemoryBase):
|
||||
new_memories_with_actions = {}
|
||||
else:
|
||||
response = remove_code_blocks(response)
|
||||
new_memories_with_actions = json.loads(response)
|
||||
new_memories_with_actions = json.loads(response, strict=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid JSON response: {e}")
|
||||
new_memories_with_actions = {}
|
||||
@@ -1594,7 +1676,10 @@ class AsyncMemory(MemoryBase):
|
||||
updated_metadata["agent_id"] = meta["agent_id"]
|
||||
if meta.get("run_id"):
|
||||
updated_metadata["run_id"] = meta["run_id"]
|
||||
updated_metadata["updated_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
updated_metadata["created_at"] = _normalize_iso_timestamp_to_utc(
|
||||
updated_metadata.get("created_at")
|
||||
)
|
||||
updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.vector_store.update,
|
||||
@@ -1680,8 +1765,8 @@ class AsyncMemory(MemoryBase):
|
||||
id=memory.id,
|
||||
memory=memory.payload.get("data", ""),
|
||||
hash=memory.payload.get("hash"),
|
||||
created_at=memory.payload.get("created_at"),
|
||||
updated_at=memory.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(memory.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(memory.payload.get("updated_at")),
|
||||
).model_dump()
|
||||
|
||||
for key in promoted_payload_keys:
|
||||
@@ -1788,8 +1873,8 @@ class AsyncMemory(MemoryBase):
|
||||
id=mem.id,
|
||||
memory=mem.payload.get("data", ""),
|
||||
hash=mem.payload.get("hash"),
|
||||
created_at=mem.payload.get("created_at"),
|
||||
updated_at=mem.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")),
|
||||
).model_dump(exclude={"score"})
|
||||
|
||||
for key in promoted_payload_keys:
|
||||
@@ -2029,8 +2114,8 @@ class AsyncMemory(MemoryBase):
|
||||
id=mem.id,
|
||||
memory=mem.payload.get("data", ""),
|
||||
hash=mem.payload.get("hash"),
|
||||
created_at=mem.payload.get("created_at"),
|
||||
updated_at=mem.payload.get("updated_at"),
|
||||
created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")),
|
||||
updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")),
|
||||
score=mem.score,
|
||||
).model_dump()
|
||||
|
||||
@@ -2144,7 +2229,7 @@ class AsyncMemory(MemoryBase):
|
||||
metadata = metadata or {}
|
||||
metadata["data"] = data
|
||||
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
metadata["created_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.vector_store.insert,
|
||||
@@ -2234,8 +2319,8 @@ class AsyncMemory(MemoryBase):
|
||||
|
||||
new_metadata["data"] = data
|
||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
new_metadata["created_at"] = existing_memory.payload.get("created_at")
|
||||
new_metadata["updated_at"] = datetime.now(pytz.timezone("US/Pacific")).isoformat()
|
||||
new_metadata["created_at"] = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
||||
new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Preserve session identifiers from existing memory only if not provided in new metadata
|
||||
if "user_id" not in new_metadata and "user_id" in existing_memory.payload:
|
||||
|
||||
@@ -218,7 +218,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
if "entity" in item and "entity_type" in item:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
|
||||
+53
-1
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mem0.configs.prompts import (
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
FACT_RETRIEVAL_PROMPT,
|
||||
USER_MEMORY_EXTRACTION_PROMPT,
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_fact_retrieval_messages(message, is_agent_memory=False):
|
||||
"""Get fact retrieval messages based on the memory type.
|
||||
@@ -29,6 +32,31 @@ def get_fact_retrieval_messages_legacy(message):
|
||||
return FACT_RETRIEVAL_PROMPT, f"Input:\n{message}"
|
||||
|
||||
|
||||
def ensure_json_instruction(system_prompt, user_prompt):
|
||||
"""Ensure the word 'json' appears in the prompts when using json_object response format.
|
||||
|
||||
OpenAI's API requires the word 'json' to appear in the messages when
|
||||
response_format is set to {"type": "json_object"}. When users provide a
|
||||
custom_fact_extraction_prompt that doesn't include 'json', this causes a
|
||||
400 error. This function appends a JSON format instruction to the system
|
||||
prompt if 'json' is not already present in either prompt.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt string
|
||||
user_prompt: The user prompt string
|
||||
|
||||
Returns:
|
||||
tuple: (system_prompt, user_prompt) with JSON instruction added if needed
|
||||
"""
|
||||
combined = (system_prompt + user_prompt).lower()
|
||||
if "json" not in combined:
|
||||
system_prompt += (
|
||||
"\n\nYou must return your response in valid JSON format "
|
||||
"with a 'facts' key containing an array of strings."
|
||||
)
|
||||
return system_prompt, user_prompt
|
||||
|
||||
|
||||
def parse_messages(messages):
|
||||
response = ""
|
||||
for msg in messages:
|
||||
@@ -52,6 +80,30 @@ def format_entities(entities):
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def normalize_facts(raw_facts):
|
||||
"""Normalize LLM-extracted facts to a list of strings.
|
||||
|
||||
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
|
||||
like {"fact": "..."} or {"text": "..."} instead of plain strings.
|
||||
This mirrors the TypeScript FactRetrievalSchema validation.
|
||||
"""
|
||||
if not raw_facts:
|
||||
return []
|
||||
normalized = []
|
||||
for item in raw_facts:
|
||||
if isinstance(item, str):
|
||||
fact = item
|
||||
elif isinstance(item, dict):
|
||||
fact = item.get("fact") or item.get("text")
|
||||
if fact is None:
|
||||
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
|
||||
continue
|
||||
else:
|
||||
fact = str(item)
|
||||
if fact:
|
||||
normalized.append(fact)
|
||||
return normalized
|
||||
|
||||
|
||||
def remove_code_blocks(content: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
from typing import List, Dict, Any, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
from mem0.reranker.base import BaseReranker
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
class LLMReranker(BaseReranker):
|
||||
@@ -33,19 +33,30 @@ class LLMReranker(BaseReranker):
|
||||
|
||||
self.config = config
|
||||
|
||||
# Create LLM configuration for the factory
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
|
||||
# Add API key if provided
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
# If a nested ``llm`` dict is provided (e.g. for non-OpenAI providers
|
||||
# like Ollama that need provider-specific fields such as
|
||||
# ``ollama_base_url``), use it to configure the LLM factory.
|
||||
if self.config.llm:
|
||||
nested = self.config.llm
|
||||
llm_provider = nested.get("provider", self.config.provider)
|
||||
llm_config: dict = dict(nested.get("config") or {})
|
||||
llm_config.setdefault("model", self.config.model)
|
||||
llm_config.setdefault("temperature", self.config.temperature)
|
||||
llm_config.setdefault("max_tokens", self.config.max_tokens)
|
||||
if self.config.api_key:
|
||||
llm_config.setdefault("api_key", self.config.api_key)
|
||||
else:
|
||||
llm_provider = self.config.provider
|
||||
llm_config = {
|
||||
"model": self.config.model,
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
if self.config.api_key:
|
||||
llm_config["api_key"] = self.config.api_key
|
||||
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(self.config.provider, llm_config)
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from functools import reduce
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import redis
|
||||
from redis.commands.search.query import Query
|
||||
from redisvl.index import SearchIndex
|
||||
@@ -159,17 +158,17 @@ class RedisDB(VectorStoreBase):
|
||||
return [
|
||||
MemoryResult(
|
||||
id=result["memory_id"],
|
||||
score=result["vector_distance"],
|
||||
score=float(result["vector_distance"]),
|
||||
payload={
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
"created_at": datetime.fromtimestamp(
|
||||
int(result["created_at"]), tz=pytz.timezone("US/Pacific")
|
||||
int(result["created_at"]), tz=timezone.utc
|
||||
).isoformat(timespec="microseconds"),
|
||||
**(
|
||||
{
|
||||
"updated_at": datetime.fromtimestamp(
|
||||
int(result["updated_at"]), tz=pytz.timezone("US/Pacific")
|
||||
int(result["updated_at"]), tz=timezone.utc
|
||||
).isoformat(timespec="microseconds")
|
||||
}
|
||||
if "updated_at" in result
|
||||
@@ -207,13 +206,13 @@ class RedisDB(VectorStoreBase):
|
||||
payload = {
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
"created_at": datetime.fromtimestamp(int(result["created_at"]), tz=pytz.timezone("US/Pacific")).isoformat(
|
||||
"created_at": datetime.fromtimestamp(int(result["created_at"]), tz=timezone.utc).isoformat(
|
||||
timespec="microseconds"
|
||||
),
|
||||
**(
|
||||
{
|
||||
"updated_at": datetime.fromtimestamp(
|
||||
int(result["updated_at"]), tz=pytz.timezone("US/Pacific")
|
||||
int(result["updated_at"]), tz=timezone.utc
|
||||
).isoformat(timespec="microseconds")
|
||||
}
|
||||
if "updated_at" in result
|
||||
@@ -271,12 +270,12 @@ class RedisDB(VectorStoreBase):
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
"created_at": datetime.fromtimestamp(
|
||||
int(result["created_at"]), tz=pytz.timezone("US/Pacific")
|
||||
int(result["created_at"]), tz=timezone.utc
|
||||
).isoformat(timespec="microseconds"),
|
||||
**(
|
||||
{
|
||||
"updated_at": datetime.fromtimestamp(
|
||||
int(result["updated_at"]), tz=pytz.timezone("US/Pacific")
|
||||
int(result["updated_at"]), tz=timezone.utc
|
||||
).isoformat(timespec="microseconds")
|
||||
}
|
||||
if result.__dict__.get("updated_at")
|
||||
|
||||
@@ -2,8 +2,46 @@
|
||||
|
||||
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
|
||||
|
||||
## [0.4.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
- **Non-interactive trigger filtering**: Skips recall and capture for `cron`, `heartbeat`, `automation`, and `schedule` triggers — prevents system-generated noise from polluting memory
|
||||
- **Subagent hallucination prevention**: `isSubagentSession()` detects ephemeral subagent sessions and routes recall to the parent (main user) namespace instead of empty ephemeral namespaces; skips capture to prevent orphaned memories
|
||||
- **Subagent-specific preamble**: Subagents receive "You are a subagent — use these memories for context but do not assume you are this user" to prevent identity assumption
|
||||
- **User identity in recall preamble**: Recalled memories now include `userId` attribution for better context
|
||||
- **User identity in extraction preamble**: Extraction context includes user identity and current date for accurate attribution and temporal anchoring
|
||||
- **User-content guard**: Skips extraction when no meaningful user messages remain after filtering
|
||||
- **Dynamic recall thresholding**: Memories scoring less than 50% of the top result are dropped to filter out the long tail of weak matches
|
||||
- **SQLite resilience for OSS mode**: Init error recovery with automatic retry (history disabled) when native SQLite bindings fail under jiti
|
||||
- **`disableHistory` config option**: New `oss.disableHistory` flag to explicitly skip history DB initialization
|
||||
- **Updated minimum package version of mem0ai package**: Updated minimum package version of mem0ai package to ^2.3.0 to force old users to migrate to better-sqlite3
|
||||
- 78 unit tests covering filtering, isolation, trigger filtering, subagent detection, and SQLite resilience
|
||||
|
||||
### Changed
|
||||
- Auto-recall threshold raised from 0.5 to 0.6 for stricter precision during automatic injection (explicit tool searches remain at 0.5)
|
||||
- Recall candidate pool increased to `topK * 2` for better filtering headroom
|
||||
- Provider init promises now reset on failure, allowing retry on subsequent calls
|
||||
- Relaxed extraction instructions: related facts are kept together to preserve context (removed atomic memory requirement)
|
||||
|
||||
### Fixed
|
||||
- **Concurrent session race condition**: Lifecycle hooks (`before_agent_start`, `agent_end`) now use `ctx.sessionKey` directly from the event context instead of a shared mutable `currentSessionId` variable, preventing cross-session data leaks when multiple sessions run simultaneously
|
||||
|
||||
## [0.3.1] - 2026-03-12
|
||||
|
||||
### Added
|
||||
- **Message filtering pipeline**: Multi-stage noise removal before extraction — drops heartbeats, timestamps, single-word acks, system routing metadata, compaction audit logs, and generic assistant acknowledgments
|
||||
- **Broad recall for new sessions**: Short or new-session prompts trigger a secondary broad search to avoid cold-start blindness
|
||||
- **Client-side threshold filtering**: Safety net that drops low-relevance results even if the API doesn't honor the threshold parameter
|
||||
- **Temporal anchoring**: Extraction instructions now include current date so memories are prefixed with "As of YYYY-MM-DD, ..."
|
||||
- **Summary message inclusion**: Earlier assistant messages containing work summaries are included in extraction context even if outside the recent-message window
|
||||
- 55 unit tests covering filtering and isolation helpers
|
||||
|
||||
### Changed
|
||||
- Default `searchThreshold` remains at 0.5, with client-side filtering as a safety net
|
||||
- Extraction window expanded from last 10 → last 20 messages for richer context
|
||||
- Rewritten custom extraction instructions: conciseness, outcome-over-intent, deduplication guidance, language preservation
|
||||
- **Refactored** monolithic `index.ts` (1772 lines) into 6 focused modules: `types.ts`, `providers.ts`, `config.ts`, `filtering.ts`, `isolation.ts`, `index.ts`
|
||||
|
||||
### Fixed
|
||||
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
|
||||
|
||||
|
||||
+39
-10
@@ -12,10 +12,19 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
|
||||
|
||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
||||
|
||||
**Auto-Capture** — After the agent responds, the plugin sends the exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
|
||||
**Auto-Capture** — After the agent responds, the plugin filters the conversation through a noise-removal pipeline, then sends the cleaned exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Both run silently. No prompting, no configuration, no manual calls.
|
||||
|
||||
### Message filtering
|
||||
|
||||
Before extraction, messages pass through a multi-stage filtering pipeline:
|
||||
|
||||
1. **Noise detection** — Drops entire messages that are system noise: heartbeats (`HEARTBEAT_OK`, `NO_REPLY`), timestamps, single-word acknowledgments (`ok`, `sure`, `done`), system routing metadata, and compaction audit logs.
|
||||
2. **Generic assistant detection** — Drops short assistant messages that are boilerplate acknowledgments with no extractable facts (e.g. "I see you've shared an update. How can I help?").
|
||||
3. **Content stripping** — Removes embedded noise fragments (media boilerplate, routing metadata, compaction blocks) from otherwise useful messages.
|
||||
4. **Truncation** — Caps messages at 2000 characters to avoid sending excessive context.
|
||||
|
||||
### Short-term vs long-term memory
|
||||
|
||||
Memories are organized into two scopes:
|
||||
@@ -40,6 +49,13 @@ In multi-agent setups, each agent automatically gets its own memory namespace. S
|
||||
- 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
|
||||
|
||||
**Subagent handling:**
|
||||
|
||||
Ephemeral subagents (session keys like `agent:main:subagent:<uuid>`) are handled specially:
|
||||
- **Recall** is routed to the parent (main user) namespace — subagents get the user's long-term context instead of searching their empty ephemeral namespace
|
||||
- **Capture** is skipped entirely — the main agent's `agent_end` hook captures the consolidated result including subagent output, preventing orphaned memories
|
||||
- A **subagent-specific preamble** is used: "You are a subagent — use these memories for context but do not assume you are this user"
|
||||
|
||||
**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:
|
||||
@@ -48,7 +64,17 @@ All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget
|
||||
memory_search({ query: "user's tech stack", agentId: "researcher" })
|
||||
```
|
||||
|
||||
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
|
||||
The `agentId` is always namespaced under the configured `userId` (e.g. `agentId: "researcher"` → `utkarsh:agent:researcher`), so it cannot be used to access other users' namespaces.
|
||||
|
||||
### Concurrency safety
|
||||
|
||||
Lifecycle hooks (`before_agent_start`, `agent_end`) use `ctx.sessionKey` directly from the event context rather than shared mutable state. This prevents race conditions when multiple sessions run concurrently (e.g. multiple Telegram users chatting simultaneously).
|
||||
|
||||
Tools still read from a best-effort `currentSessionId` variable (since tools don't receive `ctx`), but hooks — where the critical recall and capture logic runs — are fully concurrency-safe.
|
||||
|
||||
### Non-interactive trigger filtering
|
||||
|
||||
The plugin automatically skips recall and capture for non-interactive triggers: `cron`, `heartbeat`, `automation`, and `schedule`. Detection works via both `ctx.trigger` and session key patterns (`:cron:`, `:heartbeat:`). This prevents system-generated noise from polluting long-term memory.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -121,10 +147,10 @@ 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_get` | Retrieve a memory by ID |
|
||||
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent, `scope` to filter by session/long-term. |
|
||||
| `memory_list` | List all stored memories. Optional `agentId` to scope to a specific agent, `scope` to filter. |
|
||||
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace, `longTerm` to choose scope. |
|
||||
| `memory_get` | Retrieve a memory by ID. |
|
||||
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
|
||||
|
||||
## CLI
|
||||
@@ -160,7 +186,7 @@ openclaw mem0 stats --agent researcher
|
||||
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
|
||||
| `topK` | `number` | `5` | Max memories per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
|
||||
| `searchThreshold` | `number` | `0.5` | Min similarity (0–1) |
|
||||
|
||||
### Platform mode
|
||||
|
||||
@@ -170,7 +196,7 @@ openclaw mem0 stats --agent researcher
|
||||
| `orgId` | `string` | — | Organization ID |
|
||||
| `projectId` | `string` | — | Project ID |
|
||||
| `enableGraph` | `boolean` | `false` | Entity graph for relationships |
|
||||
| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format |
|
||||
| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format. Built-in instructions include temporal anchoring, conciseness, outcome-over-intent, deduplication, and language preservation guidelines. |
|
||||
| `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging |
|
||||
|
||||
### Open-source mode
|
||||
@@ -180,16 +206,19 @@ Works with zero extra config. The `oss` block lets you swap out any component:
|
||||
| Key | Type | Default | |
|
||||
|-----|------|---------|---|
|
||||
| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` |
|
||||
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) |
|
||||
| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
|
||||
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
|
||||
| `oss.disableHistory` | `boolean` | `false` | Skip history DB initialization (useful when native SQLite bindings fail) |
|
||||
|
||||
Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. Override only what you need.
|
||||
|
||||
> **SQLite resilience:** If the history DB fails to initialize (e.g. native binding resolution under jiti), the plugin automatically retries with history disabled. Core memory operations (add, search, get, delete) work without the history DB.
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Configuration parsing, env var resolution, and default instructions/categories.
|
||||
*/
|
||||
|
||||
import type { Mem0Config, Mem0Mode } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Env Var Resolution
|
||||
// ============================================================================
|
||||
|
||||
function resolveEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||
const envValue = process.env[envVar];
|
||||
if (!envValue) {
|
||||
throw new Error(`Environment variable ${envVar} is not set`);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnvVarsDeep(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "string") {
|
||||
result[key] = resolveEnvVars(value);
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
result[key] = resolveEnvVarsDeep(value as Record<string, unknown>);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Custom Instructions & Categories
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract durable, actionable facts from conversations between a user and an AI assistant. Only store information that would be useful to an agent in a FUTURE session, days or weeks later.
|
||||
|
||||
Before storing any fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If the answer is no, do not store it.
|
||||
|
||||
Information to Extract (in priority order):
|
||||
|
||||
1. Configuration & System State Changes:
|
||||
- Tools/services configured, installed, or removed (with versions/dates)
|
||||
- Model assignments for agents, API keys configured (NEVER the key itself — see Exclude)
|
||||
- Cron schedules, automation pipelines, deployment configurations
|
||||
- Architecture decisions (agent hierarchy, system design, deployment strategy)
|
||||
- Specific identifiers: file paths, sheet IDs, channel IDs, user IDs, folder IDs
|
||||
|
||||
2. Standing Rules & Policies:
|
||||
- Explicit user directives about behavior ("never create accounts without consent")
|
||||
- Workflow policies ("each agent must review model selection before completing a task")
|
||||
- Security constraints, permission boundaries, access patterns
|
||||
|
||||
3. Identity & Demographics:
|
||||
- Name, location, timezone, language preferences
|
||||
- Occupation, employer, job role, industry
|
||||
|
||||
4. Preferences & Opinions:
|
||||
- Communication style preferences
|
||||
- Tool and technology preferences (with specifics: versions, configs)
|
||||
- Strong opinions or values explicitly stated
|
||||
- The WHY behind preferences when stated
|
||||
|
||||
5. Goals, Projects & Milestones:
|
||||
- Active projects (name, description, current status)
|
||||
- Completed setup milestones ("ElevenLabs fully configured as of 2026-02-20")
|
||||
- Deadlines, roadmaps, and progress tracking
|
||||
- Problems actively being solved
|
||||
|
||||
6. Technical Context:
|
||||
- Tech stack, tools, development environment
|
||||
- Agent ecosystem structure (names, roles, relationships)
|
||||
- Skill levels in different areas
|
||||
|
||||
7. Relationships & People:
|
||||
- Names and roles of people mentioned (colleagues, family, clients)
|
||||
- Team structure, key contacts
|
||||
|
||||
8. Decisions & Lessons:
|
||||
- Important decisions made and their reasoning
|
||||
- Lessons learned, strategies that worked or failed
|
||||
|
||||
Guidelines:
|
||||
|
||||
TEMPORAL ANCHORING (critical):
|
||||
- ALWAYS include temporal context for time-sensitive facts using "As of YYYY-MM-DD, ..."
|
||||
- Extract dates from message timestamps, dates mentioned in the text, or the system-provided current date
|
||||
- If no date is available, note "date unknown" rather than omitting temporal context
|
||||
- Examples: "As of 2026-02-20, ElevenLabs setup is complete" NOT "ElevenLabs setup is complete"
|
||||
|
||||
CONCISENESS:
|
||||
- Use third person ("User prefers..." not "I prefer...")
|
||||
- Keep related facts together in a single memory to preserve context
|
||||
- "User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
|
||||
- NOT a paragraph retelling the whole conversation
|
||||
|
||||
OUTCOMES OVER INTENT:
|
||||
- When an assistant message summarizes completed work, extract the durable OUTCOMES
|
||||
- "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates" NOT "User wants to update call scripts"
|
||||
- Extract what WAS DONE, not what was requested
|
||||
|
||||
DEDUPLICATION:
|
||||
- Before creating a new memory, check if a substantially similar fact already exists
|
||||
- If so, UPDATE the existing memory with any new details rather than creating a duplicate
|
||||
|
||||
LANGUAGE:
|
||||
- ALWAYS preserve the original language of the conversation
|
||||
- If the user speaks Spanish, store the memory in Spanish; do not translate
|
||||
|
||||
Exclude (NEVER store):
|
||||
- Passwords, API keys, tokens, secrets, or any credentials — even if shared in conversation. Instead store: "Tavily API key was configured and saved to .env (as of 2026-02-20)"
|
||||
- One-time commands or instructions ("stop the script", "continue where you left off")
|
||||
- Acknowledgments or emotional reactions ("ok", "sounds good", "you're right", "sir")
|
||||
- Transient UI/navigation states ("user is in the admin panel", "relay is attached")
|
||||
- Ephemeral process status ("download at 50%", "daemon not running", "still syncing")
|
||||
- Cron heartbeat outputs, NO_REPLY responses, compaction flush directives
|
||||
- System routing metadata (message IDs, sender IDs, channel routing info)
|
||||
- Generic small talk with no informational content
|
||||
- Raw code snippets (capture the intent/decision, not the code itself)
|
||||
- Information the user explicitly asks not to remember`;
|
||||
|
||||
export const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
|
||||
identity:
|
||||
"Personal identity information: name, age, location, timezone, occupation, employer, education, demographics",
|
||||
preferences:
|
||||
"Explicitly stated likes, dislikes, preferences, opinions, and values across any domain",
|
||||
goals:
|
||||
"Current and future goals, aspirations, objectives, targets the user is working toward",
|
||||
projects:
|
||||
"Specific projects, initiatives, or endeavors the user is working on, including status and details",
|
||||
technical:
|
||||
"Technical skills, tools, tech stack, development environment, programming languages, frameworks",
|
||||
decisions:
|
||||
"Important decisions made, reasoning behind choices, strategy changes, and their outcomes",
|
||||
relationships:
|
||||
"People mentioned by the user: colleagues, family, friends, their roles and relevance",
|
||||
routines:
|
||||
"Daily habits, work patterns, schedules, productivity routines, health and wellness habits",
|
||||
life_events:
|
||||
"Significant life events, milestones, transitions, upcoming plans and changes",
|
||||
lessons:
|
||||
"Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs",
|
||||
work:
|
||||
"Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
|
||||
health:
|
||||
"Health-related information voluntarily shared: conditions, medications, fitness, wellness goals",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Config Schema
|
||||
// ============================================================================
|
||||
|
||||
const ALLOWED_KEYS = [
|
||||
"mode",
|
||||
"apiKey",
|
||||
"userId",
|
||||
"orgId",
|
||||
"projectId",
|
||||
"autoCapture",
|
||||
"autoRecall",
|
||||
"customInstructions",
|
||||
"customCategories",
|
||||
"customPrompt",
|
||||
"enableGraph",
|
||||
"searchThreshold",
|
||||
"topK",
|
||||
"oss",
|
||||
];
|
||||
|
||||
function assertAllowedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length === 0) return;
|
||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
export const mem0ConfigSchema = {
|
||||
parse(value: unknown): Mem0Config {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("openclaw-mem0 config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config");
|
||||
|
||||
// Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform
|
||||
const mode: Mem0Mode =
|
||||
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
|
||||
|
||||
// Platform mode requires apiKey
|
||||
if (mode === "platform") {
|
||||
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
|
||||
throw new Error(
|
||||
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve env vars in oss config
|
||||
let ossConfig: Mem0Config["oss"];
|
||||
if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) {
|
||||
ossConfig = resolveEnvVarsDeep(
|
||||
cfg.oss as Record<string, unknown>,
|
||||
) as unknown as Mem0Config["oss"];
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
apiKey:
|
||||
typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined,
|
||||
userId:
|
||||
typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default",
|
||||
orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined,
|
||||
projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined,
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
customInstructions:
|
||||
typeof cfg.customInstructions === "string"
|
||||
? cfg.customInstructions
|
||||
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
||||
customCategories:
|
||||
cfg.customCategories &&
|
||||
typeof cfg.customCategories === "object" &&
|
||||
!Array.isArray(cfg.customCategories)
|
||||
? (cfg.customCategories as Record<string, string>)
|
||||
: DEFAULT_CUSTOM_CATEGORIES,
|
||||
customPrompt:
|
||||
typeof cfg.customPrompt === "string"
|
||||
? cfg.customPrompt
|
||||
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
||||
enableGraph: cfg.enableGraph === true,
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
oss: ossConfig,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Pre-extraction message filtering: noise detection, content stripping,
|
||||
* generic assistant detection, truncation, and deduplication.
|
||||
*/
|
||||
|
||||
import type { MemoryItem } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Noise Detection
|
||||
// ============================================================================
|
||||
|
||||
/** Patterns that indicate an entire message is noise and should be dropped. */
|
||||
const NOISE_MESSAGE_PATTERNS: RegExp[] = [
|
||||
/^(HEARTBEAT_OK|NO_REPLY)$/i,
|
||||
/^Current time:.*\d{4}/,
|
||||
/^Pre-compaction memory flush/i,
|
||||
/^(ok|yes|no|sir|sure|thanks|done|good|nice|cool|got it|it's on|continue)$/i,
|
||||
/^System: \[.*\] (Slack message edited|Gateway restart|Exec (failed|completed))/,
|
||||
/^System: \[.*\] ⚠️ Post-Compaction Audit:/,
|
||||
];
|
||||
|
||||
/** Content fragments that should be stripped from otherwise-valid messages. */
|
||||
const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
{ pattern: /Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g, replacement: "" },
|
||||
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
|
||||
{ pattern: /To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g, replacement: "" },
|
||||
{ pattern: /System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g, replacement: "" },
|
||||
{ pattern: /Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g, replacement: "" },
|
||||
];
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Patterns indicating an assistant message is a generic acknowledgment with
|
||||
* no extractable facts. These are produced when the agent receives a
|
||||
* transcript dump or forwarded message and responds with a boilerplate reply.
|
||||
*/
|
||||
const GENERIC_ASSISTANT_PATTERNS: RegExp[] = [
|
||||
/^(I see you'?ve shared|Thanks for sharing|Got it[.!]?\s*(I see|Let me|How can)|I understand[.!]?\s*(How can|Is there|Would you))/i,
|
||||
/^(How can I help|Is there anything|Would you like me to|Let me know (if|how|what))/i,
|
||||
/^(I('?ll| will) (help|assist|look into|review|take a look))/i,
|
||||
/^(Sure[.!]?\s*(How|What|Is)|Understood[.!]?\s*(How|What|Is))/i,
|
||||
/^(That('?s| is) (noted|understood|clear))/i,
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Public Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check whether a message's content is entirely noise (cron heartbeats,
|
||||
* single-word acknowledgments, system routing metadata, etc.).
|
||||
*/
|
||||
export function isNoiseMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return true;
|
||||
return NOISE_MESSAGE_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an assistant message is a generic acknowledgment with no
|
||||
* extractable facts (e.g. "I see you've shared an update. How can I help?").
|
||||
* Only applies to short assistant messages — longer responses likely contain
|
||||
* substantive content even if they start with a generic opener.
|
||||
*/
|
||||
export function isGenericAssistantMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
// Only flag short messages — longer ones likely have substance after the opener
|
||||
if (trimmed.length > 300) return false;
|
||||
return GENERIC_ASSISTANT_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove embedded noise fragments (routing metadata, media boilerplate,
|
||||
* compaction audit blocks) from a message while preserving the useful content.
|
||||
*/
|
||||
export function stripNoiseFromContent(content: string): string {
|
||||
let cleaned = content;
|
||||
for (const { pattern, replacement } of NOISE_CONTENT_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, replacement);
|
||||
}
|
||||
// Collapse excessive whitespace left behind after stripping
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a message to `MAX_MESSAGE_LENGTH` characters, preserving the
|
||||
* opening (which typically contains the summary/conclusion) and appending
|
||||
* a truncation marker so the extraction model knows content was cut.
|
||||
*/
|
||||
function truncateMessage(content: string): string {
|
||||
if (content.length <= MAX_MESSAGE_LENGTH) return content;
|
||||
return content.slice(0, MAX_MESSAGE_LENGTH) + "\n[...truncated]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pre-extraction pipeline: drop noise messages, strip noise fragments,
|
||||
* and truncate remaining messages to a reasonable length.
|
||||
*/
|
||||
export function filterMessagesForExtraction(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
): Array<{ role: string; content: string }> {
|
||||
const filtered: Array<{ role: string; content: string }> = [];
|
||||
for (const msg of messages) {
|
||||
if (isNoiseMessage(msg.content)) continue;
|
||||
// Drop generic assistant acknowledgments that contain no facts
|
||||
if (msg.role === "assistant" && isGenericAssistantMessage(msg.content)) continue;
|
||||
const cleaned = stripNoiseFromContent(msg.content);
|
||||
if (!cleaned) continue;
|
||||
filtered.push({ role: msg.role, content: truncateMessage(cleaned) });
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
+329
-5
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Regression tests for per-agent memory isolation helpers.
|
||||
*
|
||||
* Addresses review feedback: targeted coverage for auth/session state,
|
||||
* malformed input, and the resolveUserId priority chain.
|
||||
* Regression tests for per-agent memory isolation helpers and
|
||||
* message filtering logic.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
@@ -10,16 +8,33 @@ import {
|
||||
effectiveUserId,
|
||||
agentUserId,
|
||||
resolveUserId,
|
||||
isNonInteractiveTrigger,
|
||||
isSubagentSession,
|
||||
isNoiseMessage,
|
||||
isGenericAssistantMessage,
|
||||
stripNoiseFromContent,
|
||||
filterMessagesForExtraction,
|
||||
} from "./index.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractAgentId
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("extractAgentId", () => {
|
||||
it("returns agentId from a well-formed session key", () => {
|
||||
it("returns agentId from a named agent session key", () => {
|
||||
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
|
||||
});
|
||||
|
||||
it("returns subagent namespace from subagent session key", () => {
|
||||
// OpenClaw subagent format: agent:main:subagent:<uuid>
|
||||
expect(extractAgentId("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(
|
||||
"subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for the main agent session (agent:main:main)", () => {
|
||||
expect(extractAgentId("agent:main:main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for the 'main' sentinel", () => {
|
||||
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
|
||||
});
|
||||
@@ -163,3 +178,312 @@ describe("multi-agent isolation", () => {
|
||||
expect(mainId).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNonInteractiveTrigger
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isNonInteractiveTrigger", () => {
|
||||
it("returns true for cron trigger", () => {
|
||||
expect(isNonInteractiveTrigger("cron", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for heartbeat trigger", () => {
|
||||
expect(isNonInteractiveTrigger("heartbeat", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for automation trigger", () => {
|
||||
expect(isNonInteractiveTrigger("automation", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for schedule trigger", () => {
|
||||
expect(isNonInteractiveTrigger("schedule", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive for trigger", () => {
|
||||
expect(isNonInteractiveTrigger("CRON", undefined)).toBe(true);
|
||||
expect(isNonInteractiveTrigger("Heartbeat", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user-initiated triggers", () => {
|
||||
expect(isNonInteractiveTrigger("user", undefined)).toBe(false);
|
||||
expect(isNonInteractiveTrigger("webchat", undefined)).toBe(false);
|
||||
expect(isNonInteractiveTrigger("telegram", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when trigger is undefined and session key is normal", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, "agent:main:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects cron from session key as fallback", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, "agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects heartbeat from session key as fallback", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when both trigger and sessionKey are undefined", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isSubagentSession
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isSubagentSession", () => {
|
||||
it("returns true for subagent session keys", () => {
|
||||
expect(isSubagentSession("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for main agent session", () => {
|
||||
expect(isSubagentSession("agent:main:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for named agent session", () => {
|
||||
expect(isSubagentSession("agent:researcher:550e8400-e29b")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSubagentSession(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNoiseMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isNoiseMessage", () => {
|
||||
it("detects HEARTBEAT_OK", () => {
|
||||
expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true);
|
||||
expect(isNoiseMessage("heartbeat_ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects NO_REPLY", () => {
|
||||
expect(isNoiseMessage("NO_REPLY")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects current-time stamps", () => {
|
||||
expect(
|
||||
isNoiseMessage("Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects single-word acknowledgments", () => {
|
||||
for (const word of ["ok", "yes", "sir", "done", "cool", "Got it", "it's on"]) {
|
||||
expect(isNoiseMessage(word)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects system routing messages", () => {
|
||||
expect(
|
||||
isNoiseMessage("System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS."),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNoiseMessage("System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects compaction audit messages", () => {
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
"System: [2026-02-20 16:12:04 EST] ⚠️ Post-Compaction Audit: The following required startup files were not read",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves real content", () => {
|
||||
expect(isNoiseMessage("Beau runs Rize Digital LLC")).toBe(false);
|
||||
expect(isNoiseMessage("Can you check the lovable discord?")).toBe(false);
|
||||
expect(isNoiseMessage("I approve the Tailscale installation")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats empty/whitespace as noise", () => {
|
||||
expect(isNoiseMessage("")).toBe(true);
|
||||
expect(isNoiseMessage(" ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isGenericAssistantMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isGenericAssistantMessage", () => {
|
||||
it("detects 'I see you've shared' openers", () => {
|
||||
expect(isGenericAssistantMessage("I see you've shared an update. How can I help?")).toBe(true);
|
||||
expect(isGenericAssistantMessage("I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'Thanks for sharing' openers", () => {
|
||||
expect(isGenericAssistantMessage("Thanks for sharing that update! Would you like me to review the changes?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'How can I help' standalone", () => {
|
||||
expect(isGenericAssistantMessage("How can I help you with this?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'Got it' + follow-up", () => {
|
||||
expect(isGenericAssistantMessage("Got it! How can I assist?")).toBe(true);
|
||||
expect(isGenericAssistantMessage("Got it. Let me know what you need.")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'I'll help/review/look into'", () => {
|
||||
expect(isGenericAssistantMessage("I'll review that for you.")).toBe(true);
|
||||
expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves substantive assistant content", () => {
|
||||
expect(isGenericAssistantMessage("## What I Accomplished\n\nDeployed the API to production with Vercel.")).toBe(false);
|
||||
expect(isGenericAssistantMessage("The ElevenLabs SDK has been installed and configured. Voice skill is ready.")).toBe(false);
|
||||
expect(isGenericAssistantMessage("Updated the call scripts sheet with truth-based messaging templates.")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves long messages even with generic openers", () => {
|
||||
const longMsg = "I see you've shared an update. " + "Here are the detailed changes I made to the configuration. ".repeat(10);
|
||||
expect(isGenericAssistantMessage(longMsg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripNoiseFromContent
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("stripNoiseFromContent", () => {
|
||||
it("removes conversation metadata JSON blocks", () => {
|
||||
const input = `Conversation info (untrusted metadata):
|
||||
\`\`\`json
|
||||
{
|
||||
"message_id": "499",
|
||||
"sender": "6039555582"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
What models are you currently using?`;
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toBe("What models are you currently using?");
|
||||
});
|
||||
|
||||
it("removes media attachment lines", () => {
|
||||
const input = "[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here";
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toContain("Actual question here");
|
||||
expect(result).not.toContain("[media attached:");
|
||||
});
|
||||
|
||||
it("removes image sending boilerplate", () => {
|
||||
const input =
|
||||
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg. Keep caption in the text body.\nReal content here";
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toContain("Real content here");
|
||||
expect(result).not.toContain("prefer the message tool");
|
||||
});
|
||||
|
||||
it("preserves content when no noise is present", () => {
|
||||
const input = "User wants to deploy to production via Vercel.";
|
||||
expect(stripNoiseFromContent(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("collapses excessive blank lines after stripping", () => {
|
||||
const input = "Line one\n\n\n\n\nLine two";
|
||||
expect(stripNoiseFromContent(input)).toBe("Line one\n\nLine two");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterMessagesForExtraction
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("filterMessagesForExtraction", () => {
|
||||
it("drops noise messages entirely", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "HEARTBEAT_OK" },
|
||||
{ role: "assistant", content: "Real response with durable facts." },
|
||||
{ role: "user", content: "ok" },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe("Real response with durable facts.");
|
||||
});
|
||||
|
||||
it("strips noise fragments but keeps the rest", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `Conversation info (untrusted metadata):
|
||||
\`\`\`json
|
||||
{
|
||||
"message_id": "123",
|
||||
"sender": "456"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
What is the deployment plan?`,
|
||||
},
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe("What is the deployment plan?");
|
||||
});
|
||||
|
||||
it("truncates long messages", () => {
|
||||
const longContent = "A".repeat(3000);
|
||||
const messages = [{ role: "assistant", content: longContent }];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content.length).toBeLessThan(2100);
|
||||
expect(result[0].content).toContain("[...truncated]");
|
||||
});
|
||||
|
||||
it("returns empty array when all messages are noise", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "NO_REPLY" },
|
||||
{ role: "user", content: "ok" },
|
||||
{ role: "user", content: "Current time: Friday, February 20th, 2026" },
|
||||
];
|
||||
expect(filterMessagesForExtraction(messages)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles a realistic mixed payload", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Pre-compaction memory flush. Store durable memories now." },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "## What I Accomplished\n\nDeployed the API to production with Vercel.",
|
||||
},
|
||||
{ role: "user", content: "sir" },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toContain("Deployed the API");
|
||||
});
|
||||
|
||||
it("drops generic assistant acknowledgments", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "[ASSISTANT]: Updated the Google Sheet with truth-based scripts." },
|
||||
{ role: "assistant", content: "I see you've shared an update. How can I help?" },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].role).toBe("user");
|
||||
expect(result[0].content).toContain("Google Sheet");
|
||||
});
|
||||
|
||||
it("returns only assistant messages when all user messages are noise", () => {
|
||||
// This scenario triggers the #2 guard: no user content remains
|
||||
const messages = [
|
||||
{ role: "user", content: "ok" },
|
||||
{ role: "user", content: "HEARTBEAT_OK" },
|
||||
{ role: "assistant", content: "I deployed the API to production." },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].role).toBe("assistant");
|
||||
// The capture hook checks: if no user messages remain, skip add()
|
||||
expect(result.some((m) => m.role === "user")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps substantive assistant messages even with generic opener", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What did you do?" },
|
||||
{ role: "assistant", content: "I deployed the API to production and configured the webhook endpoints for Stripe integration." },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+992
-1360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Per-agent memory isolation helpers.
|
||||
*
|
||||
* Multi-agent setups write/read from separate userId namespaces
|
||||
* automatically via sessionKey routing.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Trigger filtering — skip non-interactive sessions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Triggers that should NOT run autocapture/autorecall.
|
||||
* These are system-initiated sessions (cron jobs, heartbeats, automation
|
||||
* pipelines) whose prompts would pollute the user's memory store.
|
||||
*/
|
||||
const SKIP_TRIGGERS = new Set(["cron", "heartbeat", "automation", "schedule"]);
|
||||
|
||||
/**
|
||||
* Returns true if the session trigger is non-interactive and memory
|
||||
* hooks should be skipped entirely.
|
||||
*
|
||||
* Also detects cron-style session keys (e.g. "agent:main:cron:<id>")
|
||||
* as a fallback when the trigger field is not set.
|
||||
*/
|
||||
export function isNonInteractiveTrigger(
|
||||
trigger: string | undefined,
|
||||
sessionKey: string | undefined,
|
||||
): boolean {
|
||||
if (trigger && SKIP_TRIGGERS.has(trigger.toLowerCase())) return true;
|
||||
|
||||
// Fallback: detect cron/heartbeat from the session key pattern
|
||||
if (sessionKey) {
|
||||
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the session key indicates a subagent (ephemeral) session.
|
||||
* Subagent UUIDs are random per-spawn, so their namespaces are always empty
|
||||
* on recall and orphaned after capture.
|
||||
*/
|
||||
export function isSubagentSession(sessionKey: string | undefined): boolean {
|
||||
if (!sessionKey) return false;
|
||||
return /:subagent:/i.test(sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an agent ID from a session key.
|
||||
*
|
||||
* OpenClaw session key formats:
|
||||
* - Main agent: "agent:main:main"
|
||||
* - Subagent: "agent:main:subagent:<uuid>"
|
||||
* - Named agent: "agent:<agentId>:<session>"
|
||||
*
|
||||
* Returns the subagent UUID for subagent sessions, the agentId for
|
||||
* non-"main" named agents, or undefined for the main agent session.
|
||||
*/
|
||||
export function extractAgentId(sessionKey: string | undefined): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
// Check for subagent pattern: "agent:<parent>:subagent:<uuid>"
|
||||
const subagentMatch = sessionKey.match(/:subagent:([^:]+)$/);
|
||||
if (subagentMatch?.[1]) return `subagent-${subagentMatch[1]}`;
|
||||
|
||||
// Check for named agent pattern: "agent:<agentId>:<session>"
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"mem0ai": "^2.2.1"
|
||||
"mem0ai": "^2.3.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
|
||||
Generated
+118
-1
@@ -12,12 +12,15 @@ importers:
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
mem0ai:
|
||||
specifier: ^2.2.1
|
||||
specifier: ^2.3.0
|
||||
version: 2.4.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.15.0
|
||||
version: 22.19.15
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))
|
||||
tsup:
|
||||
specifier: ^8.5.0
|
||||
version: 8.5.1(postcss@8.5.8)(typescript@5.9.3)
|
||||
@@ -96,10 +99,27 @@ packages:
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.0':
|
||||
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@cfworker/json-schema@4.1.1':
|
||||
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||
|
||||
@@ -717,6 +737,15 @@ packages:
|
||||
resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@vitest/coverage-v8@4.1.0':
|
||||
resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.0
|
||||
vitest: 4.1.0
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.0':
|
||||
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
|
||||
|
||||
@@ -790,6 +819,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -1176,6 +1208,9 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1228,6 +1263,18 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
@@ -1258,6 +1305,9 @@ packages:
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1402,6 +1452,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2235,8 +2292,21 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.29.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
|
||||
'@cloudflare/workers-types@4.20260313.1': {}
|
||||
@@ -2733,6 +2803,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.0
|
||||
ast-v8-to-istanbul: 1.0.0
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4))
|
||||
|
||||
'@vitest/expect@4.1.0':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -2802,6 +2886,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.6:
|
||||
@@ -3207,6 +3297,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -3251,6 +3343,19 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@@ -3300,6 +3405,8 @@ snapshots:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
@@ -3418,6 +3525,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.2:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
md5@2.3.0:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
approveBuilds: esbuild
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Mem0 provider implementations: Platform (cloud) and OSS (self-hosted).
|
||||
*/
|
||||
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
Mem0Config,
|
||||
Mem0Provider,
|
||||
AddOptions,
|
||||
SearchOptions,
|
||||
ListOptions,
|
||||
MemoryItem,
|
||||
AddResult,
|
||||
} from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Result Normalizers
|
||||
// ============================================================================
|
||||
|
||||
function normalizeMemoryItem(raw: any): MemoryItem {
|
||||
return {
|
||||
id: raw.id ?? raw.memory_id ?? "",
|
||||
memory: raw.memory ?? raw.text ?? raw.content ?? "",
|
||||
// Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names
|
||||
user_id: raw.user_id ?? raw.userId,
|
||||
score: raw.score,
|
||||
categories: raw.categories,
|
||||
metadata: raw.metadata,
|
||||
created_at: raw.created_at ?? raw.createdAt,
|
||||
updated_at: raw.updated_at ?? raw.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchResults(raw: any): MemoryItem[] {
|
||||
// Platform API returns flat array, OSS returns { results: [...] }
|
||||
if (Array.isArray(raw)) return raw.map(normalizeMemoryItem);
|
||||
if (raw?.results && Array.isArray(raw.results))
|
||||
return raw.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeAddResult(raw: any): AddResult {
|
||||
// Handle { results: [...] } shape (both platform and OSS)
|
||||
if (raw?.results && Array.isArray(raw.results)) {
|
||||
return {
|
||||
results: raw.results.map((r: any) => ({
|
||||
id: r.id ?? r.memory_id ?? "",
|
||||
memory: r.memory ?? r.text ?? "",
|
||||
// Platform API may return PENDING status (async processing)
|
||||
// OSS stores event in metadata.event
|
||||
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Platform API without output_format returns flat array
|
||||
if (Array.isArray(raw)) {
|
||||
return {
|
||||
results: raw.map((r: any) => ({
|
||||
id: r.id ?? r.memory_id ?? "",
|
||||
memory: r.memory ?? r.text ?? "",
|
||||
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Platform Provider (Mem0 Cloud)
|
||||
// ============================================================================
|
||||
|
||||
class PlatformProvider implements Mem0Provider {
|
||||
private client: any; // MemoryClient from mem0ai
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly orgId?: string,
|
||||
private readonly projectId?: string,
|
||||
) { }
|
||||
|
||||
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;
|
||||
});
|
||||
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 };
|
||||
if (this.orgId) opts.org_id = this.orgId;
|
||||
if (this.projectId) opts.project_id = this.projectId;
|
||||
this.client = new MemoryClient(opts);
|
||||
}
|
||||
|
||||
async add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult> {
|
||||
await this.ensureClient();
|
||||
const opts: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) opts.run_id = options.run_id;
|
||||
if (options.custom_instructions)
|
||||
opts.custom_instructions = options.custom_instructions;
|
||||
if (options.custom_categories)
|
||||
opts.custom_categories = options.custom_categories;
|
||||
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
||||
if (options.output_format) opts.output_format = options.output_format;
|
||||
if (options.source) opts.source = options.source;
|
||||
|
||||
const result = await this.client.add(messages, opts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
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;
|
||||
|
||||
const results = await this.client.search(query, opts);
|
||||
return normalizeSearchResults(results);
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem> {
|
||||
await this.ensureClient();
|
||||
const result = await this.client.get(memoryId);
|
||||
return normalizeMemoryItem(result);
|
||||
}
|
||||
|
||||
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
const opts: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) opts.run_id = options.run_id;
|
||||
if (options.page_size != null) opts.page_size = options.page_size;
|
||||
if (options.source) opts.source = options.source;
|
||||
|
||||
const results = await this.client.getAll(opts);
|
||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||
// Some versions return { results: [...] }
|
||||
if (results?.results && Array.isArray(results.results))
|
||||
return results.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.delete(memoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Open-Source Provider (Self-hosted)
|
||||
// ============================================================================
|
||||
|
||||
class OSSProvider implements Mem0Provider {
|
||||
private memory: any; // Memory from mem0ai/oss
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ossConfig?: Mem0Config["oss"],
|
||||
private readonly customPrompt?: string,
|
||||
private readonly resolvePath?: (p: string) => string,
|
||||
) { }
|
||||
|
||||
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;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const { Memory } = await import("mem0ai/oss");
|
||||
|
||||
const config: Record<string, unknown> = { version: "v1.1" };
|
||||
|
||||
if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder;
|
||||
if (this.ossConfig?.vectorStore)
|
||||
config.vectorStore = this.ossConfig.vectorStore;
|
||||
if (this.ossConfig?.llm) config.llm = this.ossConfig.llm;
|
||||
|
||||
if (this.ossConfig?.historyDbPath) {
|
||||
const dbPath = this.resolvePath
|
||||
? this.resolvePath(this.ossConfig.historyDbPath)
|
||||
: this.ossConfig.historyDbPath;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult> {
|
||||
await this.ensureMemory();
|
||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) addOpts.runId = options.run_id;
|
||||
if (options.source) addOpts.source = options.source;
|
||||
const result = await this.memory.add(messages, addOpts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureMemory();
|
||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||
const opts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) opts.runId = options.run_id;
|
||||
if (options.limit != null) opts.limit = options.limit;
|
||||
else if (options.top_k != null) opts.limit = options.top_k;
|
||||
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
||||
if (options.reranking != null) opts.reranking = options.reranking;
|
||||
if (options.source) opts.source = options.source;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
|
||||
const results = await this.memory.search(query, opts);
|
||||
const normalized = normalizeSearchResults(results);
|
||||
|
||||
// Filter results by threshold if specified (client-side filtering as fallback)
|
||||
if (options.threshold != null) {
|
||||
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem> {
|
||||
await this.ensureMemory();
|
||||
const result = await this.memory.get(memoryId);
|
||||
return normalizeMemoryItem(result);
|
||||
}
|
||||
|
||||
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureMemory();
|
||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) getAllOpts.runId = options.run_id;
|
||||
if (options.source) getAllOpts.source = options.source;
|
||||
const results = await this.memory.getAll(getAllOpts);
|
||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||
if (results?.results && Array.isArray(results.results))
|
||||
return results.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.delete(memoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createProvider(
|
||||
cfg: Mem0Config,
|
||||
api: OpenClawPluginApi,
|
||||
): Mem0Provider {
|
||||
if (cfg.mode === "open-source") {
|
||||
return new OSSProvider(cfg.oss, cfg.customPrompt, (p) =>
|
||||
api.resolvePath(p),
|
||||
);
|
||||
}
|
||||
|
||||
return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Tests for SQLite resilience fixes:
|
||||
* 1. disableHistory config passthrough
|
||||
* 2. initPromise poisoning fix (retry after failure)
|
||||
* 3. Graceful SQLite fallback in OSSProvider
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mem0ConfigSchema, createProvider } from "./index.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config: disableHistory passthrough
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema — disableHistory", () => {
|
||||
const baseConfig = {
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
|
||||
},
|
||||
};
|
||||
|
||||
it("preserves oss.disableHistory: true through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves oss.disableHistory: false through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: false },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(false);
|
||||
});
|
||||
|
||||
it("omits disableHistory when not provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse(baseConfig);
|
||||
expect(cfg.oss?.disableHistory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reject unknown keys inside oss object", () => {
|
||||
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
|
||||
expect(() =>
|
||||
mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. OSSProvider: disableHistory flows to Memory constructor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — disableHistory passthrough to Memory", () => {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
let memoryCallCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfig = undefined;
|
||||
memoryCallCount = 0;
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
memoryCallCount++;
|
||||
capturedConfig = { ...config };
|
||||
}
|
||||
async add() { return { results: [] }; }
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("passes disableHistory: true to Memory when configured", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Trigger lazy init by calling search
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch { /* provider may fail on mock, that's ok */ }
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set disableHistory when not configured", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch { }
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
|
||||
}
|
||||
// Second+ call succeeds
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call: _init throws, but initPromise is cleared so retry is possible
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("SQLITE_CANTOPEN");
|
||||
|
||||
// Second call: should retry _init (not return cached rejection)
|
||||
// callCount === 1 threw, so callCount === 2 should succeed
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OSSProvider: graceful fallback disables history on init failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — graceful SQLite fallback", () => {
|
||||
let capturedConfigs: Record<string, unknown>[];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfigs = [];
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
capturedConfigs.push({ ...config });
|
||||
if (!config.disableHistory) {
|
||||
throw new Error("Could not locate the bindings file");
|
||||
}
|
||||
// Succeeds when disableHistory is true
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries with disableHistory: true when initial construction fails", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should succeed — first attempt fails, fallback with disableHistory succeeds
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
|
||||
// Memory constructor was called twice
|
||||
expect(capturedConfigs).toHaveLength(2);
|
||||
expect(capturedConfigs[0].disableHistory).toBeFalsy();
|
||||
expect(capturedConfigs[1].disableHistory).toBe(true);
|
||||
|
||||
// Warning was logged
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[mem0] Memory initialization failed"),
|
||||
expect.stringContaining("bindings file"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not retry when disableHistory is already true", async () => {
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
// Fail even with disableHistory (e.g. vector store issue)
|
||||
throw new Error("vector store connection refused");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should throw — no fallback possible when disableHistory was already set
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("vector store connection refused");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. PlatformProvider — initPromise retry after failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("PlatformProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
|
||||
vi.doMock("mem0ai", () => ({
|
||||
default: class MockMemoryClient {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("Network timeout");
|
||||
}
|
||||
}
|
||||
async search() { return []; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() { }
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "platform",
|
||||
apiKey: "test-api-key",
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call fails
|
||||
await expect(
|
||||
provider.search("test", { user_id: "u1" }),
|
||||
).rejects.toThrow("Network timeout");
|
||||
|
||||
// Second call should retry (not return cached rejection)
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,10 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
|
||||
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Shared type definitions for the OpenClaw Mem0 plugin.
|
||||
*/
|
||||
|
||||
export type Mem0Mode = "platform" | "open-source";
|
||||
|
||||
export type Mem0Config = {
|
||||
mode: Mem0Mode;
|
||||
// Platform-specific
|
||||
apiKey?: string;
|
||||
orgId?: string;
|
||||
projectId?: string;
|
||||
customInstructions: string;
|
||||
customCategories: Record<string, string>;
|
||||
enableGraph: boolean;
|
||||
// OSS-specific
|
||||
customPrompt?: string;
|
||||
oss?: {
|
||||
embedder?: { provider: string; config: Record<string, unknown> };
|
||||
vectorStore?: { provider: string; config: Record<string, unknown> };
|
||||
llm?: { provider: string; config: Record<string, unknown> };
|
||||
historyDbPath?: string;
|
||||
disableHistory?: boolean;
|
||||
};
|
||||
// Shared
|
||||
userId: string;
|
||||
autoCapture: boolean;
|
||||
autoRecall: boolean;
|
||||
searchThreshold: number;
|
||||
topK: number;
|
||||
};
|
||||
|
||||
export interface AddOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
custom_instructions?: string;
|
||||
custom_categories?: Array<Record<string, string>>;
|
||||
enable_graph?: boolean;
|
||||
output_format?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
top_k?: number;
|
||||
threshold?: number;
|
||||
limit?: number;
|
||||
keyword_search?: boolean;
|
||||
reranking?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
page_size?: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string;
|
||||
memory: string;
|
||||
user_id?: string;
|
||||
score?: number;
|
||||
categories?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AddResultItem {
|
||||
id: string;
|
||||
memory: string;
|
||||
event: "ADD" | "UPDATE" | "DELETE" | "NOOP";
|
||||
}
|
||||
|
||||
export interface AddResult {
|
||||
results: AddResultItem[];
|
||||
}
|
||||
|
||||
export interface Mem0Provider {
|
||||
add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult>;
|
||||
search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
|
||||
get(memoryId: string): Promise<MemoryItem>;
|
||||
getAll(options: ListOptions): Promise<MemoryItem[]>;
|
||||
delete(memoryId: string): Promise<void>;
|
||||
}
|
||||
+33
-1
@@ -66,7 +66,39 @@ You can do this in one of the following ways:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id> # The User Id you want to associate the memories with
|
||||
USER=<user-id> # The User Id you want to associate the memories with
|
||||
```
|
||||
|
||||
- #### LLM Configuration (optional)
|
||||
|
||||
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
|
||||
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
|
||||
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
|
||||
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
|
||||
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
|
||||
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
|
||||
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
|
||||
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
|
||||
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
|
||||
|
||||
**Example: Using Ollama (fully local)**
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=llama3.1:latest
|
||||
EMBEDDER_PROVIDER=ollama
|
||||
EMBEDDER_MODEL=nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
**Example: Using Anthropic**
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-sonnet-4-20250514
|
||||
LLM_API_KEY=sk-ant-xxx
|
||||
```
|
||||
- #### Example `/ui/.env`
|
||||
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=user
|
||||
USER=user
|
||||
|
||||
# LLM Configuration (optional - defaults to openai/gpt-4o-mini)
|
||||
# LLM_PROVIDER=ollama
|
||||
# LLM_MODEL=llama3.1:latest
|
||||
# LLM_API_KEY=
|
||||
# LLM_BASE_URL=
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Embedder Configuration (optional - defaults to openai/text-embedding-3-small)
|
||||
# EMBEDDER_PROVIDER=ollama
|
||||
# EMBEDDER_MODEL=nomic-embed-text
|
||||
# EMBEDDER_API_KEY=
|
||||
# EMBEDDER_BASE_URL=
|
||||
|
||||
@@ -133,6 +133,97 @@ def reset_memory_client():
|
||||
_config_hash = None
|
||||
|
||||
|
||||
# --- LLM provider config factories ---
|
||||
|
||||
def _build_ollama_llm_config(model, api_key, base_url, ollama_base_url):
|
||||
config = {"model": model or "llama3.1:latest"}
|
||||
# OLLAMA_BASE_URL takes precedence, then LLM_BASE_URL, then default
|
||||
config["ollama_base_url"] = ollama_base_url or base_url or "http://localhost:11434"
|
||||
return config
|
||||
|
||||
|
||||
def _build_openai_llm_config(model, api_key, base_url, ollama_base_url):
|
||||
config = {
|
||||
"model": model or "gpt-4o-mini",
|
||||
"api_key": api_key or "env:OPENAI_API_KEY",
|
||||
}
|
||||
if base_url:
|
||||
config["openai_base_url"] = base_url
|
||||
return config
|
||||
|
||||
|
||||
_LLM_CONFIG_FACTORIES = {
|
||||
"ollama": _build_ollama_llm_config,
|
||||
"openai": _build_openai_llm_config,
|
||||
}
|
||||
|
||||
|
||||
def _create_llm_config(provider, model, api_key, base_url, ollama_base_url):
|
||||
"""Build LLM config using registered provider factory or generic fallback."""
|
||||
base_config = {
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
|
||||
factory = _LLM_CONFIG_FACTORIES.get(provider)
|
||||
if factory:
|
||||
base_config.update(factory(model, api_key, base_url, ollama_base_url))
|
||||
else:
|
||||
# Generic provider (anthropic, groq, together, deepseek, etc.)
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"LLM_MODEL environment variable is required when using LLM_PROVIDER='{provider}'. "
|
||||
f"Set LLM_MODEL to a valid model name for the '{provider}' provider."
|
||||
)
|
||||
base_config["model"] = model
|
||||
if api_key:
|
||||
base_config["api_key"] = api_key
|
||||
|
||||
return base_config
|
||||
|
||||
|
||||
# --- Embedder provider config factories ---
|
||||
|
||||
def _build_ollama_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
config = {"model": model or "nomic-embed-text"}
|
||||
config["ollama_base_url"] = base_url or ollama_base_url or llm_base_url or "http://localhost:11434"
|
||||
return config
|
||||
|
||||
|
||||
def _build_openai_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
config = {
|
||||
"model": model or "text-embedding-3-small",
|
||||
"api_key": api_key or "env:OPENAI_API_KEY",
|
||||
}
|
||||
if base_url:
|
||||
config["openai_base_url"] = base_url
|
||||
return config
|
||||
|
||||
|
||||
_EMBEDDER_CONFIG_FACTORIES = {
|
||||
"ollama": _build_ollama_embedder_config,
|
||||
"openai": _build_openai_embedder_config,
|
||||
}
|
||||
|
||||
|
||||
def _create_embedder_config(provider, model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
"""Build embedder config using registered provider factory or generic fallback."""
|
||||
factory = _EMBEDDER_CONFIG_FACTORIES.get(provider)
|
||||
if factory:
|
||||
config = factory(model, api_key, base_url, ollama_base_url, llm_base_url)
|
||||
else:
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"EMBEDDER_MODEL environment variable is required when using EMBEDDER_PROVIDER='{provider}'. "
|
||||
f"Set EMBEDDER_MODEL to a valid model name for the '{provider}' provider."
|
||||
)
|
||||
config = {"model": model}
|
||||
if api_key:
|
||||
config["api_key"] = api_key
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_default_memory_config():
|
||||
"""Get default memory client configuration with sensible defaults."""
|
||||
# Detect vector store based on environment variables
|
||||
@@ -235,27 +326,51 @@ def get_default_memory_config():
|
||||
})
|
||||
|
||||
print(f"Auto-detected vector store: {vector_store_provider} with config: {vector_store_config}")
|
||||
|
||||
|
||||
# Detect LLM provider from environment variables
|
||||
llm_provider = os.environ.get('LLM_PROVIDER', 'openai').lower()
|
||||
llm_model = os.environ.get('LLM_MODEL')
|
||||
llm_api_key = os.environ.get('LLM_API_KEY')
|
||||
llm_base_url = os.environ.get('LLM_BASE_URL')
|
||||
ollama_base_url = os.environ.get('OLLAMA_BASE_URL')
|
||||
|
||||
llm_config = _create_llm_config(
|
||||
provider=llm_provider,
|
||||
model=llm_model,
|
||||
api_key=llm_api_key,
|
||||
base_url=llm_base_url,
|
||||
ollama_base_url=ollama_base_url,
|
||||
)
|
||||
print(f"Auto-detected LLM provider: {llm_provider}")
|
||||
|
||||
# Detect embedder provider from environment variables
|
||||
embedder_provider = os.environ.get('EMBEDDER_PROVIDER', llm_provider if llm_provider == 'ollama' else 'openai').lower()
|
||||
embedder_model = os.environ.get('EMBEDDER_MODEL')
|
||||
embedder_api_key = os.environ.get('EMBEDDER_API_KEY')
|
||||
embedder_base_url = os.environ.get('EMBEDDER_BASE_URL')
|
||||
|
||||
embedder_config = _create_embedder_config(
|
||||
provider=embedder_provider,
|
||||
model=embedder_model,
|
||||
api_key=embedder_api_key,
|
||||
base_url=embedder_base_url,
|
||||
ollama_base_url=ollama_base_url,
|
||||
llm_base_url=llm_base_url,
|
||||
)
|
||||
print(f"Auto-detected embedder provider: {embedder_provider}")
|
||||
|
||||
return {
|
||||
"vector_store": {
|
||||
"provider": vector_store_provider,
|
||||
"config": vector_store_config
|
||||
},
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
"provider": llm_provider,
|
||||
"config": llm_config
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "text-embedding-3-small",
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
"provider": embedder_provider,
|
||||
"config": embedder_config
|
||||
},
|
||||
"version": "v1.1"
|
||||
}
|
||||
@@ -327,18 +442,10 @@ def get_memory_client(custom_instructions: str = None):
|
||||
# Update LLM configuration if available
|
||||
if "llm" in mem0_config and mem0_config["llm"] is not None:
|
||||
config["llm"] = mem0_config["llm"]
|
||||
|
||||
# Fix Ollama URLs for Docker if needed
|
||||
if config["llm"].get("provider") == "ollama":
|
||||
config["llm"] = _fix_ollama_urls(config["llm"])
|
||||
|
||||
|
||||
# Update Embedder configuration if available
|
||||
if "embedder" in mem0_config and mem0_config["embedder"] is not None:
|
||||
config["embedder"] = mem0_config["embedder"]
|
||||
|
||||
# Fix Ollama URLs for Docker if needed
|
||||
if config["embedder"].get("provider") == "ollama":
|
||||
config["embedder"] = _fix_ollama_urls(config["embedder"])
|
||||
|
||||
if "vector_store" in mem0_config and mem0_config["vector_store"] is not None:
|
||||
config["vector_store"] = mem0_config["vector_store"]
|
||||
@@ -357,6 +464,12 @@ def get_memory_client(custom_instructions: str = None):
|
||||
if instructions_to_use:
|
||||
config["custom_fact_extraction_prompt"] = instructions_to_use
|
||||
|
||||
# Fix Ollama URLs for Docker environment (applies to both env-var defaults and DB overrides)
|
||||
if config.get("llm", {}).get("provider") == "ollama":
|
||||
config["llm"] = _fix_ollama_urls(config["llm"])
|
||||
if config.get("embedder", {}).get("provider") == "ollama":
|
||||
config["embedder"] = _fix_ollama_urls(config["embedder"])
|
||||
|
||||
# ALWAYS parse environment variables in the final config
|
||||
# This ensures that even default config values like "env:OPENAI_API_KEY" get parsed
|
||||
print("Parsing environment variables in final config...")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user